From bf216dde30a6d90c976bac844129ccbd08a00392 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Sat, 18 Jul 2020 07:05:36 +0200 Subject: [PATCH] feat: validate yup form schemas using object validation --- packages/core/src/types.ts | 1 + packages/core/src/useField.ts | 50 +++++++++++------------- packages/core/src/useForm.ts | 66 +++++++++++++++++++++++++++++++- packages/core/tests/Form.spec.ts | 51 ++++++++++++++++++++++-- 4 files changed, 135 insertions(+), 33 deletions(-) diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index e457ad33c..9332e7bd8 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -48,6 +48,7 @@ export interface FormController { values: ComputedRef>; fields: ComputedRef>; schema?: Record>; + validateSchema?: (shouldMutate?: boolean) => Promise>; } export type MaybeReactive = Ref | ComputedRef | T; diff --git a/packages/core/src/useField.ts b/packages/core/src/useField.ts index a8bd75e70..c999cc41a 100644 --- a/packages/core/src/useField.ts +++ b/packages/core/src/useField.ts @@ -26,28 +26,34 @@ type RuleExpression = MaybeReactive | GenericValida */ export function useField(fieldName: MaybeReactive, rules: RuleExpression, opts?: Partial) { const { value, form, immediate, bails, disabled } = normalizeOptions(opts); - const { meta, errors, failedRules, onBlur, handleChange, reset, patch } = useValidationState(value); - // eslint-disable-next-line prefer-const - let schemaValidation: GenericValidateFunction | string | Record | undefined; + const { meta, errors, onBlur, handleChange, reset, patch } = useValidationState(value); + const nonYupSchemaRules = extractRuleFromSchema(opts?.form?.schema, unwrap(fieldName)); const normalizedRules = computed(() => { - return normalizeRules(schemaValidation || unwrap(rules)); + return normalizeRules(nonYupSchemaRules || unwrap(rules)); }); const runValidation = async (): Promise => { meta.pending.value = true; - const result = await validate(value.value, normalizedRules.value, { - name: unwrap(fieldName), - values: form?.values.value ?? {}, - bails, - }); + if (!form || !form.validateSchema) { + const result = await validate(value.value, normalizedRules.value, { + name: unwrap(fieldName), + values: form?.values.value ?? {}, + bails, + }); + + // Must be updated regardless if a mutation is needed or not + // FIXME: is this needed? + meta.valid.value = result.valid; + meta.invalid.value = !result.valid; + meta.pending.value = false; + + return result; + } - // Must be updated regardless if a mutation is needed or not - // FIXME: is this needed? - meta.valid.value = result.valid; - meta.invalid.value = !result.valid; + const results = await form.validateSchema(); meta.pending.value = false; - return result; + return results[unwrap(fieldName)]; }; const runValidationWithMutation = () => runValidation().then(patch); @@ -72,13 +78,13 @@ export function useField(fieldName: MaybeReactive, rules: RuleExpression meta, errors, errorMessage, - failedRules, aria, reset, validate: runValidationWithMutation, handleChange, onBlur, disabled, + setValidationState: patch, }; watch(value, runValidationWithMutation, { @@ -99,9 +105,6 @@ export function useField(fieldName: MaybeReactive, rules: RuleExpression // associate the field with the given form form.register(field); - // set the rules if present in schema - schemaValidation = extractRuleFromSchema(form.schema, unwrap(fieldName)); - // extract cross-field dependencies in a computed prop const dependencies = computed(() => { const rulesVal = normalizedRules.value; @@ -282,15 +285,6 @@ function extractRuleFromSchema(schema: Record | undefined, fieldNam return undefined; } - // a yup schema - if (schema.fields?.[fieldName]) { - return schema.fields?.[fieldName]; - } - // there is a key on the schema object for this field - if (schema[fieldName]) { - return schema[fieldName]; - } - - return undefined; + return schema[fieldName]; } diff --git a/packages/core/src/useForm.ts b/packages/core/src/useForm.ts index 9fc52a2d2..6f20acf6a 100644 --- a/packages/core/src/useForm.ts +++ b/packages/core/src/useForm.ts @@ -1,7 +1,15 @@ import { computed, ref, Ref } from 'vue'; import type { useField } from './useField'; -import { Flag, FormController, SubmissionHandler, GenericValidateFunction, SubmitEvent } from './types'; +import { + Flag, + FormController, + SubmissionHandler, + GenericValidateFunction, + SubmitEvent, + ValidationResult, +} from './types'; import { unwrap } from './utils/refs'; +import { isYupValidator } from './utils'; interface FormOptions { validationSchema?: Record>; @@ -46,9 +54,20 @@ export function useForm(opts?: FormOptions) { fields: fieldsById, values, schema: opts?.validationSchema, + validateSchema: isYupValidator(opts?.validationSchema) + ? (shouldMutate = false) => { + return validateYupSchema(controller, shouldMutate); + } + : undefined, }; const validate = async () => { + if (controller.validateSchema) { + return controller.validateSchema(true).then(results => { + return Object.keys(results).every(r => results[r].valid); + }); + } + const results = await Promise.all( activeFields.value.map((f: any) => { return f.validate(); @@ -138,3 +157,48 @@ function useFormMeta(fields: Ref) { return acc; }, {} as Record>); } + +async function validateYupSchema( + form: FormController, + shouldMutate = false +): Promise> { + const errors: any[] = await (form.schema as any) + .validate(form.values.value, { abortEarly: false }) + .then(() => []) + .catch((err: any) => { + // Yup errors have a name prop one them. + // https://github.com/jquense/yup#validationerrorerrors-string--arraystring-value-any-path-string + if (err.name !== 'ValidationError') { + throw err; + } + + // list of aggregated errors + return err.inner || []; + }); + + const fields = form.fields.value; + const errorsByPath = errors.reduce((acc, err) => { + acc[err.path] = err; + + return acc; + }, {}); + + // Aggregates the validation result + const aggregatedResult = Object.keys(fields).reduce((result: Record, fieldId) => { + const field = fields[fieldId]; + const messages = (errorsByPath[fieldId] || { errors: [] }).errors; + const fieldResult = { + errors: messages, + valid: !messages.length, + }; + + result[fieldId] = fieldResult; + if (shouldMutate || field.meta.validated) { + field.setValidationState(fieldResult); + } + + return result; + }, {}); + + return aggregatedResult; +} diff --git a/packages/core/tests/Form.spec.ts b/packages/core/tests/Form.spec.ts index 47257f86f..807e3448d 100644 --- a/packages/core/tests/Form.spec.ts +++ b/packages/core/tests/Form.spec.ts @@ -279,15 +279,15 @@ describe('
', () => { wrapper.$el.querySelector('button').click(); await flushPromises(); - expect(emailError.textContent).toBe('this is a required field'); - expect(passwordError.textContent).toBe('this is a required field'); + expect(emailError.textContent).toBe('email is a required field'); + expect(passwordError.textContent).toBe('password is a required field'); setValue(email, 'hello@'); setValue(password, '1234'); await flushPromises(); - expect(emailError.textContent).toBe('this must be a valid email'); - expect(passwordError.textContent).toBe('this must be at least 8 characters'); + expect(emailError.textContent).toBe('email must be a valid email'); + expect(passwordError.textContent).toBe('password must be at least 8 characters'); setValue(email, 'hello@email.com'); setValue(password, '12346789'); @@ -331,4 +331,47 @@ describe('', () => { expect(first.textContent).toBe(REQUIRED_MESSAGE); expect(second.textContent).toBe(REQUIRED_MESSAGE); }); + + test('cross field validation with yup schema', async () => { + const wrapper = mountWithHoc({ + setup() { + const schema = yup.object().shape({ + password: yup.string().required(), + confirmation: yup.string().oneOf([yup.ref('password')], 'passwords must match'), + }); + + return { + schema, + }; + }, + template: ` + + + {{ errors.password }} + + + {{ errors.confirmation }} + + + + `, + }); + + const password = wrapper.$el.querySelector('#password'); + const confirmation = wrapper.$el.querySelector('#confirmation'); + const confirmationError = wrapper.$el.querySelector('#confirmationError'); + + wrapper.$el.querySelector('button').click(); + await flushPromises(); + + setValue(password, 'hello@'); + setValue(confirmation, '1234'); + await flushPromises(); + expect(confirmationError.textContent).toBe('passwords must match'); + + setValue(password, '1234'); + setValue(confirmation, '1234'); + await flushPromises(); + expect(confirmationError.textContent).toBe(''); + }); });