diff --git a/.gitignore b/.gitignore index 5611bea8..e083b003 100644 --- a/.gitignore +++ b/.gitignore @@ -13,4 +13,5 @@ dist .DS_STORE lerna-debug.log +packages/*/dist/** packages/*/src/playground.ts diff --git a/packages/core/src/useForm/formContext.ts b/packages/core/src/useForm/formContext.ts index 3e24ece0..2a948ec1 100644 --- a/packages/core/src/useForm/formContext.ts +++ b/packages/core/src/useForm/formContext.ts @@ -15,7 +15,7 @@ import { escapePath, findLeaf, getFromPath, isPathSet, setInPath, unsetPath as u import { FormSnapshot } from './formSnapshot'; import { isObject, merge } from '../../../shared/src'; -export type FormValidationMode = 'native' | 'schema'; +export type FormValidationMode = 'aggregate' | 'schema'; export interface BaseFormContext { id: string; @@ -226,7 +226,7 @@ export function createFormContext = { schema: TypedSchema | undefined; validate(mutate?: boolean): Promise; getPath: Getter; + getName: Getter; setValue: (value: TValue | undefined) => void; setTouched: (touched: boolean) => void; setErrors: (messages: Arrayable) => void; @@ -78,7 +79,7 @@ export function useFormField(opts?: Partial): ValidationResult { return { type: 'FIELD', - path: getPath() || '', + path: (formGroup ? toValue(opts?.path) : getPath()) || '', ...result, }; } @@ -113,6 +114,7 @@ export function useFormField(opts?: Partial toValue(opts?.path), setValue, setTouched, setErrors, diff --git a/packages/core/src/useFormGroup/useFormGroup.spec.ts b/packages/core/src/useFormGroup/useFormGroup.spec.ts index a2dc0b2b..244b2edc 100644 --- a/packages/core/src/useFormGroup/useFormGroup.spec.ts +++ b/packages/core/src/useFormGroup/useFormGroup.spec.ts @@ -4,17 +4,6 @@ import { TypedSchema, useTextField, useForm, useFormGroup } from '@core/index'; import { fireEvent, render, screen } from '@testing-library/vue'; import { flush } from '@test-utils/flush'; -test('warns if no form is present', async () => { - const warnFn = vi.spyOn(console, 'warn'); - - await renderSetup(() => { - return useFormGroup({ name: 'test' }); - }); - - expect(warnFn).toHaveBeenCalledOnce(); - warnFn.mockRestore(); -}); - function createInputComponent(): Component { return { inheritAttrs: false, @@ -49,6 +38,17 @@ function createGroupComponent(fn?: (fg: ReturnType) => void }; } +test('warns if no form is present', async () => { + const warnFn = vi.spyOn(console, 'warn'); + + await renderSetup(() => { + return useFormGroup({ name: 'test' }); + }); + + expect(warnFn).toHaveBeenCalledOnce(); + warnFn.mockRestore(); +}); + test('prefixes path values with its name', async () => { let form!: ReturnType; await render({ @@ -169,8 +169,150 @@ test('tracks its valid state', async () => { expect(groups[0].isValid.value).toBe(false); expect(groups[1].isValid.value).toBe(true); await fireEvent.update(screen.getByTestId('field1'), 'test'); - await fireEvent.blur(screen.getByTestId('field1'), 'test'); + await fireEvent.blur(screen.getByTestId('field1')); await flush(); expect(groups[0].isValid.value).toBe(true); expect(groups[1].isValid.value).toBe(true); }); + +test('validates with a typed schema', async () => { + let form!: ReturnType; + const groups: ReturnType[] = []; + const schema: TypedSchema<{ field: string }> = { + async parse(value) { + return { + errors: value.field ? [] : [{ path: 'field', messages: ['error'] }], + }; + }, + }; + + await render({ + components: { TInput: createInputComponent(), TGroup: createGroupComponent(fg => groups.push(fg)) }, + setup() { + form = useForm(); + + return { + schema, + }; + }, + template: ` + + + + `, + }); + + await flush(); + expect(groups[0].isValid.value).toBe(false); + expect(form.getError('group.field')).toBe('error'); + await fireEvent.update(screen.getByTestId('field'), 'test'); + await fireEvent.blur(screen.getByTestId('field')); + + await flush(); + expect(groups[0].isValid.value).toBe(true); + expect(form.getError('group.field')).toBeUndefined(); +}); + +test('validation combines schema with form schema', async () => { + let form!: ReturnType; + const groups: ReturnType[] = []; + const groupSchema: TypedSchema<{ field: string }> = { + async parse(value) { + return { + errors: value.field ? [] : [{ path: 'field', messages: ['error'] }], + }; + }, + }; + + const formSchema: TypedSchema<{ other: string }> = { + async parse(value) { + return { + errors: value.other ? [] : [{ path: 'other', messages: ['error'] }], + }; + }, + }; + + await render({ + components: { TInput: createInputComponent(), TGroup: createGroupComponent(fg => groups.push(fg)) }, + setup() { + form = useForm({ + schema: formSchema, + }) as any; + + return { + groupSchema, + }; + }, + template: ` + + + + + + `, + }); + + await flush(); + expect(form.getErrors()).toHaveLength(2); + await fireEvent.update(screen.getByTestId('field'), 'test'); + await fireEvent.blur(screen.getByTestId('other')); + await flush(); + expect(form.getErrors()).toHaveLength(1); + await fireEvent.update(screen.getByTestId('other'), 'test'); + await fireEvent.blur(screen.getByTestId('other')); + await flush(); + expect(form.getErrors()).toHaveLength(0); +}); + +test('submission combines group data with form data', async () => { + const submitHandler = vi.fn(); + const groupSchema: TypedSchema<{ first: string }> = { + async parse() { + return { + output: { first: 'wow', second: 'how' }, + errors: [], + }; + }, + }; + await render({ + components: { TInput: createInputComponent(), TGroup: createGroupComponent() }, + setup() { + const { handleSubmit } = useForm({}); + + const onSubmit = handleSubmit(submitHandler); + + return { + onSubmit, + groupSchema, + }; + }, + template: ` + + + + + + + + + + + + `, + }); + + await flush(); + expect(submitHandler).not.toHaveBeenCalled(); + await fireEvent.update(screen.getByTestId('first'), 'first'); + await fireEvent.update(screen.getByTestId('second'), 'second'); + await fireEvent.update(screen.getByTestId('third'), 'third'); + await flush(); + await screen.getByText('Submit').click(); + await flush(); + expect(submitHandler).toHaveBeenCalledOnce(); + expect(submitHandler).toHaveBeenLastCalledWith({ + group: { first: 'wow', second: 'how' }, + other: { second: 'second' }, + third: 'third', + }); +}); diff --git a/packages/core/src/useFormGroup/useFormGroup.ts b/packages/core/src/useFormGroup/useFormGroup.ts index c8d9c9b6..f4129289 100644 --- a/packages/core/src/useFormGroup/useFormGroup.ts +++ b/packages/core/src/useFormGroup/useFormGroup.ts @@ -26,6 +26,7 @@ import { import { isEqual, normalizeProps, useUniqId, warn, withRefCapture } from '../utils/common'; import { FormKey } from '../useForm'; import { useValidationProvider } from '../validation/useValidationProvider'; +import { FormValidationMode } from '../useForm/formContext'; export interface FormGroupProps { name: string; @@ -42,6 +43,7 @@ interface FormGroupContext { prefixPath: (path: string | undefined) => string | undefined; onValidationDispatch(cb: (enqueue: (promise: Promise) => void) => void): void; requestValidation(): Promise>; + getValidationMode(): FormValidationMode; } export const FormGroupKey: InjectionKey = Symbol('FormGroup'); @@ -134,6 +136,7 @@ export function useFormGroup (props.schema ? 'schema' : 'aggregate'), }; // Whenever the form is validated, it is deferred to the form group to do that. diff --git a/packages/core/src/validation/useInputValidity.ts b/packages/core/src/validation/useInputValidity.ts index a793b335..dfd8cbab 100644 --- a/packages/core/src/validation/useInputValidity.ts +++ b/packages/core/src/validation/useInputValidity.ts @@ -15,9 +15,9 @@ interface InputValidityOptions { export function useInputValidity(opts: InputValidityOptions) { const form = inject(FormKey, null); const formGroup = inject(FormGroupKey, null); - const { setErrors, errorMessage, schema, validate: validateField, getPath, fieldValue } = opts.field; + const { setErrors, errorMessage, schema, validate: validateField, getPath, getName, fieldValue } = opts.field; const validityDetails = shallowRef(); - const validationMode = form?.getValidationMode() ?? 'native'; + const validationMode = (formGroup || form)?.getValidationMode() ?? 'aggregate'; useMessageCustomValiditySync(errorMessage, opts.inputRef); function validateNative(mutate?: boolean): ValidationResult { @@ -29,7 +29,7 @@ export function useInputValidity(opts: InputValidityOptions) { return { type: 'FIELD', - path: getPath() || '', + path: (formGroup ? getName() : getPath()) || '', output: cloneDeep(fieldValue.value), isValid: !messages.length, errors: [{ messages, path: getPath() || '' }], @@ -37,7 +37,7 @@ export function useInputValidity(opts: InputValidityOptions) { } function _updateValidity() { - if (validationMode === 'native') { + if (validationMode === 'aggregate') { return schema ? validateField(true) : validateNative(true); } @@ -59,13 +59,13 @@ export function useInputValidity(opts: InputValidityOptions) { return; } - if (validationMode === 'native') { + if (validationMode === 'aggregate') { enqueue(Promise.resolve(validateNative(false))); return; } }); - if (validationMode === 'native') { + if (validationMode === 'aggregate') { // It should self-mutate the field errors because this is fired by a native validation and not sourced by the form. useEventListener(opts.inputRef, opts?.events || ['invalid'], () => validateNative(true)); } diff --git a/packages/core/src/validation/useValidationProvider.ts b/packages/core/src/validation/useValidationProvider.ts index ce36d47e..2fe17c24 100644 --- a/packages/core/src/validation/useValidationProvider.ts +++ b/packages/core/src/validation/useValidationProvider.ts @@ -86,7 +86,7 @@ export function useValidationProvider< if (type === 'FORM') { return { type, - mode: schema ? 'schema' : 'native', + mode: schema ? 'schema' : 'aggregate', ...base, } as TResult; } @@ -94,7 +94,7 @@ export function useValidationProvider< return { type: 'GROUP', path: getPath?.() || '', - mode: schema ? 'schema' : 'native', + mode: schema ? 'schema' : 'aggregate', ...base, } as TResult; } diff --git a/packages/playground/src/App.vue b/packages/playground/src/App.vue index 8783d373..3698f563 100644 --- a/packages/playground/src/App.vue +++ b/packages/playground/src/App.vue @@ -39,13 +39,7 @@ const groupSchema = defineSchema( }), ); -const { getErrors, values, handleSubmit } = useForm({ - schema: defineSchema( - z.object({ - fullName: z.string().min(1), - }), - ), -}); +const { getErrors, values, handleSubmit } = useForm({}); const onSubmit = handleSubmit(values => { console.log(values); diff --git a/packages/playground/src/components/FormGroup.vue b/packages/playground/src/components/FormGroup.vue index 78d641c5..fc2e9d37 100644 --- a/packages/playground/src/components/FormGroup.vue +++ b/packages/playground/src/components/FormGroup.vue @@ -1,16 +1,16 @@ @@ -19,5 +19,6 @@ import { FormGroupProps, useFormGroup } from '@formwerk/core'; const props = defineProps(); -const { labelProps, groupProps, getErrors, getError, displayError, getValues, isValid, isDirty, isTouched } = useFormGroup(props); +const { labelProps, groupProps, getErrors, getError, displayError, getValues, isValid, isDirty, isTouched } = + useFormGroup(props);