From 9c758e9fbf0e47ad4b101dea22fecf2546ee790e Mon Sep 17 00:00:00 2001 From: Zaytsev Kirill Date: Wed, 11 Nov 2020 19:46:31 +0300 Subject: [PATCH 01/38] docs: Updated story of address list - added possibility to remove items from list; removed outdated story --- storybook/stories/ExampleAddressList.tale.vue | 28 ++++- storybook/stories/FormularioGrouping.tale.vue | 106 ------------------ storybook/stories/index.stories.js | 2 - 3 files changed, 25 insertions(+), 111 deletions(-) delete mode 100644 storybook/stories/FormularioGrouping.tale.vue diff --git a/storybook/stories/ExampleAddressList.tale.vue b/storybook/stories/ExampleAddressList.tale.vue index f010758..6d51450 100644 --- a/storybook/stories/ExampleAddressList.tale.vue +++ b/storybook/stories/ExampleAddressList.tale.vue @@ -42,7 +42,7 @@ v-slot="{ context }" class="col col-auto px-2 mb-3" name="building" - validation="^required|number" + validation="^required|alphanumeric" > + +
+ +
@@ -91,14 +101,18 @@ export default { data: () => ({ values: { - addressList: [], + addressList: [{ + street: 'Baker Street', + building: '221b', + }], }, }), created () { this.$formulario.extend({ validationMessages: { - number: () => 'Value is not a number', + alphanumeric: () => 'Value must be alphanumeric', + number: () => 'Value must be a number', required: () => 'Value is required', }, }) @@ -111,6 +125,10 @@ export default { building: '', }) }, + + removeAddress (index) { + this.values.addressList.splice(index, 1) + }, }, } @@ -119,4 +137,8 @@ export default { .field { max-width: 250px; } + +.remove-btn-wrapper { + padding-top: 32px; +} diff --git a/storybook/stories/FormularioGrouping.tale.vue b/storybook/stories/FormularioGrouping.tale.vue deleted file mode 100644 index d42333a..0000000 --- a/storybook/stories/FormularioGrouping.tale.vue +++ /dev/null @@ -1,106 +0,0 @@ - - - diff --git a/storybook/stories/index.stories.js b/storybook/stories/index.stories.js index c0c491d..930afb9 100644 --- a/storybook/stories/index.stories.js +++ b/storybook/stories/index.stories.js @@ -6,7 +6,6 @@ import Vue from 'vue' import VueFormulario from '../../dist/formulario.esm' import ExampleAddressList from './ExampleAddressList.tale' -import FormularioGrouping from './FormularioGrouping.tale' Vue.mixin({ methods: { @@ -22,4 +21,3 @@ Vue.use(VueFormulario) storiesOf('Examples', module) .add('Address list', () => ExampleAddressList) - .add('FormularioGrouping', () => FormularioGrouping) From 8edc523af7783ced9b62b16b2dfafd06961b30b2 Mon Sep 17 00:00:00 2001 From: Zaytsev Kirill Date: Sat, 22 May 2021 14:57:43 +0300 Subject: [PATCH 02/38] chore: Removed unused code --- build/rollup.config.js | 2 -- 1 file changed, 2 deletions(-) diff --git a/build/rollup.config.js b/build/rollup.config.js index 6c94096..5feea19 100644 --- a/build/rollup.config.js +++ b/build/rollup.config.js @@ -1,7 +1,6 @@ import alias from '@rollup/plugin-alias' import autoExternal from 'rollup-plugin-auto-external' import commonjs from '@rollup/plugin-commonjs' -import { terser } from 'rollup-plugin-terser' import typescript from 'rollup-plugin-typescript2' import vue from 'rollup-plugin-vue' @@ -27,6 +26,5 @@ export default { alias({ entries: [{ find: /^@\/(.+)/, replacement: './$1' }] }), commonjs(), autoExternal(), - // terser(), ] } From 5547cb5b872814f96e27a9075db3559809fee64c Mon Sep 17 00:00:00 2001 From: Zaytsev Kirill Date: Sat, 22 May 2021 15:26:52 +0300 Subject: [PATCH 03/38] chore: Improved typehinting --- src/FormularioInput.vue | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/src/FormularioInput.vue b/src/FormularioInput.vue index f9f9e06..c272a67 100644 --- a/src/FormularioInput.vue +++ b/src/FormularioInput.vue @@ -1,5 +1,8 @@ @@ -28,6 +31,15 @@ const VALIDATION_BEHAVIOR = { SUBMIT: 'submit', } +type Context = { + model: U; + name: string; + runValidation(): Promise; + violations: Violation[]; + errors: string[]; + allErrors: string[]; +} + interface ModelGetConverter { (value: U|Empty): U|T|Empty; } @@ -99,7 +111,7 @@ export default class FormularioInput extends Vue { } } - get context (): Record { + get context (): Context { return Object.defineProperty({ name: this.fullQualifiedName, runValidation: this.runValidation.bind(this), From cb2f350edf9f22f1c4f127ad6c81021e59a9d41a Mon Sep 17 00:00:00 2001 From: Zaytsev Kirill Date: Sat, 22 May 2021 15:27:24 +0300 Subject: [PATCH 04/38] chore: Fixed description for registry forEach method --- src/form/registry.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/form/registry.ts b/src/form/registry.ts index 07e2ae7..5287423 100644 --- a/src/form/registry.ts +++ b/src/form/registry.ts @@ -112,7 +112,7 @@ export default class Registry { } /** - * Map over the registry (recursively). + * Iterate over the registry. */ forEach (callback: Function): void { this.registry.forEach((component, field) => { From 4cec18b1d7f3a250b2bfb8a9961af154e8f8b3f2 Mon Sep 17 00:00:00 2001 From: Zaytsev Kirill Date: Sat, 22 May 2021 15:29:01 +0300 Subject: [PATCH 05/38] test: Improved checking validation event & code style --- test/unit/FormularioForm.test.js | 7 +++---- test/unit/FormularioInput.test.js | 2 +- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/test/unit/FormularioForm.test.js b/test/unit/FormularioForm.test.js index 9bd6d97..0c6f3f8 100644 --- a/test/unit/FormularioForm.test.js +++ b/test/unit/FormularioForm.test.js @@ -320,7 +320,7 @@ describe('FormularioForm', () => { expect(wrapper.vm.mergedFieldErrors.inputB.length).toBe(2) }) - it('Emits correct validation event when no errors', async () => { + it('emits correct validation event when no errors', async () => { const wrapper = mount(FormularioForm, { slots: { default: ` @@ -337,11 +337,10 @@ describe('FormularioForm', () => { await flushPromises() expect(wrapper.emitted('validation')).toBeTruthy() - expect(wrapper.emitted('validation').length).toBe(1) - expect(wrapper.emitted('validation')[0][0]).toEqual({ + expect(wrapper.emitted('validation')).toEqual([[{ name: 'foo', violations: [], - }) + }]]) }) it('Emits correct validation event on entry', async () => { diff --git a/test/unit/FormularioInput.test.js b/test/unit/FormularioInput.test.js index 50ca524..77a5d49 100644 --- a/test/unit/FormularioInput.test.js +++ b/test/unit/FormularioInput.test.js @@ -6,7 +6,7 @@ import Formulario from '@/index.ts' import FormularioForm from '@/FormularioForm.vue' import FormularioInput from '@/FormularioInput.vue' -const globalRule = jest.fn(() => { return false }) +const globalRule = jest.fn(() => false) Vue.use(Formulario, { validationRules: { globalRule }, From 6a86d1f7b79dea4f596234f5ddc027e4b505ee2f Mon Sep 17 00:00:00 2001 From: Zaytsev Kirill Date: Sat, 22 May 2021 15:38:04 +0300 Subject: [PATCH 06/38] style: Rearranged imports --- src/FormularioInput.vue | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/FormularioInput.vue b/src/FormularioInput.vue index c272a67..7c4be81 100644 --- a/src/FormularioInput.vue +++ b/src/FormularioInput.vue @@ -18,10 +18,10 @@ import { } from 'vue-property-decorator' import { arrayify, has, shallowEqualObjects, snakeToCamel } from './utils' import { - ValidationRuleFn, - ValidationMessageI18NFn, processConstraints, validate, + ValidationRuleFn, + ValidationMessageI18NFn, Violation, } from '@/validation/validator' From 013931fbfc1d0a5d0a8b27c7a2b9555a039bd142 Mon Sep 17 00:00:00 2001 From: Zaytsev Kirill Date: Sat, 22 May 2021 16:50:53 +0300 Subject: [PATCH 07/38] feat: Renamed FormularioInput => FormularioField, FormularioGrouping => FormularioFieldGroup, old names are preserved in plugin install method for compatibility --- README.md | 22 +++++----- ...ormularioInput.vue => FormularioField.vue} | 4 +- ...oGrouping.vue => FormularioFieldGroup.vue} | 4 +- src/FormularioForm.vue | 14 +++--- src/form/registry.ts | 10 ++--- src/index.ts | 17 ++++--- src/validation/ErrorObserver.ts | 2 +- storybook/stories/ExampleAddressList.tale.vue | 28 ++++++------ ...oInput.test.js => FormularioField.test.js} | 44 +++++++++---------- ...g.test.js => FormularioFieldGroup.test.js} | 37 ++++++++-------- 10 files changed, 95 insertions(+), 87 deletions(-) rename src/{FormularioInput.vue => FormularioField.vue} (98%) rename src/{FormularioGrouping.vue => FormularioFieldGroup.vue} (86%) rename test/unit/{FormularioInput.test.js => FormularioField.test.js} (91%) rename test/unit/{FormularioGrouping.test.js => FormularioFieldGroup.test.js} (73%) diff --git a/README.md b/README.md index ba3625c..04d9ae3 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ for working with forms and gives full control on the form presentation. ## Examples -Every form control have to rendered inside FormularioInput component. This component provides `id` and `context` in +Every form control have to rendered inside FormularioField component. This component provides `id` and `context` in v-slot props. Control should use `context.model` as v-model and `context.runValidation` as handler for `blur` event (it is necessary for validation when property `validationBehavior` is `demand`). Errors list for a field can be accessed through `context.allErrors`. @@ -27,7 +27,7 @@ The example below creates the authorization form from data: v-model="formData" name="formName" > - - + - - + - - + @@ -72,10 +72,10 @@ The example below creates the authorization form from data: > - - + + - @@ -83,7 +83,7 @@ The example below creates the authorization form from data: v-model="context.model" type="text" > - + ``` diff --git a/src/FormularioInput.vue b/src/FormularioField.vue similarity index 98% rename from src/FormularioInput.vue rename to src/FormularioField.vue index 7c4be81..8ce1b2a 100644 --- a/src/FormularioInput.vue +++ b/src/FormularioField.vue @@ -50,8 +50,8 @@ interface ModelSetConverter { type Empty = null | undefined -@Component({ name: 'FormularioInput', inheritAttrs: false }) -export default class FormularioInput extends Vue { +@Component({ name: 'FormularioField', inheritAttrs: false }) +export default class FormularioField extends Vue { @Inject({ default: undefined }) formularioSetter!: Function|undefined @Inject({ default: () => (): void => {} }) onFormularioFieldValidation!: Function @Inject({ default: undefined }) formularioRegister!: Function|undefined diff --git a/src/FormularioGrouping.vue b/src/FormularioFieldGroup.vue similarity index 86% rename from src/FormularioGrouping.vue rename to src/FormularioFieldGroup.vue index 7df7275..bd48408 100644 --- a/src/FormularioGrouping.vue +++ b/src/FormularioFieldGroup.vue @@ -13,8 +13,8 @@ import { Provide, } from 'vue-property-decorator' -@Component({ name: 'FormularioGrouping' }) -export default class FormularioGrouping extends Vue { +@Component({ name: 'FormularioFieldGroup' }) +export default class FormularioFieldGroup extends Vue { @Inject({ default: '' }) path!: string @Prop({ required: true }) diff --git a/src/FormularioForm.vue b/src/FormularioForm.vue index 4dfc006..fd8b838 100644 --- a/src/FormularioForm.vue +++ b/src/FormularioForm.vue @@ -9,7 +9,7 @@ import Vue from 'vue' import { Component, Model, Prop, Provide, Watch } from 'vue-property-decorator' import { clone, getNested, has, merge, setNested, shallowEqualObjects } from '@/utils' import Registry from '@/form/registry' -import FormularioInput from '@/FormularioInput.vue' +import FormularioField from '@/FormularioField.vue' import { ErrorHandler, @@ -125,7 +125,7 @@ export default class FormularioForm extends Vue { } @Provide('formularioRegister') - register (field: string, component: FormularioInput): void { + register (field: string, component: FormularioField): void { this.registry.add(field, component) } @@ -149,7 +149,7 @@ export default class FormularioForm extends Vue { } this.registry.getNested(field).forEach((registryField, registryKey) => { - const $input = this.registry.get(registryKey) as FormularioInput + const $input = this.registry.get(registryKey) as FormularioField const oldValue = getNested(this.proxy, registryKey) const newValue = getNested(values, registryKey) @@ -193,8 +193,8 @@ export default class FormularioForm extends Vue { } hasValidationErrors (): Promise { - return Promise.all(this.registry.reduce((resolvers: Promise[], input: FormularioInput) => { - resolvers.push(input.runValidation() && input.hasValidationErrors()) + return Promise.all(this.registry.reduce((resolvers: Promise[], field: FormularioField) => { + resolvers.push(field.runValidation() && field.hasValidationErrors()) return resolvers }, [])).then(results => results.some(hasErrors => hasErrors)) } @@ -202,8 +202,8 @@ export default class FormularioForm extends Vue { resetValidation (): void { this.localFormErrors = [] this.localFieldErrors = {} - this.registry.forEach((input: FormularioInput) => { - input.resetValidation() + this.registry.forEach((field: FormularioField) => { + field.resetValidation() }) } } diff --git a/src/form/registry.ts b/src/form/registry.ts index 5287423..c806a45 100644 --- a/src/form/registry.ts +++ b/src/form/registry.ts @@ -1,6 +1,6 @@ import { shallowEqualObjects, has, getNested } from '@/utils' +import FormularioField from '@/FormularioField.vue' import FormularioForm from '@/FormularioForm.vue' -import FormularioInput from '@/FormularioInput.vue' /** * Component registry with inherent depth to handle complex nesting. This is @@ -8,7 +8,7 @@ import FormularioInput from '@/FormularioInput.vue' */ export default class Registry { private ctx: FormularioForm - private registry: Map + private registry: Map /** * Create a new registry of components. @@ -24,7 +24,7 @@ export default class Registry { * @param {string} field name of the field. * @param {FormularioForm} component the actual component instance. */ - add (field: string, component: FormularioInput): void { + add (field: string, component: FormularioField): void { if (this.registry.has(field)) { return } @@ -85,14 +85,14 @@ export default class Registry { /** * Get a particular registry value. */ - get (key: string): FormularioInput | undefined { + get (key: string): FormularioField | undefined { return this.registry.get(key) } /** * Get registry value for key or nested to given key */ - getNested (key: string): Map { + getNested (key: string): Map { const result = new Map() for (const i of this.registry.keys()) { diff --git a/src/index.ts b/src/index.ts index a8e34b8..0c412e4 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,14 +1,21 @@ -import Formulario, { FormularioOptions } from '@/Formulario.ts' import { VueConstructor } from 'vue' + +import Formulario, { FormularioOptions } from '@/Formulario.ts' + +import FormularioField from '@/FormularioField.vue' +import FormularioFieldGroup from '@/FormularioFieldGroup.vue' import FormularioForm from '@/FormularioForm.vue' -import FormularioGrouping from '@/FormularioGrouping.vue' -import FormularioInput from '@/FormularioInput.vue' export default { install (Vue: VueConstructor, options?: FormularioOptions): void { + Vue.component('FormularioField', FormularioField) + Vue.component('FormularioFieldGroup', FormularioFieldGroup) Vue.component('FormularioForm', FormularioForm) - Vue.component('FormularioGrouping', FormularioGrouping) - Vue.component('FormularioInput', FormularioInput) + + // @deprecated Use FormularioField instead + Vue.component('FormularioInput', FormularioField) + // @deprecated Use FormularioFieldGroup instead + Vue.component('FormularioGrouping', FormularioFieldGroup) Vue.mixin({ beforeCreate () { diff --git a/src/validation/ErrorObserver.ts b/src/validation/ErrorObserver.ts index 847a5e6..436f55d 100644 --- a/src/validation/ErrorObserver.ts +++ b/src/validation/ErrorObserver.ts @@ -6,7 +6,7 @@ export interface ErrorHandler { export interface ErrorObserver { callback: ErrorHandler; - type: 'form' | 'input'; + type: 'form' | 'field'; field?: string; } diff --git a/storybook/stories/ExampleAddressList.tale.vue b/storybook/stories/ExampleAddressList.tale.vue index 6d51450..a9bed67 100644 --- a/storybook/stories/ExampleAddressList.tale.vue +++ b/storybook/stories/ExampleAddressList.tale.vue @@ -2,19 +2,19 @@

Address list

- - - + - {{ error }} - + - {{ error }} - +
-
-
+ + -
+
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', + ]) + }) +}) From e55ea0c410e9a44d18ca056861ec69a85a9a173f Mon Sep 17 00:00:00 2001 From: Zaytsev Kirill Date: Mon, 24 May 2021 20:07:28 +0300 Subject: [PATCH 16/38] test: Improved test for validation interruption & code style --- test/unit/FormularioField.test.js | 145 +++++++++++++++--------------- 1 file changed, 70 insertions(+), 75 deletions(-) diff --git a/test/unit/FormularioField.test.js b/test/unit/FormularioField.test.js index 34302ff..f49f4b8 100644 --- a/test/unit/FormularioField.test.js +++ b/test/unit/FormularioField.test.js @@ -3,8 +3,8 @@ import flushPromises from 'flush-promises' import { mount } from '@vue/test-utils' import Formulario from '@/index.ts' -import FormularioForm from '@/FormularioForm.vue' import FormularioField from '@/FormularioField.vue' +import FormularioForm from '@/FormularioForm.vue' const globalRule = jest.fn(() => false) @@ -151,32 +151,32 @@ describe('FormularioField', () => { expect(wrapper.find('span').text()).toBe('failed the foobar check') }) - it('Uses global custom validation rules', async () => { - const wrapper = mount(FormularioField, { + it('uses global custom validation rules', async () => { + mount(FormularioField, { propsData: { name: 'test', value: 'bar', validation: 'required|globalRule', validationBehavior: 'live', - } + }, }) await flushPromises() expect(globalRule.mock.calls.length).toBe(1) }) - it('Emits correct validation event', async () => { + it('emits correct validation event', async () => { const wrapper = mount(FormularioField, { propsData: { name: 'fieldName', value: '', validation: 'required', validationBehavior: 'live', - } + }, }) + await flushPromises() - expect(wrapper.emitted('validation')).toBeTruthy() - expect(wrapper.emitted('validation')[0][0]).toEqual({ + expect(wrapper.emitted('validation')).toEqual([[{ name: 'fieldName', violations: [{ rule: expect.stringContaining('required'), @@ -184,68 +184,55 @@ describe('FormularioField', () => { context: expect.any(Object), message: expect.any(String), }], - }) + }]]) }) - it('Can bail on validation when encountering the bail rule', async () => { - const wrapper = mount(FormularioField, { - propsData: { - name: 'test', - validation: 'bail|required|in:xyz', - validationBehavior: 'live', - }, + test.each([ + ['bail|required|in:xyz', 1], + ['^required|in:xyz|min:10,length', 1], + ['required|^in:xyz|min:10,length', 2], + ['required|in:xyz|bail', 2], + ])('prevents further validation if not passed a rule with bail modifier', async ( + validation, + expectedViolationsCount + ) => { + const wrapper = mount({ + data: () => ({ validation }), + template: ` + +
+ + ` }) - await flushPromises(); - expect(wrapper.vm.context.violations.length).toBe(1); + + await flushPromises() + + expect(wrapper.findAll('[data-violation]').length).toBe(expectedViolationsCount) }) - it('Can show multiple validation errors if they occur before the bail rule', async () => { - const wrapper = mount(FormularioField, { - propsData: { - name: 'test', - validation: 'required|in:xyz|bail', - validationBehavior: 'live', - }, + it('proceeds validation if passed a rule with bail modifier', async () => { + const wrapper = mount({ + template: ` + +
+ + ` }) - await flushPromises(); - expect(wrapper.vm.context.violations.length).toBe(2); - }) - it('Can avoid bail behavior by using modifier', async () => { - const wrapper = mount(FormularioField, { - propsData: { - name: 'test', - value: '123', - validation: '^required|in:xyz|min:10,length', - validationBehavior: 'live', - }, - }) - await flushPromises(); - expect(wrapper.vm.context.violations.length).toBe(2); - }) + await flushPromises() - it('Prevents later error messages when modified rule fails', async () => { - const wrapper = mount(FormularioField, { - propsData: { - name: 'test', - validation: '^required|in:xyz|min:10,length', - validationBehavior: 'live', - }, - }) - await flushPromises(); - expect(wrapper.vm.context.violations.length).toBe(1); - }) - - it('can bail in the middle of the rule set with a modifier', async () => { - const wrapper = mount(FormularioField, { - propsData: { - name: 'test', - validation: 'required|^in:xyz|min:10,length', - validationBehavior: 'live', - }, - }) - await flushPromises(); - expect(wrapper.vm.context.violations.length).toBe(2); + expect(wrapper.findAll('[data-violation]').length).toBe(2) }) it('Displays errors when validation-behavior is submit and form is submitted', async () => { @@ -307,44 +294,52 @@ describe('FormularioField', () => { it('Model setter for input', async () => { const wrapper = mount({ - data: () => ({ values: { test: 'abcd' } }), + data: () => ({ values: { date: 'not a date' } }), template: ` - + `, methods: { - onGet(source) { - if (!(source instanceof Date)) { - return source - } - - return source.getDate() + onGet (source) { + return source instanceof Date ? source.getDate() : source }, - onSet(source) { + + onSet (source) { if (source instanceof Date) { return source } + if (isNaN(source)) { return undefined } - let result = new Date('2001-05-01') + const result = new Date('2001-05-01') result.setDate(source) return result - } + }, } }) await flushPromises() - expect(wrapper.vm.values.test).toBe(undefined) + + expect(wrapper.vm.values.date).toBe(undefined) wrapper.find('input[type="text"]').element['value'] = '12' wrapper.find('input[type="text"]').trigger('input') + await flushPromises() - expect(wrapper.vm.values.test.toISOString()).toBe((new Date('2001-05-12')).toISOString()) + + expect(wrapper.vm.values.date.toISOString()).toBe( + (new Date('2001-05-12')).toISOString() + ) }) }) From 4e05844e7325323d7c2054d362a5c1ae2cca8e13 Mon Sep 17 00:00:00 2001 From: Zaytsev Kirill Date: Mon, 24 May 2021 23:12:40 +0300 Subject: [PATCH 17/38] refactor!: FormularioForm - renamed prop errors to fieldsErrors, some internal renamings; tests semantic improvements --- src/FormularioField.vue | 10 +- src/FormularioForm.vue | 96 +++-- test/unit/FormularioFieldGroup.test.js | 23 +- test/unit/FormularioForm.test.js | 533 ++++++++++++------------- 4 files changed, 321 insertions(+), 341 deletions(-) diff --git a/src/FormularioField.vue b/src/FormularioField.vue index fd2f71e..2fb174b 100644 --- a/src/FormularioField.vue +++ b/src/FormularioField.vue @@ -94,16 +94,16 @@ export default class FormularioField extends Vue { /** * Determines if this formulario element is v-modeled or not. */ - get hasModel (): boolean { + public get hasModel (): boolean { return has(this.$options.propsData || {}, 'value') } - private get model (): unknown { + public get model (): unknown { const model = this.hasModel ? 'value' : 'proxy' return this.modelGetConverter(this[model]) } - private set model (value: unknown) { + public set model (value: unknown) { value = this.modelSetConverter(value, this.proxy) if (!shallowEquals(value, this.proxy)) { @@ -113,7 +113,7 @@ export default class FormularioField extends Vue { this.$emit('input', value) if (typeof this.__FormularioForm_set === 'function') { - this.__FormularioForm_set(this.context.name, value) + this.__FormularioForm_set(this.fullPath, value) } } @@ -241,7 +241,7 @@ export default class FormularioField extends Vue { */ setErrors (errors: string[]): void { if (!this.errorsDisabled) { - this.localErrors = arrayify(errors) + this.localErrors = arrayify(errors) as string[] } } diff --git a/src/FormularioForm.vue b/src/FormularioForm.vue index a969f53..8d11d23 100644 --- a/src/FormularioForm.vue +++ b/src/FormularioForm.vue @@ -1,6 +1,6 @@ @@ -38,9 +38,9 @@ export default class FormularioForm extends Vue { @Model('input', { default: () => ({}) }) public readonly state!: Record - // Errors record, describing state validation errors of whole form - @Prop({ default: () => ({}) }) readonly errors!: Record - // Form errors only used on FormularioForm default slot + // Describes validation errors of whole form + @Prop({ default: () => ({}) }) readonly fieldsErrors!: Record + // Only used on FormularioForm default slot @Prop({ default: () => ([]) }) readonly formErrors!: string[] public proxy: Record = {} @@ -48,58 +48,57 @@ export default class FormularioForm extends Vue { private registry: PathRegistry = new PathRegistry() // Local error messages are temporal, they wiped each resetValidation call + private localFieldsErrors: Record = {} private localFormErrors: string[] = [] - private localFieldErrors: Record = {} - get initialValues (): Record { + private get hasModel (): boolean { + return has(this.$options.propsData || {}, 'state') + } + + private get modelIsDefined (): boolean { + return this.state && typeof this.state === 'object' + } + + private get modelCopy (): Record { if (this.hasModel && typeof this.state === 'object') { - // If there is a v-model on the form/group, use those values as first priority return { ...this.state } // @todo - use a deep clone to detach reference types } return {} } - get mergedFormErrors (): string[] { + private get fieldsErrorsComputed (): Record { + return merge(this.fieldsErrors || {}, this.localFieldsErrors) + } + + private get formErrorsComputed (): string[] { return [...this.formErrors, ...this.localFormErrors] } - get mergedFieldErrors (): Record { - return merge(this.errors || {}, this.localFieldErrors) - } - - get hasModel (): boolean { - return has(this.$options.propsData || {}, 'state') - } - - get hasInitialValue (): boolean { - return this.state && typeof this.state === 'object' - } - @Watch('state', { deep: true }) - onStateChange (values: Record): void { + private onStateChange (values: Record): void { if (this.hasModel && values && typeof values === 'object') { this.setValues(values) } } - @Watch('mergedFieldErrors', { deep: true, immediate: true }) - onMergedFieldErrorsChange (errors: Record): void { + @Watch('fieldsErrorsComputed', { deep: true, immediate: true }) + private onFieldsErrorsChange (fieldsErrors: Record): void { this.registry.forEach((field, path) => { - field.setErrors(errors[path] || []) + field.setErrors(fieldsErrors[path] || []) }) } + @Provide('__FormularioForm_getValue') + private getValue (): Record { + return this.proxy + } + created (): void { this.syncProxy() } - @Provide('__FormularioForm_getValue') - getFormValues (): Record { - return this.proxy - } - - onFormSubmit (): Promise { + private onSubmit (): Promise { return this.hasValidationErrors() .then(hasErrors => hasErrors ? undefined : clone(this.proxy)) .then(data => { @@ -111,30 +110,24 @@ export default class FormularioForm extends Vue { }) } - @Provide('__FormularioForm_emitValidation') - private emitValidation (payload: ValidationEventPayload): void { - this.$emit('validation', payload) - } - @Provide('__FormularioForm_register') private register (path: string, field: FormularioField): void { this.registry.add(path, field) - const value = getNested(this.initialValues, path) + const value = getNested(this.modelCopy, path) - if (!field.hasModel && this.hasInitialValue && value !== undefined) { + if (!field.hasModel && this.modelIsDefined && 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 + field.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] || []) + if (has(this.fieldsErrorsComputed, path)) { + field.setErrors(this.fieldsErrorsComputed[path] || []) } } @@ -148,9 +141,14 @@ export default class FormularioForm extends Vue { } } - syncProxy (): void { - if (this.hasInitialValue) { - this.proxy = this.initialValues + @Provide('__FormularioForm_emitValidation') + private emitValidation (payload: ValidationEventPayload): void { + this.$emit('validation', payload) + } + + private syncProxy (): void { + if (this.modelIsDefined) { + this.proxy = this.modelCopy } } @@ -177,7 +175,7 @@ export default class FormularioForm extends Vue { } if (!shallowEquals(newValue, field.proxy)) { - field.context.model = newValue + field.model = newValue } }) }) @@ -205,9 +203,9 @@ export default class FormularioForm extends Vue { this.$emit('input', { ...this.proxy }) } - setErrors ({ formErrors, inputErrors }: { formErrors?: string[]; inputErrors?: Record }): void { + setErrors ({ fieldsErrors, formErrors }: { fieldsErrors?: Record; formErrors?: string[] }): void { + this.localFieldsErrors = fieldsErrors || {} this.localFormErrors = formErrors || [] - this.localFieldErrors = inputErrors || {} } hasValidationErrors (): Promise { @@ -218,8 +216,8 @@ export default class FormularioForm extends Vue { } resetValidation (): void { + this.localFieldsErrors = {} this.localFormErrors = [] - this.localFieldErrors = {} this.registry.forEach((field: FormularioField) => { field.resetValidation() }) diff --git a/test/unit/FormularioFieldGroup.test.js b/test/unit/FormularioFieldGroup.test.js index 7f3d0d9..0dd842f 100644 --- a/test/unit/FormularioFieldGroup.test.js +++ b/test/unit/FormularioFieldGroup.test.js @@ -10,7 +10,7 @@ import FormularioForm from '@/FormularioForm.vue' Vue.use(Formulario) describe('FormularioFieldGroup', () => { - it('Grouped fields to be set', async () => { + test('grouped fields to be set', async () => { const wrapper = mount(FormularioForm, { slots: { default: ` @@ -36,7 +36,7 @@ describe('FormularioFieldGroup', () => { expect(emitted['submit']).toEqual([[{ group: { text: 'test' } }]]) }) - it('Grouped fields to be got', async () => { + test('grouped fields to be got', async () => { const wrapper = mount(FormularioForm, { propsData: { state: { @@ -57,11 +57,11 @@ describe('FormularioFieldGroup', () => { expect(wrapper.find('input[type="text"]').element['value']).toBe('Group text') }) - it('Data reactive with grouped fields', async () => { + test('data reactive with grouped fields', async () => { const wrapper = mount({ data: () => ({ values: {} }), template: ` - + @@ -71,22 +71,23 @@ describe('FormularioFieldGroup', () => { ` }) + expect(wrapper.find('span').text()).toBe('') + wrapper.find('input[type="text"]').setValue('test') + await flushPromises() + expect(wrapper.find('span').text()).toBe('test') }) - it('Errors are set for grouped fields', async () => { + test('errors are set for grouped fields', async () => { const wrapper = mount(FormularioForm, { - propsData: { - state: {}, - errors: { 'group.text': 'Test error' }, - }, + propsData: { fieldsErrors: { 'address.street': 'Test error' } }, slots: { default: ` - - + + {{ error }} diff --git a/test/unit/FormularioForm.test.js b/test/unit/FormularioForm.test.js index f834b23..08f34ee 100644 --- a/test/unit/FormularioForm.test.js +++ b/test/unit/FormularioForm.test.js @@ -1,6 +1,8 @@ import Vue from 'vue' + import { mount } from '@vue/test-utils' import flushPromises from 'flush-promises' + import Formulario from '@/index.ts' import FormularioForm from '@/FormularioForm.vue' @@ -13,32 +15,22 @@ Vue.use(Formulario, { }) describe('FormularioForm', () => { - it('render a form DOM element', () => { + test('renders a form DOM element', () => { const wrapper = mount(FormularioForm) expect(wrapper.find('form').exists()).toBe(true) }) - it('accepts a default slot', () => { + test('accepts a default slot', () => { const wrapper = mount(FormularioForm, { slots: { - default: '
' - } + default: '
' + }, }) - expect(wrapper.find('form div.default-slot-item').exists()).toBe(true) + + expect(wrapper.find('form [data-default]').exists()).toBe(true) }) - it('Intercepts submit event', () => { - const wrapper = mount(FormularioForm, { - slots: { - default: '