diff --git a/src/FormularioField.vue b/src/FormularioField.vue index 2fb174b..94c4a36 100644 --- a/src/FormularioField.vue +++ b/src/FormularioField.vue @@ -56,7 +56,7 @@ export default class FormularioField extends Vue { @Inject({ default: undefined }) __FormularioForm_unregister!: Function|undefined @Inject({ default: () => (): Record => ({}) }) - __FormularioForm_getValue!: () => Record + __FormularioForm_getState!: () => Record @Model('input', { default: '' }) value!: unknown @@ -79,7 +79,7 @@ export default class FormularioField extends Vue { @Prop({ default: () => (value: U|Empty): U|T|Empty => value }) modelGetConverter!: ModelGetConverter @Prop({ default: () => (value: U|T): U|T => value }) modelSetConverter!: ModelSetConverter - public proxy: unknown = this.getInitialValue() + public proxy: unknown = this.hasModel ? this.value : '' private localErrors: string[] = [] @@ -148,10 +148,17 @@ export default class FormularioField extends Vue { return messages } + @Watch('value') + private onValueChange (newValue: unknown, oldValue: unknown): void { + if (this.hasModel && !shallowEquals(newValue, oldValue)) { + this.model = newValue + } + } + @Watch('proxy') private onProxyChange (newValue: unknown, oldValue: unknown): void { if (!this.hasModel && !shallowEquals(newValue, oldValue)) { - this.context.model = newValue + this.model = newValue } if (this.validationBehavior === VALIDATION_BEHAVIOR.LIVE) { this.runValidation() @@ -160,54 +167,42 @@ export default class FormularioField extends Vue { } } - @Watch('value') - private onValueChange (newValue: unknown, oldValue: unknown): void { - if (this.hasModel && !shallowEquals(newValue, oldValue)) { - this.context.model = newValue + /** + * @internal + */ + public created (): void { + if (!shallowEquals(this.model, this.proxy)) { + this.model = this.proxy } - } - created (): void { - this.initProxy() if (typeof this.__FormularioForm_register === 'function') { this.__FormularioForm_register(this.fullPath, this) } + if (this.validationBehavior === VALIDATION_BEHAVIOR.LIVE) { this.runValidation() } } - beforeDestroy (): void { + /** + * @internal + */ + public beforeDestroy (): void { if (typeof this.__FormularioForm_unregister === 'function') { this.__FormularioForm_unregister(this.fullPath) } } - private getInitialValue (): unknown { - return has(this.$options.propsData || {}, 'value') ? this.value : '' - } - - private initProxy (): void { - // This should only be run immediately on created and ensures that the - // proxy and the model are both the same before any additional registration. - if (!shallowEquals(this.context.model, this.proxy)) { - this.context.model = this.proxy - } - } - - runValidation (): Promise { + public runValidation (): Promise { this.validationRun = this.validate().then(violations => { const validationChanged = !shallowEquals(violations, this.violations) this.violations = violations + if (validationChanged) { - const payload = { - name: this.context.name, + this.emitValidation({ + name: this.fullPath, violations: this.violations, - } - this.$emit('validation', payload) - if (typeof this.__FormularioForm_emitValidation === 'function') { - this.__FormularioForm_emitValidation(payload) - } + }) } return this.violations @@ -216,7 +211,7 @@ export default class FormularioField extends Vue { return this.validationRun } - validate (): Promise { + private validate (): Promise { return validate(processConstraints( this.validation, this.$formulario.getRules(this.normalizedValidationRules), @@ -224,11 +219,18 @@ export default class FormularioField extends Vue { ), { value: this.context.model, name: this.context.name, - formValues: this.__FormularioForm_getValue(), + formValues: this.__FormularioForm_getState(), }) } - hasValidationErrors (): Promise { + private emitValidation (payload: { name: string; violations: Violation[] }): void { + this.$emit('validation', payload) + if (typeof this.__FormularioForm_emitValidation === 'function') { + this.__FormularioForm_emitValidation(payload) + } + } + + public hasValidationErrors (): Promise { return new Promise(resolve => { this.$nextTick(() => { this.validationRun.then(() => resolve(this.violations.length > 0)) @@ -239,7 +241,7 @@ export default class FormularioField extends Vue { /** * @internal */ - setErrors (errors: string[]): void { + public setErrors (errors: string[]): void { if (!this.errorsDisabled) { this.localErrors = arrayify(errors) as string[] } @@ -248,7 +250,7 @@ export default class FormularioField extends Vue { /** * @internal */ - resetValidation (): void { + public resetValidation (): void { this.localErrors = [] this.violations = [] } diff --git a/src/FormularioForm.vue b/src/FormularioForm.vue index 8d11d23..b076d62 100644 --- a/src/FormularioForm.vue +++ b/src/FormularioForm.vue @@ -28,18 +28,22 @@ import FormularioField from '@/FormularioField.vue' import { Violation } from '@/validation/validator' +type ErrorsRecord = Record + type ValidationEventPayload = { name: string; violations: Violation[]; } +type ViolationsRecord = Record + @Component({ name: 'FormularioForm' }) export default class FormularioForm extends Vue { @Model('input', { default: () => ({}) }) public readonly state!: Record // Describes validation errors of whole form - @Prop({ default: () => ({}) }) readonly fieldsErrors!: Record + @Prop({ default: () => ({}) }) readonly fieldsErrors!: ErrorsRecord // Only used on FormularioForm default slot @Prop({ default: () => ([]) }) readonly formErrors!: string[] @@ -48,7 +52,7 @@ 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 localFieldsErrors: ErrorsRecord = {} private localFormErrors: string[] = [] private get hasModel (): boolean { @@ -75,41 +79,6 @@ export default class FormularioForm extends Vue { return [...this.formErrors, ...this.localFormErrors] } - @Watch('state', { deep: true }) - private onStateChange (values: Record): void { - if (this.hasModel && values && typeof values === 'object') { - this.setValues(values) - } - } - - @Watch('fieldsErrorsComputed', { deep: true, immediate: true }) - private onFieldsErrorsChange (fieldsErrors: Record): void { - this.registry.forEach((field, path) => { - field.setErrors(fieldsErrors[path] || []) - }) - } - - @Provide('__FormularioForm_getValue') - private getValue (): Record { - return this.proxy - } - - created (): void { - this.syncProxy() - } - - private onSubmit (): Promise { - return this.hasValidationErrors() - .then(hasErrors => hasErrors ? undefined : clone(this.proxy)) - .then(data => { - if (typeof data !== 'undefined') { - this.$emit('submit', data) - } else { - this.$emit('error') - } - }) - } - @Provide('__FormularioForm_register') private register (path: string, field: FormularioField): void { this.registry.add(path, field) @@ -117,17 +86,13 @@ export default class FormularioForm extends Vue { const value = getNested(this.modelCopy, path) 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. 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.fieldsErrorsComputed, path)) { - field.setErrors(this.fieldsErrorsComputed[path] || []) + field.setErrors(this.fieldsErrorsComputed[path]) } } @@ -141,18 +106,82 @@ export default class FormularioForm extends Vue { } } + @Provide('__FormularioForm_getState') + private getState (): Record { + return this.proxy + } + + @Provide('__FormularioForm_set') + private setFieldValueAndEmit (field: string, value: unknown): void { + this.setFieldValue(field, value) + this.$emit('input', { ...this.proxy }) + } + @Provide('__FormularioForm_emitValidation') private emitValidation (payload: ValidationEventPayload): void { this.$emit('validation', payload) } - private syncProxy (): void { - if (this.modelIsDefined) { - this.proxy = this.modelCopy + @Watch('state', { deep: true }) + private onStateChange (state: Record): void { + if (this.hasModel && state && typeof state === 'object') { + this.loadState(state) } } - setValues (state: Record): void { + @Watch('fieldsErrorsComputed', { deep: true, immediate: true }) + private onFieldsErrorsChange (fieldsErrors: Record): void { + this.registry.forEach((field, path) => { + field.setErrors(fieldsErrors[path] || []) + }) + } + + public created (): void { + this.syncProxy() + } + + public runValidation (): Promise { + const violations: ViolationsRecord = {} + const runs = this.registry.map((field: FormularioField, path: string) => { + return field.runValidation().then(v => { violations[path] = v }) + }) + + return Promise.all(runs).then(() => violations) + } + + public hasValidationErrors (): Promise { + return this.runValidation().then(violations => { + return Object.keys(violations).some(path => violations[path].length > 0) + }) + } + + public setErrors ({ fieldsErrors, formErrors }: { fieldsErrors?: ErrorsRecord; formErrors?: string[] }): void { + this.localFieldsErrors = fieldsErrors || {} + this.localFormErrors = formErrors || [] + } + + public resetValidation (): void { + this.localFieldsErrors = {} + this.localFormErrors = [] + this.registry.forEach((field: FormularioField) => { + field.resetValidation() + }) + } + + private onSubmit (): Promise { + return this.runValidation() + .then(violations => { + const hasErrors = Object.keys(violations).some(path => violations[path].length > 0) + + if (!hasErrors) { + this.$emit('submit', clone(this.proxy)) + } else { + this.$emit('error', violations) + } + }) + } + + private loadState (state: Record): void { const paths = Array.from(new Set([ ...Object.keys(state), ...Object.keys(this.proxy), @@ -187,7 +216,13 @@ export default class FormularioForm extends Vue { } } - setFieldValue (field: string, value: unknown): void { + private syncProxy (): void { + if (this.modelIsDefined) { + this.proxy = this.modelCopy + } + } + + private setFieldValue (field: string, value: unknown): void { if (value === undefined) { // eslint-disable-next-line @typescript-eslint/no-unused-vars const { [field]: value, ...proxy } = this.proxy @@ -196,31 +231,5 @@ export default class FormularioForm extends Vue { setNested(this.proxy, field, value) } } - - @Provide('__FormularioForm_set') - setFieldValueAndEmit (field: string, value: unknown): void { - this.setFieldValue(field, value) - this.$emit('input', { ...this.proxy }) - } - - setErrors ({ fieldsErrors, formErrors }: { fieldsErrors?: Record; formErrors?: string[] }): void { - this.localFieldsErrors = fieldsErrors || {} - this.localFormErrors = formErrors || [] - } - - hasValidationErrors (): Promise { - return Promise.all(this.registry.reduce((resolvers: Promise[], field: FormularioField) => { - resolvers.push(field.runValidation() && field.hasValidationErrors()) - return resolvers - }, [])).then(results => results.some(hasErrors => hasErrors)) - } - - resetValidation (): void { - this.localFieldsErrors = {} - this.localFormErrors = [] - this.registry.forEach((field: FormularioField) => { - field.resetValidation() - }) - } } diff --git a/src/PathRegistry.ts b/src/PathRegistry.ts index 1c02572..966434f 100644 --- a/src/PathRegistry.ts +++ b/src/PathRegistry.ts @@ -59,24 +59,13 @@ export default class PathRegistry { 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 + map (mapper: (item: T, path: string) => U): U[] { + return Array.from(this.registry.keys()).map(path => mapper(this.get(path) as T, path)) } } diff --git a/src/validation/validator.ts b/src/validation/validator.ts index 2954a8e..a34b2b5 100644 --- a/src/validation/validator.ts +++ b/src/validation/validator.ts @@ -5,10 +5,10 @@ export interface Validator { } export interface Violation { + message: string; rule: string|null; args: any[]; context: ValidationContext|null; - message: string; } export interface ValidationRuleFn { diff --git a/test/unit/FormularioForm.test.js b/test/unit/FormularioForm.test.js index 08f34ee..4529b9f 100644 --- a/test/unit/FormularioForm.test.js +++ b/test/unit/FormularioForm.test.js @@ -136,7 +136,7 @@ describe('FormularioForm', () => { test('resolves submitted form values to an object', async () => { const wrapper = mount(FormularioForm, { slots: { - default: '' + default: '' }, }) @@ -145,10 +145,53 @@ describe('FormularioForm', () => { await flushPromises() expect(wrapper.emitted('submit')).toEqual([ - [{ fieldName: 'Justin' }], + [{ name: 'Justin' }], ]) }) + test('resolves runValidation', async () => { + const wrapper = mount(FormularioForm, { + slots: { + default: ` +
+ + +
+ `, + }, + }) + + const violations = await wrapper.vm.runValidation() + const state = { + address: { + street: null, + }, + } + + expect(violations).toEqual({ + 'address.street': [{ + message: expect.any(String), + rule: 'required', + args: [], + context: { + name: 'address.street', + value: null, + formValues: state, + }, + }], + 'address.building': [{ + message: expect.any(String), + rule: 'required', + args: [], + context: { + name: 'address.building', + value: '', + formValues: state, + }, + }], + }) + }) + test('resolves hasValidationErrors to true', async () => { const wrapper = mount(FormularioForm, { slots: { @@ -160,10 +203,8 @@ describe('FormularioForm', () => { await flushPromises() - const emitted = wrapper.emitted() - - expect(emitted['error']).toBeTruthy() - expect(emitted['error'].length).toBe(1) + expect(wrapper.emitted('error')).toBeTruthy() + expect(wrapper.emitted('error').length).toBe(1) }) describe('allows setting fields errors', () => {