From 4c3274e621a34ef0644ac709cbbef88fca78f9dd Mon Sep 17 00:00:00 2001 From: Zaytsev Kirill Date: Sun, 23 May 2021 01:44:02 +0300 Subject: [PATCH] refactor: Simplified logic of field components registry, moved value set logic from registry to form component --- src/FormularioField.vue | 24 +++--- src/FormularioFieldGroup.vue | 4 +- src/FormularioForm.vue | 73 ++++++++++------ src/FormularioFormRegistry.ts | 142 ------------------------------- src/PathRegistry.ts | 82 ++++++++++++++++++ test/unit/FormularioForm.test.js | 57 ------------- test/unit/PathRegistry.test.js | 50 +++++++++++ 7 files changed, 192 insertions(+), 240 deletions(-) delete mode 100644 src/FormularioFormRegistry.ts create mode 100644 src/PathRegistry.ts create mode 100644 test/unit/PathRegistry.test.js diff --git a/src/FormularioField.vue b/src/FormularioField.vue index 3ed6d58..fd2f71e 100644 --- a/src/FormularioField.vue +++ b/src/FormularioField.vue @@ -73,7 +73,7 @@ export default class FormularioField extends Vue { validator: behavior => Object.values(VALIDATION_BEHAVIOR).includes(behavior) }) validationBehavior!: string - // Affects only observing & setting of local errors + // Affects only setting of local errors @Prop({ default: false }) errorsDisabled!: boolean @Prop({ default: () => (value: U|Empty): U|T|Empty => value }) modelGetConverter!: ModelGetConverter @@ -87,10 +87,17 @@ export default class FormularioField extends Vue { private validationRun: Promise = Promise.resolve([]) - public get fullQualifiedName (): string { + public get fullPath (): string { return this.__Formulario_path !== '' ? `${this.__Formulario_path}.${this.name}` : this.name } + /** + * Determines if this formulario element is v-modeled or not. + */ + get hasModel (): boolean { + return has(this.$options.propsData || {}, 'value') + } + private get model (): unknown { const model = this.hasModel ? 'value' : 'proxy' return this.modelGetConverter(this[model]) @@ -112,7 +119,7 @@ export default class FormularioField extends Vue { private get context (): FormularioFieldContext { return Object.defineProperty({ - name: this.fullQualifiedName, + name: this.fullPath, runValidation: this.runValidation.bind(this), violations: this.violations, errors: this.localErrors, @@ -141,13 +148,6 @@ export default class FormularioField extends Vue { return messages } - /** - * Determines if this formulario element is v-modeled or not. - */ - private get hasModel (): boolean { - return has(this.$options.propsData || {}, 'value') - } - @Watch('proxy') private onProxyChange (newValue: unknown, oldValue: unknown): void { if (!this.hasModel && !shallowEquals(newValue, oldValue)) { @@ -170,7 +170,7 @@ export default class FormularioField extends Vue { created (): void { this.initProxy() if (typeof this.__FormularioForm_register === 'function') { - this.__FormularioForm_register(this.fullQualifiedName, this) + this.__FormularioForm_register(this.fullPath, this) } if (this.validationBehavior === VALIDATION_BEHAVIOR.LIVE) { this.runValidation() @@ -179,7 +179,7 @@ export default class FormularioField extends Vue { beforeDestroy (): void { if (typeof this.__FormularioForm_unregister === 'function') { - this.__FormularioForm_unregister(this.fullQualifiedName) + this.__FormularioForm_unregister(this.fullPath) } } diff --git a/src/FormularioFieldGroup.vue b/src/FormularioFieldGroup.vue index f1fe108..5dd9190 100644 --- a/src/FormularioFieldGroup.vue +++ b/src/FormularioFieldGroup.vue @@ -24,7 +24,7 @@ export default class FormularioFieldGroup extends Vue { readonly isArrayItem!: boolean @Provide('__Formulario_path') - get groupPath (): string { + get fullPath (): string { if (this.isArrayItem) { return `${this.__Formulario_path}[${this.name}]` } @@ -33,7 +33,7 @@ export default class FormularioFieldGroup extends Vue { return this.name } - return `${this.path}.${this.name}` + return `${this.__Formulario_path}.${this.name}` } } diff --git a/src/FormularioForm.vue b/src/FormularioForm.vue index 09e421b..a969f53 100644 --- a/src/FormularioForm.vue +++ b/src/FormularioForm.vue @@ -22,7 +22,7 @@ import { shallowEquals, } from '@/utils' -import FormularioFormRegistry from '@/FormularioFormRegistry' +import PathRegistry from '@/PathRegistry' import FormularioField from '@/FormularioField.vue' @@ -45,7 +45,7 @@ export default class FormularioForm extends Vue { public proxy: Record = {} - private registry: FormularioFormRegistry = new FormularioFormRegistry(this) + private registry: PathRegistry = new PathRegistry() // Local error messages are temporal, they wiped each resetValidation call private localFormErrors: string[] = [] @@ -85,13 +85,13 @@ export default class FormularioForm extends Vue { @Watch('mergedFieldErrors', { deep: true, immediate: true }) onMergedFieldErrorsChange (errors: Record): void { - this.registry.forEach((vm, path) => { - vm.setErrors(errors[path] || []) + this.registry.forEach((field, path) => { + field.setErrors(errors[path] || []) }) } created (): void { - this.initProxy() + this.syncProxy() } @Provide('__FormularioForm_getValue') @@ -112,58 +112,77 @@ export default class FormularioForm extends Vue { } @Provide('__FormularioForm_emitValidation') - onFormularioFieldValidation (payload: ValidationEventPayload): void { + private emitValidation (payload: ValidationEventPayload): void { this.$emit('validation', payload) } @Provide('__FormularioForm_register') - private register (field: string, vm: FormularioField): void { - this.registry.add(field, vm) + private register (path: string, field: FormularioField): void { + this.registry.add(path, field) - if (has(this.mergedFieldErrors, field)) { - vm.setErrors(this.mergedFieldErrors[field] || []) + const value = getNested(this.initialValues, path) + + if (!field.hasModel && this.hasInitialValue && value !== undefined) { + // In the case that the form is carrying an initial value and the + // element is not, set it directly. + // @ts-ignore + field.context.model = value + } else if (field.hasModel && !shallowEquals(field.proxy, value)) { + // In this case, the field is v-modeled or has an initial value and the + // form has no value or a different value, so use the field value + this.setFieldValueAndEmit(path, field.proxy) + } + + if (has(this.mergedFieldErrors, path)) { + field.setErrors(this.mergedFieldErrors[path] || []) } } @Provide('__FormularioForm_unregister') - private unregister (field: string): void { - if (this.registry.has(field)) { - this.registry.remove(field) + private unregister (path: string): void { + if (this.registry.has(path)) { + this.registry.remove(path) + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { [path]: _, ...newProxy } = this.proxy + this.proxy = newProxy } } - initProxy (): void { + syncProxy (): void { if (this.hasInitialValue) { this.proxy = this.initialValues } } - setValues (values: Record): void { - const keys = Array.from(new Set([...Object.keys(values), ...Object.keys(this.proxy)])) + setValues (state: Record): void { + const paths = Array.from(new Set([ + ...Object.keys(state), + ...Object.keys(this.proxy), + ])) + let proxyHasChanges = false - keys.forEach(field => { - if (!this.registry.hasNested(field)) { + + paths.forEach(path => { + if (!this.registry.hasSubset(path)) { return } - this.registry.getNested(field).forEach((_, fqn) => { - const $field = this.registry.get(fqn) as FormularioField - - const oldValue = getNested(this.proxy, fqn) - const newValue = getNested(values, fqn) + this.registry.getSubset(path).forEach((field, path) => { + const oldValue = getNested(this.proxy, path) + const newValue = getNested(state, path) if (!shallowEquals(newValue, oldValue)) { - this.setFieldValue(fqn, newValue) + this.setFieldValue(path, newValue) proxyHasChanges = true } - if (!shallowEquals(newValue, $field.proxy)) { - $field.context.model = newValue + if (!shallowEquals(newValue, field.proxy)) { + field.context.model = newValue } }) }) - this.initProxy() + this.syncProxy() if (proxyHasChanges) { this.$emit('input', { ...this.proxy }) diff --git a/src/FormularioFormRegistry.ts b/src/FormularioFormRegistry.ts deleted file mode 100644 index 574b9d4..0000000 --- a/src/FormularioFormRegistry.ts +++ /dev/null @@ -1,142 +0,0 @@ -import { getNested, has, shallowEquals } from '@/utils' - -import FormularioField from '@/FormularioField.vue' -import FormularioForm from '@/FormularioForm.vue' - -/** - * Component registry with inherent depth to handle complex nesting. This is - * important for features such as grouped fields. - */ -export default class FormularioFormRegistry { - private ctx: FormularioForm - private registry: Map - - /** - * Create a new registry of components. - * @param {FormularioForm} ctx The host vm context of the registry. - */ - constructor (ctx: FormularioForm) { - this.registry = new Map() - this.ctx = ctx - } - - /** - * Fully register a component. - * @param {string} field name of the field. - * @param {FormularioForm} component the actual component instance. - */ - add (field: string, component: FormularioField): void { - if (this.registry.has(field)) { - return - } - - this.registry.set(field, component) - - // @ts-ignore - const value = getNested(this.ctx.initialValues, field) - const hasModel = has(component.$options.propsData || {}, 'value') - - // @ts-ignore - if (!hasModel && this.ctx.hasInitialValue && value !== undefined) { - // In the case that the form is carrying an initial value and the - // element is not, set it directly. - // @ts-ignore - component.context.model = value - // @ts-ignore - } else if (hasModel && !shallowEquals(component.proxy, value)) { - // In this case, the field is v-modeled or has an initial value and the - // form has no value or a different value, so use the field value - // @ts-ignore - this.ctx.setFieldValueAndEmit(field, component.proxy) - } - } - - /** - * Remove an item from the registry. - */ - remove (name: string): void { - this.registry.delete(name) - // @ts-ignore - // eslint-disable-next-line @typescript-eslint/no-unused-vars - const { [name]: value, ...newProxy } = this.ctx.proxy - // @ts-ignore - this.ctx.proxy = newProxy - } - - /** - * Check if the registry has the given key. - */ - has (key: string): boolean { - return this.registry.has(key) - } - - /** - * Check if the registry has elements, that equals or nested given key - */ - hasNested (key: string): boolean { - for (const i of this.registry.keys()) { - if (i === key || i.includes(key + '.')) { - return true - } - } - - return false - } - - /** - * Get a particular registry value. - */ - get (key: string): FormularioField | undefined { - return this.registry.get(key) - } - - /** - * Get registry value for key or nested to given key - */ - getNested (key: string): Map { - const result = new Map() - - for (const i of this.registry.keys()) { - const objectKey = key + '.' - const arrayKey = key + '[' - - if ( - i === key || - i.substring(0, objectKey.length) === objectKey || - i.substring(0, arrayKey.length) === arrayKey - ) { - result.set(i, this.registry.get(i)) - } - } - - return result - } - - /** - * Iterate over the registry. - */ - forEach (callback: (component: FormularioField, field: string) => void): void { - this.registry.forEach((component, field) => { - callback(component, field) - }) - } - - /** - * Return the keys of the registry. - */ - keys (): string[] { - return Array.from(this.registry.keys()) - } - - /** - * Reduce the registry. - * @param {function} callback - * @param accumulator - */ - reduce (callback: Function, accumulator: U): U { - this.registry.forEach((component, field) => { - accumulator = callback(accumulator, component, field) - }) - return accumulator - } -} diff --git a/src/PathRegistry.ts b/src/PathRegistry.ts new file mode 100644 index 0000000..1c02572 --- /dev/null +++ b/src/PathRegistry.ts @@ -0,0 +1,82 @@ +/** + * @internal + */ +export default class PathRegistry { + private registry: Map + + constructor () { + this.registry = new Map() + } + + has (path: string): boolean { + return this.registry.has(path) + } + + hasSubset (path: string): boolean { + for (const itemPath of this.registry.keys()) { + if (itemPath === path || itemPath.includes(path + '.')) { + return true + } + } + + return false + } + + get (path: string): T | undefined { + return this.registry.get(path) + } + + /** + * Returns registry subset by given path - field & descendants + */ + getSubset (path: string): PathRegistry { + const subset: PathRegistry = new PathRegistry() + + for (const itemPath of this.registry.keys()) { + if ( + itemPath === path || + itemPath.startsWith(path + '.') || + itemPath.startsWith(path + '[') + ) { + subset.add(itemPath, this.registry.get(itemPath) as T) + } + } + + return subset + } + + add (path: string, item: T): void { + if (!this.registry.has(path)) { + this.registry.set(path, item) + } + } + + remove (path: string): void { + this.registry.delete(path) + } + + paths (): IterableIterator { + return this.registry.keys() + } + + /** + * Iterate over the registry. + */ + forEach (callback: (field: T, path: string) => void): void { + this.registry.forEach((field, path) => { + callback(field, path) + }) + } + + /** + * Reduce the registry. + * @param {function} callback + * @param accumulator + */ + reduce (callback: (accumulator: U, item: T, path: string) => U, accumulator: U): U { + this.registry.forEach((item, path) => { + accumulator = callback(accumulator, item, path) + }) + return accumulator + } +} diff --git a/test/unit/FormularioForm.test.js b/test/unit/FormularioForm.test.js index 94bff26..f834b23 100644 --- a/test/unit/FormularioForm.test.js +++ b/test/unit/FormularioForm.test.js @@ -38,63 +38,6 @@ describe('FormularioForm', () => { expect(spy).toHaveBeenCalled() }) - it('Adds subcomponents to the registry', () => { - const wrapper = mount(FormularioForm, { - propsData: { state: {} }, - slots: { - default: ` - - - ` - } - }) - expect(wrapper.vm['registry'].keys()).toEqual(['sub1', 'sub2']) - }) - - it('Removes subcomponents from the registry', async () => { - const wrapper = mount({ - data: () => ({ active: true }), - template: ` - - - - - ` - }) - await flushPromises() - expect(wrapper.findComponent(FormularioForm).vm['registry'].keys()).toEqual(['sub1', 'sub2']) - wrapper.setData({ active: false }) - await flushPromises() - expect(wrapper.findComponent(FormularioForm).vm['registry'].keys()).toEqual(['sub2']) - }) - - it('Getting nested fields from registry', async () => { - const wrapper = mount({ - data: () => ({ active: true, nested: { groups: { value: 'value' } }, groups: [{ name: 'group1' }, { name: 'group2' }] }), - template: ` - - - - - - - - - - - ` - }) - await flushPromises() - expect(Array.from(wrapper.findComponent(FormularioForm).vm.registry.getNested('sub1').keys())).toEqual(['sub1']) - expect(Array.from(wrapper.findComponent(FormularioForm).vm.registry.getNested('groups').keys())) - .toEqual(['groups', 'groups[0].name', 'groups[1].name']) - - wrapper.setData({ active: true, groups: [{ name: 'group1' }] }) - await flushPromises() - expect(Array.from(wrapper.findComponent(FormularioForm).vm.registry.getNested('groups').keys())) - .toEqual(['groups', 'groups[0].name']) - }) - it('Can set a field’s initial value', async () => { const wrapper = mount(FormularioForm, { propsData: { state: { test: 'Has initial value' } }, diff --git a/test/unit/PathRegistry.test.js b/test/unit/PathRegistry.test.js new file mode 100644 index 0000000..b802746 --- /dev/null +++ b/test/unit/PathRegistry.test.js @@ -0,0 +1,50 @@ +import PathRegistry from '@/PathRegistry' + +describe('PathRegistry', () => { + test ('subset structure', () => { + const registry = new PathRegistry() + + const paths = path => Array.from(registry.getSubset(path).paths()) + + registry.add('name', null) + registry.add('address', []) + registry.add('address[0]', {}) + registry.add('address[0].street', 'Baker Street') + registry.add('address[0].building', '221b') + registry.add('address[1]', {}) + registry.add('address[1].street', '') + registry.add('address[1].building', '') + + expect(paths('name')).toEqual(['name']) + expect(paths('address')).toEqual([ + 'address', + 'address[0]', + 'address[0].street', + 'address[0].building', + 'address[1]', + 'address[1].street', + 'address[1].building', + ]) + expect(paths('address[1]')).toEqual([ + 'address[1]', + 'address[1].street', + 'address[1].building', + ]) + + registry.remove('address[1]') + + expect(paths('address')).toEqual([ + 'address', + 'address[0]', + 'address[0].street', + 'address[0].building', + 'address[1].street', + 'address[1].building', + ]) + + expect(paths('address[1]')).toEqual([ + 'address[1].street', + 'address[1].building', + ]) + }) +})