From 6145f48ac2056f1489c34c2654d4eec2b1374e9d Mon Sep 17 00:00:00 2001 From: Abdelrahman Awad Date: Mon, 5 Aug 2024 03:39:04 +0300 Subject: [PATCH] feat: perform schema validation initially and on submit --- packages/core/src/form/formContext.ts | 15 +++- packages/core/src/form/useForm.spec.ts | 8 +- packages/core/src/form/useForm.ts | 20 +++-- packages/core/src/form/useFormActions.ts | 74 ++++++++++++------- packages/core/src/form/useFormField.ts | 2 +- packages/core/src/types/forms.ts | 2 +- .../core/src/validation/useInputValidity.ts | 2 +- 7 files changed, 79 insertions(+), 44 deletions(-) diff --git a/packages/core/src/form/formContext.ts b/packages/core/src/form/formContext.ts index 51b52b1c..d7a8eb39 100644 --- a/packages/core/src/form/formContext.ts +++ b/packages/core/src/form/formContext.ts @@ -7,7 +7,8 @@ import { PathValue, TouchedSchema, TypedSchema, - ValiditySchema, + ErrorsSchema, + TypedSchemaError, } from '../types'; import { cloneDeep, merge, normalizeArrayable } from '../utils/common'; import { escapePath, findLeaf, getFromPath, isPathSet, setInPath, unsetPath as unsetInObject } from '../utils/path'; @@ -33,6 +34,7 @@ export interface FormContext { getFieldErrors>(path: TPath): string[]; setFieldErrors>(path: TPath, message: Arrayable): void; getValidationMode(): FormValidationMode; + getErrors: () => TypedSchemaError[]; clearErrors: () => void; hasErrors: () => boolean; getValues: () => TForm; @@ -50,7 +52,7 @@ export interface FormContextCreateOptions; disabled: DisabledSchema; - errors: Ref>; + errors: Ref>; schema: TypedSchema | undefined; snapshots: { values: FormSnapshot; @@ -121,6 +123,12 @@ export function createFormContext Array.isArray(l) && l.length > 0); } + function getErrors(): TypedSchemaError[] { + return Object.entries(errors.value) + .map(([key, value]) => ({ path: key, errors: value as string[] })) + .filter(e => e.errors.length > 0); + } + function setInitialValues(newValues: Partial, opts?: SetValueOptions) { if (opts?.mode === 'merge') { snapshots.values.initials.value = merge(cloneDeep(snapshots.values.initials.value), cloneDeep(newValues)); @@ -191,7 +199,7 @@ export function createFormContext; + errors.value = {} as ErrorsSchema; } function revertValues() { @@ -228,6 +236,7 @@ export function createFormContext { const cb = vi.fn(); const onSubmit = handleSubmit(cb); expect(values).toEqual(defaults()); - onSubmit(new Event('submit')); - await nextTick(); + await onSubmit(new Event('submit')); expect(cb).toHaveBeenLastCalledWith(defaults()); disabled.value = true; - onSubmit(new Event('submit')); - await nextTick(); + await onSubmit(new Event('submit')); + expect(cb).toHaveBeenCalledTimes(2); expect(cb).toHaveBeenLastCalledWith({ multiple: ['field 1', 'field 3', 'field 4'] }); }); }); @@ -414,6 +413,7 @@ describe('validation', () => { expect(handler).not.toHaveBeenCalled(); await fireEvent.change(screen.getByTestId('input'), { target: { value: 'test' } }); await fireEvent.click(screen.getByText('Submit')); + await nextTick(); expect(handler).toHaveBeenCalledOnce(); }); diff --git a/packages/core/src/form/useForm.ts b/packages/core/src/form/useForm.ts index a6312e34..7638afb7 100644 --- a/packages/core/src/form/useForm.ts +++ b/packages/core/src/form/useForm.ts @@ -1,4 +1,4 @@ -import { computed, InjectionKey, provide, reactive, readonly, Ref, ref } from 'vue'; +import { computed, InjectionKey, onMounted, provide, reactive, readonly, Ref, ref } from 'vue'; import { cloneDeep, isEqual, useUniqId } from '../utils/common'; import { FormObject, @@ -6,7 +6,7 @@ import { MaybeGetter, TouchedSchema, DisabledSchema, - ValiditySchema, + ErrorsSchema, Path, TypedSchema, } from '../types'; @@ -26,7 +26,8 @@ export interface FormOptions extends FormContext, FormTransactionManager { - onSubmitted(cb: () => void): void; + onSubmitAttempt(cb: () => void): void; + onValidateTriggered(cb: () => void): void; } export const FormKey: InjectionKey> = Symbol('Formwerk FormKey'); @@ -42,7 +43,7 @@ export function useForm; const disabled = {} as DisabledSchema; - const errors = ref({}) as Ref>; + const errors = ref({}) as Ref>; const ctx = createFormContext({ id: opts?.id || useUniqId('form'), @@ -74,7 +75,7 @@ export function useForm(ctx, { + const { actions, onSubmitAttempt, onValidateTriggered, isSubmitting } = useFormActions(ctx, { disabled, schema: opts?.schema, }); @@ -90,9 +91,16 @@ export function useForm); + if (ctx.getValidationMode() === 'schema') { + onMounted(() => { + actions.validate(); + }); + } + return { values: readonly(values), context: ctx, diff --git a/packages/core/src/form/useFormActions.ts b/packages/core/src/form/useFormActions.ts index 769ffbcf..6ec6cc6f 100644 --- a/packages/core/src/form/useFormActions.ts +++ b/packages/core/src/form/useFormActions.ts @@ -1,5 +1,5 @@ import { shallowRef } from 'vue'; -import { DisabledSchema, FormObject, MaybeAsync, Path, TouchedSchema, TypedSchema } from '../types'; +import { DisabledSchema, FormObject, MaybeAsync, Path, TouchedSchema, TypedSchema, TypedSchemaError } from '../types'; import { cloneDeep } from '../utils/common'; import { createEventDispatcher } from '../utils/events'; import { FormContext, SetValueOptions } from './formContext'; @@ -15,64 +15,79 @@ export interface FormActionsOptions; } +export interface FormValidationResult { + isValid: boolean; + errors: TypedSchemaError[]; + output: TOutput; +} + export function useFormActions( form: FormContext, { disabled, schema }: FormActionsOptions, ) { const isSubmitting = shallowRef(false); - const [dispatchSubmit, onSubmitted] = createEventDispatcher('submit'); + const [dispatchSubmit, onSubmitAttempt] = createEventDispatcher('submit'); + const [dispatchValidate, onValidateTriggered] = createEventDispatcher('validate'); - function handleSubmit(cb: (values: TOutput) => MaybeAsync) { + function handleSubmit(onSuccess: (values: TOutput) => MaybeAsync) { return async function onSubmit(e: Event) { e.preventDefault(); isSubmitting.value = true; - await dispatchSubmit(); - const validationMode = form.getValidationMode(); - // Prevent submission if the form has errors and is using native validation - if (validationMode === 'native' && form.hasErrors()) { + dispatchSubmit(); + const { isValid, output } = await validate(); + // Prevent submission if the form has errors + if (!isValid) { isSubmitting.value = false; return; } - // Clone the values to prevent mutation or reactive leaks - const values = cloneDeep(form.getValues()); const disabledPaths = Object.entries(disabled) .filter(([, v]) => !!v) .map(([k]) => k) .sort((a, b) => b.localeCompare(a)) as (keyof DisabledSchema)[]; for (const path of disabledPaths) { - unsetPath(values, path, true); + unsetPath(output, path, true); } - if (!schema || validationMode === 'native') { - const result = await cb(values as unknown as TOutput); - isSubmitting.value = false; + const result = await onSuccess(output); + isSubmitting.value = false; - return result; - } + return result; + }; + } - const { output, errors } = await schema.parse(values); - form.clearErrors(); - if (errors.length) { - for (const entry of errors) { - form.setFieldErrors(entry.path as Path, entry.errors); - } + async function validate(): Promise> { + if (form.getValidationMode() === 'native' || !schema) { + await dispatchValidate(); - isSubmitting.value = false; + return { + isValid: !form.hasErrors(), + errors: form.getErrors(), + output: cloneDeep(form.getValues() as unknown as TOutput), + }; + } - return; - } + const { errors, output } = await schema.parse(form.getValues()); + form.clearErrors(); - const result = await cb(output ?? (values as unknown as TOutput)); - isSubmitting.value = false; + applyErrors(errors); - return result; + return { + isValid: !errors.length, + errors, + output: cloneDeep(output ?? (form.getValues() as unknown as TOutput)), }; } + function applyErrors(errors: TypedSchemaError[]) { + for (const entry of errors) { + form.setFieldErrors(entry.path as Path, entry.errors); + } + } + function reset(state?: Partial>, opts?: SetValueOptions) { if (state?.values) { form.setInitialValues(state.values, opts); @@ -84,14 +99,17 @@ export function useFormActions(opts?: Partial { + form.onSubmitAttempt(() => { setTouched(true); }); diff --git a/packages/core/src/types/forms.ts b/packages/core/src/types/forms.ts index 0b7f8c8d..d86ccfb3 100644 --- a/packages/core/src/types/forms.ts +++ b/packages/core/src/types/forms.ts @@ -6,4 +6,4 @@ export type TouchedSchema = Simplify = Partial, boolean>>; -export type ValiditySchema = Partial, string[]>>; +export type ErrorsSchema = Partial, string[]>>; diff --git a/packages/core/src/validation/useInputValidity.ts b/packages/core/src/validation/useInputValidity.ts index c084250d..c1a8c6b8 100644 --- a/packages/core/src/validation/useInputValidity.ts +++ b/packages/core/src/validation/useInputValidity.ts @@ -29,7 +29,7 @@ export function useInputValidity(opts: InputValidityOptions) { useEventListener(opts.inputRef, opts?.events || ['invalid', 'change', 'blur'], updateValidity); - form?.onSubmitted(updateValiditySync); + form?.onValidateTriggered(updateValiditySync); if (opts.inputRef) { watch(errorMessage, msg => {