From 295d6567035bc3c452ad0f13fce13ff362b08005 Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Wed, 7 Apr 2021 15:33:05 +0200 Subject: [PATCH] feat: added support for reactive schemas (#3238) --- docs/content/guide/components/validation.md | 57 +++++++++++++ .../guide/composition-api/validation.md | 42 ++++++++++ packages/vee-validate/src/Form.ts | 4 +- packages/vee-validate/src/types.ts | 11 ++- packages/vee-validate/src/useField.ts | 9 ++- packages/vee-validate/src/useForm.ts | 38 ++++++--- packages/vee-validate/tests/Form.spec.ts | 79 ++++++++++++++++++- 7 files changed, 221 insertions(+), 19 deletions(-) diff --git a/docs/content/guide/components/validation.md b/docs/content/guide/components/validation.md index 18a60bda1..8d9dc3ce8 100644 --- a/docs/content/guide/components/validation.md +++ b/docs/content/guide/components/validation.md @@ -211,6 +211,63 @@ There is an official integration available for [Zod validation](https://github.c +### Reactive Form Schema + +You can have reactive form schemas using `computed` if you are looking to create dynamic schemas using either `yup` or a validation object. + +```js +import * as yup from 'yup'; + +export default { + data: () => ({ + min: 6, + }), + computed: { + schema() { + return yup.object({ + password: yup.string().min(this.min), + }); + }, + }, +}; +``` + +```vue + + + +``` + +When the validation schema changes, only the fields that were validated at least once will be re-validated, the other fields won't be validated to avoid aggressive validation behavior. + ## Validation Behavior By default vee-validate runs validation in these scenarios: diff --git a/docs/content/guide/composition-api/validation.md b/docs/content/guide/composition-api/validation.md index a956fdd56..d9f239410 100644 --- a/docs/content/guide/composition-api/validation.md +++ b/docs/content/guide/composition-api/validation.md @@ -212,6 +212,48 @@ There is an official integration available for [Zod validation](https://github.c +### Reactive Form Schema + +You can have reactive form schemas using `computed` if you are looking to create dynamic schemas using either `yup` or a validation object. + +```vue + + + +``` + +When the validation schema changes, only the fields that were validated at least once will be re-validated, the other fields won't be validated to avoid aggressive validation behavior. + ## Validation Behavior diff --git a/packages/vee-validate/src/Form.ts b/packages/vee-validate/src/Form.ts index 891c149aa..f5cec277e 100644 --- a/packages/vee-validate/src/Form.ts +++ b/packages/vee-validate/src/Form.ts @@ -38,6 +38,8 @@ export const Form = defineComponent({ }, setup(props, ctx) { const initialValues = toRef(props, 'initialValues'); + const validationSchema = toRef(props, 'validationSchema'); + const { errors, values, @@ -57,7 +59,7 @@ export const Form = defineComponent({ setFieldTouched, setTouched, } = useForm({ - validationSchema: props.validationSchema, + validationSchema: validationSchema.value ? validationSchema : undefined, initialValues, initialErrors: props.initialErrors, initialTouched: props.initialTouched, diff --git a/packages/vee-validate/src/types.ts b/packages/vee-validate/src/types.ts index b2468eacd..73b41d3cb 100644 --- a/packages/vee-validate/src/types.ts +++ b/packages/vee-validate/src/types.ts @@ -25,6 +25,7 @@ export interface FieldMeta { touched: boolean; dirty: boolean; valid: boolean; + validated: boolean; pending: boolean; initialValue?: TValue; } @@ -105,14 +106,20 @@ export type SubmissionHandler = Record ) => unknown; +/** + * validated-only: only mutate the previously validated fields + * silent: do not mutate any field + * force: validate all fields and mutate their state + */ +export type SchemaValidationMode = 'validated-only' | 'silent' | 'force'; export interface FormContext = Record> extends FormActions { register(field: PrivateFieldComposite): void; unregister(field: PrivateFieldComposite): void; values: TValues; fieldsById: ComputedRef>; submitCount: Ref; - schema?: Record> | SchemaOf; - validateSchema?: (shouldMutate?: boolean) => Promise>; + schema?: MaybeRef> | SchemaOf>; + validateSchema?: (mode: SchemaValidationMode) => Promise>; validate(): Promise>; validateField(field: keyof TValues): Promise; errorBag: Ref>; diff --git a/packages/vee-validate/src/useField.ts b/packages/vee-validate/src/useField.ts index 0c0488362..532ffe0ec 100644 --- a/packages/vee-validate/src/useField.ts +++ b/packages/vee-validate/src/useField.ts @@ -102,7 +102,7 @@ export function useField( const normalizedRules = computed(() => { let rulesValue = unref(rules); - const schema = form?.schema; + const schema = unref(form?.schema); if (schema && !isYupValidator(schema)) { rulesValue = extractRuleFromSchema(schema, unref(name)) || rulesValue; } @@ -117,6 +117,7 @@ export function useField( async function validateWithStateMutation(): Promise { meta.pending = true; let result: ValidationResult; + meta.validated = true; if (!form || !form.validateSchema) { result = await validateValue(value.value, normalizedRules.value, { name: unref(label) || unref(name), @@ -124,7 +125,7 @@ export function useField( bails, }); } else { - result = (await form.validateSchema())[unref(name)] ?? { valid: true, errors: [] }; + result = (await form.validateSchema('validated-only'))[unref(name)] ?? { valid: true, errors: [] }; } meta.pending = false; @@ -141,7 +142,7 @@ export function useField( bails, }); } else { - result = (await form.validateSchema(false))?.[unref(name)] ?? { valid: true, errors: [] }; + result = (await form.validateSchema('silent'))?.[unref(name)] ?? { valid: true, errors: [] }; } meta.valid = result.valid; @@ -392,6 +393,7 @@ function useValidationState({ setErrors(state?.errors || []); meta.touched = state?.touched ?? false; meta.pending = false; + meta.validated = false; } return { @@ -416,6 +418,7 @@ function useMeta(initialValue: MaybeRef, currentValue: Ref unref(initialValue) as TValue | undefined), dirty: computed(() => { return !isEqual(currentValue.value, unref(initialValue)); diff --git a/packages/vee-validate/src/useForm.ts b/packages/vee-validate/src/useForm.ts index df9467646..da19e0c32 100644 --- a/packages/vee-validate/src/useForm.ts +++ b/packages/vee-validate/src/useForm.ts @@ -15,6 +15,7 @@ import { PublicFormContext, FormErrors, FormErrorBag, + SchemaValidationMode, } from './types'; import { applyFieldMutation, @@ -29,7 +30,9 @@ import { import { FormErrorsSymbol, FormContextSymbol, FormInitialValuesSymbol } from './symbols'; interface FormOptions> { - validationSchema?: Record> | SchemaOf; + validationSchema?: MaybeRef< + Record> | SchemaOf + >; initialValues?: MaybeRef; initialErrors?: Record; initialTouched?: Record; @@ -299,7 +302,7 @@ export function useForm = Record { + return formCtx.validateSchema('force').then(results => { return keysOf(results) .map(r => ({ key: r, errors: results[r].errors })) .reduce(resultReducer, { errors: {}, valid: true }); @@ -381,6 +384,7 @@ export function useForm = Record = { register: registerField, unregister: unregisterField, @@ -388,11 +392,11 @@ export function useForm = Record { - return validateYupSchema(formCtx, shouldMutate); + validateSchema: isYupValidator(unref(schema)) + ? mode => { + return validateYupSchema(formCtx, mode); } : undefined, validate, @@ -443,10 +447,16 @@ export function useForm = Record { + formCtx.validateSchema?.('validated-only'); + }); + } + // Provide injections provide(FormContextSymbol, formCtx as FormContext); provide(FormErrorsSymbol, errors); @@ -508,9 +518,9 @@ function useFormMeta>( async function validateYupSchema( form: FormContext, - shouldMutate?: boolean + mode: SchemaValidationMode ): Promise> { - const errors: ValidationError[] = await (form.schema as YupValidator) + const errors: ValidationError[] = await (unref(form.schema) as YupValidator) .validate(form.values, { abortEarly: false }) .then(() => []) .catch((err: ValidationError) => { @@ -541,14 +551,18 @@ async function validateYupSchema( }; result[fieldId] = fieldResult; + if (mode === 'silent') { + applyFieldMutation(field, f => (f.meta.valid = fieldResult.valid)); - if (shouldMutate) { - applyFieldMutation(field, f => f.setValidationState(fieldResult), true); + return result; + } + const wasValidated = Array.isArray(field) ? field.some(f => f.meta.validated) : field.meta.validated; + if (mode === 'validated-only' && !wasValidated) { return result; } - applyFieldMutation(field, f => (f.meta.valid = fieldResult.valid)); + applyFieldMutation(field, f => f.setValidationState(fieldResult), true); return result; }, {} as Record); diff --git a/packages/vee-validate/tests/Form.spec.ts b/packages/vee-validate/tests/Form.spec.ts index f13577971..73955aca6 100644 --- a/packages/vee-validate/tests/Form.spec.ts +++ b/packages/vee-validate/tests/Form.spec.ts @@ -2,7 +2,7 @@ import flushPromises from 'flush-promises'; import { defineRule } from '@/vee-validate'; import { mountWithHoc, setValue, setChecked, dispatchEvent } from './helpers'; import * as yup from 'yup'; -import { onErrorCaptured, reactive, ref, Ref } from 'vue'; +import { computed, onErrorCaptured, reactive, ref, Ref } from 'vue'; describe('
', () => { const REQUIRED_MESSAGE = `This field is required`; @@ -1723,4 +1723,81 @@ describe('', () => { expect(list?.children).toHaveLength(2); expect(list?.textContent).toBe('badwrong'); }); + + test('supports computed yup schemas', async () => { + mountWithHoc({ + setup() { + const acceptList = ref(['1', '2']); + const schema = computed(() => { + return yup.object({ + password: yup.string().oneOf(acceptList.value), + }); + }); + + return { + schema, + }; + }, + template: ` + + + {{ errors.password }} + + `, + }); + + await flushPromises(); + const input = document.querySelector('input') as HTMLInputElement; + expect(document.querySelector('span')?.textContent).toBe(''); + setValue(input, '3'); + await flushPromises(); + // 3 is not allowed yet + expect(document.querySelector('span')?.textContent).toBeTruthy(); + await flushPromises(); + // field is re-validated + setValue(input, '2'); + await flushPromises(); + + expect(document.querySelector('span')?.textContent).toBe(''); + }); + + test('re-validates when a computed yup schema changes', async () => { + const acceptList = ref(['1', '2']); + function addItem(item: string) { + acceptList.value.push(item); + } + + mountWithHoc({ + setup() { + const schema = computed(() => { + return yup.object({ + password: yup.string().oneOf(acceptList.value), + }); + }); + + return { + schema, + }; + }, + template: ` + + + {{ errors.password }} + + `, + }); + + await flushPromises(); + const input = document.querySelector('input') as HTMLInputElement; + expect(document.querySelector('span')?.textContent).toBe(''); + setValue(input, '3'); + await flushPromises(); + // 3 is not allowed yet + expect(document.querySelector('span')?.textContent).toBeTruthy(); + + // field is re-validated automatically + addItem('3'); + await flushPromises(); + expect(document.querySelector('span')?.textContent).toBe(''); + }); });