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/eslint.config.js b/eslint.config.js index af97e02c..dd83646f 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -18,6 +18,7 @@ export default tseslint.config( '@typescript-eslint/no-use-before-define': 'off', '@typescript-eslint/explicit-module-boundary-types': 'off', '@typescript-eslint/no-explicit-any': 'warn', + 'no-console': ['error', { allow: ['warn', 'error'] }], }, }, { diff --git a/global.d.ts b/global.d.ts new file mode 100644 index 00000000..b867229b --- /dev/null +++ b/global.d.ts @@ -0,0 +1 @@ +declare const __DEV__: boolean; diff --git a/packages/core/src/constants/index.ts b/packages/core/src/constants/index.ts index fb4c8c08..31c00570 100644 --- a/packages/core/src/constants/index.ts +++ b/packages/core/src/constants/index.ts @@ -8,6 +8,7 @@ export const FieldTypePrefixes = { RadioButtonGroup: 'rbg', Slider: 'sl', SearchField: 'sf', + FormGroup: 'fg', } as const; export const NOOP = () => {}; diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index dc451f7b..8f32603d 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -8,6 +8,7 @@ export * from './useNumberField'; export * from './useSpinButton'; export * from './types'; export * from './config'; -export * from './form'; +export * from './useForm'; +export * from './useFormGroup'; export * from './validation'; export { normalizePath } from './utils/path'; diff --git a/packages/core/src/types/common.ts b/packages/core/src/types/common.ts index 52af0a20..3cff3715 100644 --- a/packages/core/src/types/common.ts +++ b/packages/core/src/types/common.ts @@ -4,7 +4,7 @@ export type Numberish = number | `${number}`; export type AriaLabelProps = { id: string; - for: string; + for?: string; }; export type AriaDescriptionProps = { diff --git a/packages/core/src/types/forms.ts b/packages/core/src/types/forms.ts index d86ccfb3..bba8b9eb 100644 --- a/packages/core/src/types/forms.ts +++ b/packages/core/src/types/forms.ts @@ -1,9 +1,37 @@ import { Schema, Simplify } from 'type-fest'; import { FormObject } from './common'; import { Path } from './paths'; +import { TypedSchemaError } from './typedSchema'; +import { FormValidationMode } from '../useForm/formContext'; export type TouchedSchema = Simplify>; export type DisabledSchema = Partial, boolean>>; export type ErrorsSchema = Partial, string[]>>; + +type BaseValidationResult = { + isValid: boolean; + errors: TypedSchemaError[]; +}; + +export interface ValidationResult extends BaseValidationResult { + type: 'FIELD'; + output: TValue; + path: string; +} + +export interface GroupValidationResult extends BaseValidationResult { + type: 'GROUP'; + path: string; + output: TOutput; + mode: FormValidationMode; +} + +export interface FormValidationResult extends BaseValidationResult { + type: 'FORM'; + output: TOutput; + mode: FormValidationMode; +} + +export type AnyValidationResult = GroupValidationResult | ValidationResult; diff --git a/packages/core/src/useCheckbox/useCheckbox.ts b/packages/core/src/useCheckbox/useCheckbox.ts index a7f279d9..f353fe8d 100644 --- a/packages/core/src/useCheckbox/useCheckbox.ts +++ b/packages/core/src/useCheckbox/useCheckbox.ts @@ -1,9 +1,9 @@ import { Ref, computed, inject, nextTick, ref, toValue } from 'vue'; import { isEqual, normalizeProps, useUniqId, withRefCapture } from '../utils/common'; -import { AriaLabelableProps, Reactivify, InputBaseAttributes, RovingTabIndex } from '../types'; +import { AriaLabelableProps, Reactivify, InputBaseAttributes, RovingTabIndex, TypedSchema } from '../types'; import { useLabel } from '../a11y/useLabel'; import { CheckboxGroupContext, CheckboxGroupKey } from './useCheckboxGroup'; -import { useFormField } from '../form/useFormField'; +import { useFormField } from '../useFormField'; import { FieldTypePrefixes } from '../constants'; export interface CheckboxProps { @@ -14,6 +14,8 @@ export interface CheckboxProps { trueValue?: TValue; falseValue?: TValue; indeterminate?: boolean; + + schema?: TypedSchema; } export interface CheckboxDomInputProps extends AriaLabelableProps, InputBaseAttributes { @@ -30,10 +32,10 @@ export interface CheckboxDomProps extends AriaLabelableProps { } export function useCheckbox( - _props: Reactivify>, + _props: Reactivify, 'schema'>, elementRef?: Ref, ) { - const props = normalizeProps(_props); + const props = normalizeProps(_props, ['schema']); const inputId = useUniqId(FieldTypePrefixes.Checkbox); const getTrueValue = () => (toValue(props.trueValue) as TValue) ?? (true as TValue); const getFalseValue = () => (toValue(props.falseValue) as TValue) ?? (false as TValue); @@ -45,6 +47,7 @@ export function useCheckbox( path: props.name, initialValue: toValue(props.modelValue) as TValue, disabled: props.disabled, + schema: props.schema, }); const checked = computed({ diff --git a/packages/core/src/useCheckbox/useCheckboxGroup.ts b/packages/core/src/useCheckbox/useCheckboxGroup.ts index 6bd0906d..f91b3334 100644 --- a/packages/core/src/useCheckbox/useCheckboxGroup.ts +++ b/packages/core/src/useCheckbox/useCheckboxGroup.ts @@ -9,12 +9,13 @@ import { Direction, Reactivify, Arrayable, + TypedSchema, } from '../types'; import { useUniqId, createDescribedByProps, normalizeProps, isEqual } from '../utils/common'; import { useLocale } from '../i18n/useLocale'; -import { useFormField } from '../form/useFormField'; +import { useFormField } from '../useFormField'; import { FieldTypePrefixes } from '../constants'; -import { useErrorDisplay } from '../form/useErrorDisplay'; +import { useErrorDisplay } from '../useFormField/useErrorDisplay'; export type CheckboxGroupValue = TCheckbox[]; @@ -59,6 +60,8 @@ export interface CheckboxGroupProps { disabled?: boolean; readonly?: boolean; required?: boolean; + + schema?: TypedSchema>; } interface CheckboxGroupDomProps extends AriaLabelableProps, AriaDescribableProps, AriaValidatableProps { @@ -66,8 +69,8 @@ interface CheckboxGroupDomProps extends AriaLabelableProps, AriaDescribableProps dir: Direction; } -export function useCheckboxGroup(_props: Reactivify>) { - const props = normalizeProps(_props); +export function useCheckboxGroup(_props: Reactivify, 'schema'>) { + const props = normalizeProps(_props, ['schema']); const groupId = useUniqId(FieldTypePrefixes.CheckboxGroup); const { direction } = useLocale(); const checkboxes: CheckboxContext[] = []; @@ -79,6 +82,7 @@ export function useCheckboxGroup(_props: Reactivify { id: string; @@ -36,7 +36,7 @@ export interface BaseFormContext { setFieldErrors>(path: TPath, message: Arrayable): void; getValidationMode(): FormValidationMode; getErrors: () => TypedSchemaError[]; - clearErrors: () => void; + clearErrors: (path?: string) => void; hasErrors: () => boolean; getValues: () => TForm; setValues: (newValues: Partial, opts?: SetValueOptions) => void; @@ -83,7 +83,12 @@ export function createFormContext>(path: TPath) { - return !!getFromPath(touched, path); + const value = getFromPath(touched, path); + if (isObject(value)) { + return !!findLeaf(value, v => !!v); + } + + return !!value; } function isFieldSet>(path: TPath) { @@ -199,8 +204,17 @@ export function createFormContext; + function clearErrors(path?: string) { + if (!path) { + errors.value = {} as ErrorsSchema; + return; + } + + Object.keys(errors.value).forEach(key => { + if (key === path || key.startsWith(path)) { + delete errors.value[key as Path]; + } + }); } function revertValues() { @@ -212,7 +226,7 @@ export function createFormContext { inheritAttrs: false, setup: (_, { attrs }) => { const name = (attrs.name || 'test') as string; - const { errorMessage, inputProps } = useTextField({ name, label: name }); + const schema = attrs.schema as TypedSchema; + const { errorMessage, inputProps } = useTextField({ name, label: name, schema }); return { errorMessage: errorMessage, inputProps, name }; }, @@ -489,7 +490,7 @@ describe('form validation', () => { }); await fireEvent.click(screen.getByText('Submit')); - await nextTick(); + await flush(); expect(screen.getByTestId('err').textContent).toBe('error'); expect(screen.getByTestId('form-err').textContent).toBe('error'); expect(handler).not.toHaveBeenCalled(); @@ -534,7 +535,7 @@ describe('form validation', () => { expect(screen.getByTestId('err').textContent).toBe('error'); expect(screen.getByTestId('form-err').textContent).toBe('error'); await fireEvent.click(screen.getByText('Submit')); - await nextTick(); + await flush(); expect(handler).toHaveBeenCalledOnce(); expect(screen.getByTestId('err').textContent).toBe(''); expect(screen.getByTestId('form-err').textContent).toBe(''); @@ -577,7 +578,7 @@ describe('form validation', () => { }); await fireEvent.click(screen.getByText('Submit')); - await nextTick(); + await flush(); expect(handler).toHaveBeenCalledOnce(); expect(handler).toHaveBeenLastCalledWith({ test: true, foo: 'bar' }); }); @@ -633,6 +634,52 @@ describe('form validation', () => { expect(values).toEqual({ test: 'foo' }); }); + + test('combines errors from field-level schemas', async () => { + const handler = vi.fn(); + const schema: TypedSchema = { + async parse() { + return { + errors: [{ path: 'test', messages: ['error'] }], + }; + }, + }; + + const fieldSchema: TypedSchema = { + async parse() { + return { + errors: [{ path: 'field', messages: ['field error'] }], + }; + }, + }; + + await render({ + components: { Child: createInputComponent() }, + setup() { + const { handleSubmit, getError } = useForm({ + schema, + }); + + return { getError, onSubmit: handleSubmit(handler), fieldSchema }; + }, + template: ` +
+ + + {{ getError('test') }} + {{ getError('field') }} + + + + `, + }); + + await fireEvent.click(screen.getByText('Submit')); + await flush(); + expect(screen.getByTestId('form-err').textContent).toBe('error'); + expect(screen.getByTestId('field-err').textContent).toBe('field error'); + expect(handler).not.toHaveBeenCalled(); + }); }); test('form reset clears errors', async () => { diff --git a/packages/core/src/form/useForm.ts b/packages/core/src/useForm/useForm.ts similarity index 93% rename from packages/core/src/form/useForm.ts rename to packages/core/src/useForm/useForm.ts index 518e494d..1bcbdc46 100644 --- a/packages/core/src/form/useForm.ts +++ b/packages/core/src/useForm/useForm.ts @@ -9,10 +9,13 @@ import { ErrorsSchema, Path, TypedSchema, + ValidationResult, + FormValidationResult, + GroupValidationResult, } from '../types'; import { createFormContext, BaseFormContext } from './formContext'; import { FormTransactionManager, useFormTransactions } from './useFormTransactions'; -import { FormValidationResult, useFormActions } from './useFormActions'; +import { useFormActions } from './useFormActions'; import { useFormSnapshots } from './formSnapshot'; import { findLeaf } from '../utils/path'; @@ -28,7 +31,9 @@ export interface FormContext { requestValidation(): Promise>; onSubmitAttempt(cb: () => void): void; - onNativeValidationDispatch(cb: () => void): void; + onValidationDispatch( + cb: (enqueue: (promise: Promise) => void) => void, + ): void; } export const FormKey: InjectionKey> = Symbol('Formwerk FormKey'); diff --git a/packages/core/src/form/useFormActions.ts b/packages/core/src/useForm/useFormActions.ts similarity index 61% rename from packages/core/src/form/useFormActions.ts rename to packages/core/src/useForm/useFormActions.ts index e848289b..ffe42d2e 100644 --- a/packages/core/src/form/useFormActions.ts +++ b/packages/core/src/useForm/useFormActions.ts @@ -1,10 +1,18 @@ import { shallowRef } from 'vue'; -import { DisabledSchema, FormObject, MaybeAsync, Path, TouchedSchema, TypedSchema, TypedSchemaError } from '../types'; -import { batchAsync, cloneDeep, withLatestCall } from '../utils/common'; +import { + DisabledSchema, + FormObject, + FormValidationResult, + MaybeAsync, + Path, + TouchedSchema, + TypedSchema, + TypedSchemaError, +} from '../types'; import { createEventDispatcher } from '../utils/events'; -import { BaseFormContext, FormValidationMode, SetValueOptions } from './formContext'; +import { BaseFormContext, SetValueOptions } from './formContext'; import { unsetPath } from '../utils/path'; -import { SCHEMA_BATCH_MS } from '../constants'; +import { useValidationProvider } from '../validation/useValidationProvider'; export interface ResetState { values: Partial; @@ -17,26 +25,25 @@ export interface FormActionsOptions; } -export interface FormValidationResult { - isValid: boolean; - errors: TypedSchemaError[]; - output: TOutput; - mode: FormValidationMode; -} - export function useFormActions( form: BaseFormContext, { disabled, schema }: FormActionsOptions, ) { const isSubmitting = shallowRef(false); const [dispatchSubmit, onSubmitAttempt] = createEventDispatcher('submit'); - const [dispatchValidate, onNativeValidationDispatch] = createEventDispatcher('native-validate'); + const { + validate: _validate, + onValidationDispatch, + defineValidationRequest, + } = useValidationProvider({ schema, getValues: () => form.getValues(), type: 'FORM' }); + const requestValidation = defineValidationRequest(updateValidationStateFromResult); function handleSubmit(onSuccess: (values: TOutput) => MaybeAsync) { return async function onSubmit(e: Event) { e.preventDefault(); isSubmitting.value = true; + // No need to wait for this event to propagate, it is used for non-validation stuff like setting touched state. dispatchSubmit(); const { isValid, output } = await validate(); // Prevent submission if the form has errors @@ -62,33 +69,6 @@ export function useFormActions> { - // If we are using native validation, then we don't stop the state mutation - // Because it already has happened, since validations are sourced from the fields. - if (form.getValidationMode() === 'native' || !schema) { - await dispatchValidate(); - - return { - mode: 'native', - isValid: !form.hasErrors(), - errors: form.getErrors(), - output: cloneDeep(form.getValues() as unknown as TOutput), - }; - } - - const { errors, output } = await schema.parse(form.getValues()); - - return { - mode: 'schema', - isValid: !errors.length, - errors, - output: cloneDeep(output ?? (form.getValues() as unknown as TOutput)), - }; - } - function updateValidationStateFromResult(result: FormValidationResult) { form.clearErrors(); applyErrors(result.errors); @@ -98,10 +78,6 @@ export function useFormActions> { const result = await _validate(); - if (result.mode === 'native') { - return result; - } - updateValidationStateFromResult(result); return result; @@ -134,12 +110,6 @@ export function useFormActions { - updateValidationStateFromResult(result); - - return result; - }); - return { actions: { handleSubmit, @@ -148,7 +118,7 @@ export function useFormActions { const { fieldValue } = await renderSetup(() => { @@ -114,3 +115,38 @@ test('formless fields maintain their own error state', async () => { expect(errorMessage.value).toBe('error'); expect(errors.value).toEqual(['error']); }); + +test('can have a typed schema for validation', async () => { + const { validate, errors } = await renderSetup(() => { + return useFormField({ + initialValue: 'bar', + schema: { + parse: async () => { + return { errors: [{ messages: ['error'], path: 'field' }] }; + }, + }, + }); + }); + + expect(errors.value).toEqual([]); + await validate(true); + expect(errors.value).toEqual(['error']); +}); + +test('can have a typed schema for initial value', async () => { + const { fieldValue } = await renderSetup(() => { + return useFormField({ + schema: { + parse: async () => { + return { errors: [] }; + }, + defaults(value) { + return value || 'default'; + }, + }, + }); + }); + + await nextTick(); + expect(fieldValue.value).toEqual('default'); +}); diff --git a/packages/core/src/form/useFormField.ts b/packages/core/src/useFormField/useFormField.ts similarity index 80% rename from packages/core/src/form/useFormField.ts rename to packages/core/src/useFormField/useFormField.ts index 9c712494..b182f16f 100644 --- a/packages/core/src/form/useFormField.ts +++ b/packages/core/src/useFormField/useFormField.ts @@ -10,10 +10,11 @@ import { toValue, watch, } from 'vue'; -import { FormContext, FormKey } from './useForm'; -import { Arrayable, Getter } from '../types'; +import { FormContext, FormKey } from '../useForm/useForm'; +import { Arrayable, Getter, TypedSchema, ValidationResult } from '../types'; import { useSyncModel } from '../reactivity/useModelSync'; import { cloneDeep, isEqual, normalizeArrayable } from '../utils/common'; +import { FormGroupKey } from '../useFormGroup'; interface FormFieldOptions { path: MaybeRefOrGetter | undefined; @@ -22,6 +23,7 @@ interface FormFieldOptions { syncModel: boolean; modelName: string; disabled: MaybeRefOrGetter; + schema: TypedSchema; } export type FormField = { @@ -31,6 +33,10 @@ export type FormField = { isValid: Ref; errors: Ref; errorMessage: Ref; + schema: TypedSchema | undefined; + validate(mutate?: boolean): Promise; + getPath: Getter; + getName: Getter; setValue: (value: TValue | undefined) => void; setTouched: (touched: boolean) => void; setErrors: (messages: Arrayable) => void; @@ -38,19 +44,25 @@ export type FormField = { export function useFormField(opts?: Partial>): FormField { const form = inject(FormKey, null); - const getPath = () => toValue(opts?.path); - const { fieldValue, pathlessValue, setValue } = useFieldValue(getPath, form, opts?.initialValue); + const formGroup = inject(FormGroupKey, null); + const getPath = () => { + const path = toValue(opts?.path); + + return formGroup ? formGroup.prefixPath(path) : path; + }; + const initialValue = opts?.schema?.defaults?.(opts?.initialValue as TValue) ?? opts?.initialValue; + const { fieldValue, pathlessValue, setValue } = useFieldValue(getPath, form, initialValue); const { isTouched, pathlessTouched, setTouched } = useFieldTouched(getPath, form); const { errors, setErrors, isValid, errorMessage, pathlessValidity } = useFieldValidity(getPath, form); const isDirty = computed(() => { if (!form) { - return !isEqual(fieldValue.value, opts?.initialValue); + return !isEqual(fieldValue.value, initialValue); } const path = getPath(); if (!path) { - return !isEqual(pathlessValue.value, opts?.initialValue); + return !isEqual(pathlessValue.value, initialValue); } return !isEqual(fieldValue.value, form.getFieldOriginalValue(path)); @@ -64,6 +76,34 @@ export function useFormField(opts?: Partial): ValidationResult { + return { + type: 'FIELD', + path: (formGroup ? toValue(opts?.path) : getPath()) || '', + ...result, + }; + } + + async function validate(mutate?: boolean): Promise { + const schema = opts?.schema; + if (!schema) { + return Promise.resolve( + createValidationResult({ isValid: true, errors: [], output: cloneDeep(fieldValue.value) }), + ); + } + + const { errors, output } = await schema.parse(fieldValue.value as TValue); + if (mutate) { + setErrors(errors.map(e => e.messages).flat()); + } + + return createValidationResult({ + isValid: errors.length === 0, + output, + errors: errors.map(e => ({ messages: e.messages, path: getPath() || e.path })), + }); + } + const field: FormField = { fieldValue: readonly(fieldValue) as Ref, isTouched: readonly(isTouched) as Ref, @@ -71,6 +111,10 @@ export function useFormField(opts?: Partial toValue(opts?.path), setValue, setTouched, setErrors, @@ -80,13 +124,7 @@ export function useFormField(opts?: Partial { setTouched(true); diff --git a/packages/core/src/useFormGroup/index.md b/packages/core/src/useFormGroup/index.md new file mode 100644 index 00000000..e69de29b diff --git a/packages/core/src/useFormGroup/index.ts b/packages/core/src/useFormGroup/index.ts new file mode 100644 index 00000000..b8a18462 --- /dev/null +++ b/packages/core/src/useFormGroup/index.ts @@ -0,0 +1 @@ +export * from './useFormGroup'; diff --git a/packages/core/src/useFormGroup/useFormGroup.spec.ts b/packages/core/src/useFormGroup/useFormGroup.spec.ts new file mode 100644 index 00000000..244b2edc --- /dev/null +++ b/packages/core/src/useFormGroup/useFormGroup.spec.ts @@ -0,0 +1,318 @@ +import { renderSetup } from '@test-utils/renderSetup'; +import { Component } from 'vue'; +import { TypedSchema, useTextField, useForm, useFormGroup } from '@core/index'; +import { fireEvent, render, screen } from '@testing-library/vue'; +import { flush } from '@test-utils/flush'; + +function createInputComponent(): Component { + return { + inheritAttrs: false, + setup: (_, { attrs }) => { + const name = (attrs.name || 'test') as string; + const schema = attrs.schema as TypedSchema; + const { errorMessage, inputProps } = useTextField({ name, label: name, schema }); + + return { errorMessage: errorMessage, inputProps, name }; + }, + template: ` + + {{ errorMessage }} + `, + }; +} + +function createGroupComponent(fn?: (fg: ReturnType) => void): Component { + return { + inheritAttrs: false, + setup: (_, { attrs }) => { + const name = (attrs.name || 'test') as string; + const schema = attrs.schema as TypedSchema; + const fg = useFormGroup({ name, label: name, schema }); + fn?.(fg); + + return {}; + }, + template: ` + + `, + }; +} + +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({ + components: { TInput: createInputComponent(), TGroup: createGroupComponent() }, + setup() { + form = useForm(); + + return {}; + }, + template: ` + + + + + + + + `, + }); + + await flush(); + await fireEvent.update(screen.getByTestId('field1'), 'test 1'); + await fireEvent.update(screen.getByTestId('field2'), 'test 2'); + await flush(); + + expect(form.values).toEqual({ groupTest: { field1: 'test 1' }, nestedGroup: { deep: { field2: 'test 2' } } }); +}); + +test('tracks its dirty state', async () => { + const groups: ReturnType[] = []; + await render({ + components: { TInput: createInputComponent(), TGroup: createGroupComponent(fg => groups.push(fg)) }, + setup() { + useForm(); + + return {}; + }, + template: ` + + + + + + + + `, + }); + + await flush(); + expect(groups[0].isDirty.value).toBe(false); + expect(groups[1].isDirty.value).toBe(false); + await fireEvent.update(screen.getByTestId('field1'), 'test 1'); + await flush(); + expect(groups[0].isDirty.value).toBe(true); + expect(groups[1].isDirty.value).toBe(false); +}); + +test('tracks its touched state', async () => { + const groups: ReturnType[] = []; + await render({ + components: { TInput: createInputComponent(), TGroup: createGroupComponent(fg => groups.push(fg)) }, + setup() { + useForm(); + + return {}; + }, + template: ` + + + + + + + + `, + }); + + await flush(); + expect(groups[0].isTouched.value).toBe(false); + expect(groups[1].isTouched.value).toBe(false); + await fireEvent.touch(screen.getByTestId('field1')); + await flush(); + expect(groups[0].isTouched.value).toBe(true); + expect(groups[1].isTouched.value).toBe(false); +}); + +test('tracks its valid state', async () => { + const groups: ReturnType[] = []; + const schema: TypedSchema = { + async parse(value) { + return { + errors: value ? [] : [{ path: 'groupTest.field1', messages: ['error'] }], + }; + }, + }; + + await render({ + components: { TInput: createInputComponent(), TGroup: createGroupComponent(fg => groups.push(fg)) }, + setup() { + useForm(); + + return { + schema, + }; + }, + template: ` + + + + + + + + `, + }); + + await flush(); + 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')); + 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 new file mode 100644 index 00000000..f4129289 --- /dev/null +++ b/packages/core/src/useFormGroup/useFormGroup.ts @@ -0,0 +1,206 @@ +import { + computed, + createBlock, + defineComponent, + Fragment, + inject, + InjectionKey, + openBlock, + provide, + Ref, + shallowRef, + toValue, + VNode, +} from 'vue'; +import { useLabel } from '../a11y/useLabel'; +import { FieldTypePrefixes } from '../constants'; +import { + AriaLabelableProps, + AriaLabelProps, + FormObject, + GroupValidationResult, + Reactivify, + TypedSchema, + ValidationResult, +} from '../types'; +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; + label?: string; + schema?: TypedSchema; +} + +interface GroupProps extends AriaLabelableProps { + id: string; + role?: 'group'; +} + +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'); + +export function useFormGroup( + _props: Reactivify, 'schema'>, + elementRef?: Ref, +) { + const id = useUniqId(FieldTypePrefixes.FormGroup); + const props = normalizeProps(_props, ['schema']); + const getPath = () => toValue(props.name); + const groupRef = elementRef || shallowRef(); + const form = inject(FormKey, null); + const { validate, onValidationDispatch, defineValidationRequest } = useValidationProvider({ + getValues, + getPath, + schema: props.schema, + type: 'GROUP', + }); + + const requestValidation = defineValidationRequest(({ errors }) => { + // Clears Errors in that path before proceeding. + form?.clearErrors(toValue(props.name)); + for (const entry of errors) { + form?.setFieldErrors(prefixPath(entry.path) ?? '', entry.messages); + } + }); + + if (!form) { + warn('Form groups must have a parent form. Please make sure to call `useForm` at a parent component.'); + } + + const { labelProps, labelledByProps } = useLabel({ + for: id, + label: props.label, + targetRef: groupRef, + }); + + const groupProps = computed(() => { + const isFieldSet = groupRef.value?.tagName === 'FIELDSET'; + + return withRefCapture( + { + id, + ...(isFieldSet ? {} : labelledByProps.value), + role: isFieldSet ? undefined : 'group', + }, + groupRef, + elementRef, + ); + }); + + const FormGroup = createInlineFormGroupComponent({ groupProps, labelProps }); + + function getValues() { + return form?.getFieldValue(getPath()) ?? {}; + } + + function getErrors() { + const path = getPath(); + const allErrors = form?.getErrors() || []; + + return allErrors.filter(e => e.path.startsWith(path)); + } + + const isValid = computed(() => getErrors().length === 0); + const isTouched = computed(() => form?.isFieldTouched(getPath()) ?? false); + const isDirty = computed(() => { + const path = getPath(); + + return !isEqual(getValues(), form?.getFieldOriginalValue(path) ?? {}); + }); + + function getError(name: string) { + return form?.getFieldErrors(ctx.prefixPath(name) ?? '')?.[0]; + } + + function displayError(name: string) { + const msg = getError(name); + const path = ctx.prefixPath(name) ?? ''; + + return form?.isFieldTouched(path) ? msg : undefined; + } + + function prefixPath(path: string | undefined) { + return prefixGroupPath(getPath(), path); + } + + const ctx: FormGroupContext = { + prefixPath, + onValidationDispatch, + requestValidation, + getValidationMode: () => (props.schema ? 'schema' : 'aggregate'), + }; + + // Whenever the form is validated, it is deferred to the form group to do that. + // Fields should not validate in response to their form triggering a validate and instead should follow the field group event + form?.onValidationDispatch(enqueue => { + enqueue( + validate().then(result => { + return { + ...result, + errors: result.errors.map(e => ({ path: prefixPath(e.path) ?? '', messages: e.messages })), + }; + }), + ); + }); + + provide(FormGroupKey, ctx); + + return { + groupRef, + labelProps, + groupProps, + FormGroup, + isDirty, + isValid, + isTouched, + getErrors, + getValues, + getError, + displayError, + validate, + }; +} + +interface InlineComponentProps { + groupProps: GroupProps; + labelProps: AriaLabelProps; +} + +function createInlineFormGroupComponent({ groupProps, labelProps }: Reactivify) { + const impl = defineComponent({ + setup(_, { slots }) { + return () => ( + openBlock(), + createBlock(Fragment), + slots.default?.({ groupProps: toValue(groupProps), labelProps: toValue(labelProps) }) + ); + }, + }); + + return impl as typeof impl & { + new (): { + $slots: { + default: (arg: InlineComponentProps) => VNode[]; + }; + }; + }; +} + +function prefixGroupPath(prefix: string | undefined, path: string | undefined) { + if (!path) { + return path; + } + + prefix = prefix ? `${prefix}.` : ''; + + return `${prefix}${path}`; +} diff --git a/packages/core/src/useNumberField/index.ts b/packages/core/src/useNumberField/index.ts index dd925368..d5249d38 100644 --- a/packages/core/src/useNumberField/index.ts +++ b/packages/core/src/useNumberField/index.ts @@ -21,9 +21,10 @@ import { useLabel } from '../a11y/useLabel'; import { useNumberParser } from '../i18n/useNumberParser'; import { useSpinButton } from '../useSpinButton'; import { useLocale } from '../i18n/useLocale'; -import { useFormField } from '../form/useFormField'; +import { useFormField } from '../useFormField'; import { FieldTypePrefixes } from '../constants'; -import { useErrorDisplay } from '../form/useErrorDisplay'; +import { useErrorDisplay } from '../useFormField/useErrorDisplay'; +import { TypedSchema } from '../types'; export interface NumberInputDOMAttributes { name?: string; @@ -59,13 +60,15 @@ export interface NumberFieldProps { disabled?: boolean; formatOptions?: Intl.NumberFormatOptions; + + schema?: TypedSchema; } export function useNumberField( - _props: Reactivify, + _props: Reactivify, elementRef?: Ref, ) { - const props = normalizeProps(_props); + const props = normalizeProps(_props, ['schema']); const inputId = useUniqId(FieldTypePrefixes.NumberField); const inputRef = elementRef || shallowRef(); const { locale } = useLocale(); @@ -74,6 +77,7 @@ export function useNumberField( path: props.name, initialValue: toValue(props.modelValue), disabled: props.disabled, + schema: props.schema, }); const { validityDetails } = useInputValidity({ inputRef, field }); diff --git a/packages/core/src/useRadio/useRadioGroup.ts b/packages/core/src/useRadio/useRadioGroup.ts index bd89a3df..07456a7d 100644 --- a/packages/core/src/useRadio/useRadioGroup.ts +++ b/packages/core/src/useRadio/useRadioGroup.ts @@ -9,12 +9,13 @@ import { Direction, Reactivify, Arrayable, + TypedSchema, } from '../types'; import { useUniqId, createDescribedByProps, getNextCycleArrIdx, normalizeProps, isEmpty } from '../utils/common'; import { useLocale } from '../i18n/useLocale'; -import { useFormField } from '../form/useFormField'; +import { useFormField } from '../useFormField'; import { FieldTypePrefixes } from '../constants'; -import { useErrorDisplay } from '../form/useErrorDisplay'; +import { useErrorDisplay } from '../useFormField/useErrorDisplay'; export interface RadioGroupContext { name: string; @@ -49,6 +50,8 @@ export interface RadioGroupProps { disabled?: boolean; readonly?: boolean; required?: boolean; + + schema?: TypedSchema; } interface RadioGroupDomProps extends AriaLabelableProps, AriaDescribableProps, AriaValidatableProps { @@ -74,8 +77,8 @@ function getOrientationArrows(dir: Direction | undefined) { return { prev: prevKeys, next: nextKeys }; } -export function useRadioGroup(_props: Reactivify>) { - const props = normalizeProps(_props); +export function useRadioGroup(_props: Reactivify, 'schema'>) { + const props = normalizeProps(_props, ['schema']); const groupId = useUniqId(FieldTypePrefixes.RadioButtonGroup); const { direction } = useLocale(); @@ -89,6 +92,7 @@ export function useRadioGroup(_props: Reactivify; + onSubmit?: (value: string) => void; } -export function useSearchField(_props: Reactivify, elementRef?: Ref) { - const props = normalizeProps(_props, ['onSubmit']); +export function useSearchField( + _props: Reactivify, + elementRef?: Ref, +) { + const props = normalizeProps(_props, ['onSubmit', 'schema']); const inputId = useUniqId(FieldTypePrefixes.SearchField); const inputRef = elementRef || ref(); const field = useFormField({ path: props.name, initialValue: toValue(props.modelValue), disabled: props.disabled, + schema: props.schema, }); const { validityDetails, updateValidity } = useInputValidity({ inputRef, field }); diff --git a/packages/core/src/useSlider/slider.ts b/packages/core/src/useSlider/slider.ts index 6845d18a..aeca76a9 100644 --- a/packages/core/src/useSlider/slider.ts +++ b/packages/core/src/useSlider/slider.ts @@ -1,10 +1,10 @@ import { InjectionKey, computed, onBeforeUnmount, provide, ref, toValue } from 'vue'; import { useLabel } from '../a11y/useLabel'; -import { AriaLabelableProps, Arrayable, Direction, Orientation, Reactivify } from '../types'; +import { AriaLabelableProps, Arrayable, Direction, Orientation, Reactivify, TypedSchema } from '../types'; import { isNullOrUndefined, normalizeProps, useUniqId, withRefCapture } from '../utils/common'; import { toNearestMultipleOf } from '../utils/math'; import { useLocale } from '../i18n/useLocale'; -import { useFormField } from '../form/useFormField'; +import { useFormField } from '../useFormField'; import { FieldTypePrefixes } from '../constants'; export interface SliderProps { @@ -19,6 +19,8 @@ export interface SliderProps { step?: number; disabled?: boolean; + + schema?: TypedSchema; } export type Coordinate = { x: number; y: number }; @@ -94,8 +96,8 @@ export interface SliderContext { export const SliderInjectionKey: InjectionKey = Symbol('Slider'); -export function useSlider(_props: Reactivify) { - const props = normalizeProps(_props); +export function useSlider(_props: Reactivify) { + const props = normalizeProps(_props, ['schema']); const inputId = useUniqId(FieldTypePrefixes.Slider); const trackRef = ref(); const thumbs = ref([]); @@ -105,6 +107,7 @@ export function useSlider(_props: Reactivify) { path: props.name, initialValue: toValue(props.modelValue), disabled: props.disabled, + schema: props.schema, }); const { labelProps, labelledByProps } = useLabel({ diff --git a/packages/core/src/useSwitch/index.ts b/packages/core/src/useSwitch/index.ts index 4804057b..b9f90ff5 100644 --- a/packages/core/src/useSwitch/index.ts +++ b/packages/core/src/useSwitch/index.ts @@ -1,8 +1,15 @@ import { Ref, computed, shallowRef, toValue } from 'vue'; -import { AriaDescribableProps, AriaLabelableProps, InputBaseAttributes, InputEvents, Reactivify } from '../types'; +import { + AriaDescribableProps, + AriaLabelableProps, + InputBaseAttributes, + InputEvents, + Reactivify, + TypedSchema, +} from '../types'; import { isEqual, normalizeProps, useUniqId, withRefCapture } from '../utils/common'; import { useLabel } from '../a11y/useLabel'; -import { useFormField } from '../form/useFormField'; +import { useFormField } from '../useFormField'; import { FieldTypePrefixes } from '../constants'; export interface SwitchDOMProps extends InputBaseAttributes, AriaLabelableProps, AriaDescribableProps, InputEvents { @@ -22,10 +29,12 @@ export type SwitchProps = { trueValue?: unknown; falseValue?: unknown; + + schema?: TypedSchema; }; -export function useSwitch(_props: Reactivify, elementRef?: Ref) { - const props = normalizeProps(_props); +export function useSwitch(_props: Reactivify, elementRef?: Ref) { + const props = normalizeProps(_props, ['schema']); const id = useUniqId(FieldTypePrefixes.Switch); const inputRef = elementRef || shallowRef(); const { labelProps, labelledByProps } = useLabel({ @@ -38,6 +47,7 @@ export function useSwitch(_props: Reactivify, elementRef?: Ref; } export function useTextField( - _props: Reactivify, + _props: Reactivify, elementRef?: Ref, ) { - const props = normalizeProps(_props); + const props = normalizeProps(_props, ['schema']); const inputId = useUniqId(FieldTypePrefixes.TextField); const inputRef = elementRef || shallowRef(); const field = useFormField({ path: props.name, initialValue: toValue(props.modelValue), disabled: props.disabled, + schema: props.schema, }); const { validityDetails } = useInputValidity({ inputRef, field }); diff --git a/packages/core/src/utils/common.ts b/packages/core/src/utils/common.ts index 008f6bd7..f2224da4 100644 --- a/packages/core/src/utils/common.ts +++ b/packages/core/src/utils/common.ts @@ -89,7 +89,7 @@ export function normalizeProps, Exclude e return [key, (...args: any[]) => (props[key] as any)(...args)]; } - return [key, () => props[key]]; + return [key, props[key]]; }), ) as NormalizedProps; } @@ -278,3 +278,9 @@ export function batchAsync Promise, TRe return new Promise(resolve => resolves.push(resolve)); }; } + +export function warn(message: string) { + if (__DEV__) { + console.warn(`[Formwerk]: ${message}`); + } +} diff --git a/packages/core/src/validation/useInputValidity.spec.ts b/packages/core/src/validation/useInputValidity.spec.ts index 68443a60..6b401296 100644 --- a/packages/core/src/validation/useInputValidity.spec.ts +++ b/packages/core/src/validation/useInputValidity.spec.ts @@ -1,7 +1,7 @@ import { nextTick, ref } from 'vue'; import { useInputValidity } from './useInputValidity'; import { fireEvent, render, screen } from '@testing-library/vue'; -import { FormField, useFormField } from '../form'; +import { FormField, useFormField } from '../useFormField'; test('updates the validity state on blur events', async () => { const input = ref(); @@ -91,6 +91,7 @@ test('updates the input native validity with custom validity errors', async () = `, }); + await nextTick(); field.setErrors('Custom error'); await nextTick(); expect(screen.getByTestId('err').textContent).toBe('Custom error'); diff --git a/packages/core/src/validation/useInputValidity.ts b/packages/core/src/validation/useInputValidity.ts index ecbff1de..dfd8cbab 100644 --- a/packages/core/src/validation/useInputValidity.ts +++ b/packages/core/src/validation/useInputValidity.ts @@ -1,7 +1,10 @@ import { Ref, inject, nextTick, onMounted, shallowRef, watch } from 'vue'; import { useEventListener } from '../helpers/useEventListener'; -import { FormField, FormKey } from '../form'; -import { Maybe } from '../types'; +import { FormKey } from '../useForm'; +import { Maybe, ValidationResult } from '../types'; +import { FormField } from '../useFormField'; +import { cloneDeep, normalizeArrayable } from '../utils/common'; +import { FormGroupKey } from '../useFormGroup'; interface InputValidityOptions { inputRef?: Ref; @@ -11,23 +14,34 @@ interface InputValidityOptions { export function useInputValidity(opts: InputValidityOptions) { const form = inject(FormKey, null); - const { setErrors, errorMessage } = opts.field; + const formGroup = inject(FormGroupKey, null); + 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 updateWithNativeValidity() { + function validateNative(mutate?: boolean): ValidationResult { validityDetails.value = opts.inputRef?.value?.validity; - setErrors(opts.inputRef?.value?.validationMessage || []); + const messages = normalizeArrayable(opts.inputRef?.value?.validationMessage || ([] as string[])).filter(Boolean); + if (mutate) { + setErrors(messages); + } + + return { + type: 'FIELD', + path: (formGroup ? getName() : getPath()) || '', + output: cloneDeep(fieldValue.value), + isValid: !messages.length, + errors: [{ messages, path: getPath() || '' }], + }; } function _updateValidity() { - if (validationMode === 'native') { - updateWithNativeValidity(); - return; + if (validationMode === 'aggregate') { + return schema ? validateField(true) : validateNative(true); } - form?.requestValidation(); + (formGroup || form)?.requestValidation(); } async function updateValidity() { @@ -37,16 +51,30 @@ export function useInputValidity(opts: InputValidityOptions) { useEventListener(opts.inputRef, opts?.events || ['change', 'blur'], updateValidity); - if (validationMode === 'native') { - form?.onNativeValidationDispatch(updateWithNativeValidity); - useEventListener(opts.inputRef, opts?.events || ['invalid'], updateWithNativeValidity); + // It shouldn't mutate the field if the validation is sourced by the form. + // The form will handle the mutation later once it aggregates all the results. + (formGroup || form)?.onValidationDispatch(enqueue => { + if (schema) { + enqueue(validateField(false)); + return; + } + + if (validationMode === 'aggregate') { + enqueue(Promise.resolve(validateNative(false))); + return; + } + }); + + 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)); } /** * Validity is always updated on mount. */ onMounted(() => { - nextTick(updateValidity); + nextTick(_updateValidity); }); return { diff --git a/packages/core/src/validation/useValidationProvider.ts b/packages/core/src/validation/useValidationProvider.ts new file mode 100644 index 00000000..34efd99e --- /dev/null +++ b/packages/core/src/validation/useValidationProvider.ts @@ -0,0 +1,130 @@ +import { + AnyValidationResult, + FormObject, + FormValidationResult, + GroupValidationResult, + TypedSchema, + ValidationResult, +} from '../types'; +import { batchAsync, cloneDeep, withLatestCall } from '../utils/common'; +import { createEventDispatcher } from '../utils/events'; +import { SCHEMA_BATCH_MS } from '../constants'; +import { setInPath } from '../utils/path'; + +type AggregatorResult = FormValidationResult | GroupValidationResult; + +interface ValidationProviderOptions< + TInput extends FormObject, + TOutput extends FormObject, + TType extends AggregatorResult['type'], +> { + type: TType; + schema?: TypedSchema; + getValues: () => TInput; + getPath?: () => string; +} + +export function useValidationProvider< + TInput extends FormObject, + TOutput extends FormObject, + TType extends AggregatorResult['type'], + TResult extends AggregatorResult & { type: TType }, +>({ schema, getValues, type, getPath }: ValidationProviderOptions) { + const [dispatchValidate, onValidationDispatch] = + createEventDispatcher<(pending: Promise) => void>('validate'); + + /** + * Validates but tries to not mutate anything if possible. + */ + async function validate(): Promise { + const validationQueue: Promise[] = []; + const enqueue = (promise: Promise) => validationQueue.push(promise); + // This is meant to trigger a signal for all fields that can validate themselves to do so. + // Native validation is sync so no need to wait for pending validators. + // But field-level and group-level validations are async, so we need to wait for them. + await dispatchValidate(enqueue); + const results = await Promise.all(validationQueue); + const fieldErrors = results.flatMap(r => r.errors).filter(e => e.messages.length); + + // If we are using native validation, then we don't stop the state mutation + // Because it already has happened, since validations are sourced from the fields. + if (!schema) { + return createValidationResult({ + isValid: !fieldErrors.length, + errors: fieldErrors, + output: stitchOutput(getValues() as unknown as TOutput, results), + }); + } + + const { errors, output } = await schema.parse(getValues()); + const allErrors = [...errors, ...fieldErrors]; + + return createValidationResult({ + isValid: !allErrors.length, + errors: allErrors, + output: stitchOutput(output ?? (getValues() as unknown as TOutput), results), + }); + } + + function defineValidationRequest(mutator: (result: TResult) => void) { + const requestValidation = withLatestCall(batchAsync(validate, SCHEMA_BATCH_MS), result => { + mutator(result); + + return result; + }); + + return requestValidation; + } + + function createValidationResult(result: Omit, 'mode' | 'type'>): TResult { + const base = { + output: result.output, + errors: result.errors, + isValid: result.isValid, + }; + + if (type === 'FORM') { + return { + type, + mode: schema ? 'schema' : 'aggregate', + ...base, + } as TResult; + } + + return { + type: 'GROUP', + path: getPath?.() || '', + mode: schema ? 'schema' : 'aggregate', + ...base, + } as TResult; + } + + function stitchOutput(base: TOutput, results: (ValidationResult | GroupValidationResult)[]): TOutput { + const all = cloneDeep(base); + // Make sure we start with groups first since it may override individual fields + const sorted = [...results].sort((a, b) => { + if (a.type === b.type) { + return 0; + } + + return a.type === 'FIELD' ? 1 : -1; + }); + + for (const result of sorted) { + // Pathless fields will be dropped + if (!result.path) { + continue; + } + + setInPath(all, result.path, result.output); + } + + return all; + } + + return { + validate, + onValidationDispatch, + defineValidationRequest, + }; +} diff --git a/packages/playground/src/App.vue b/packages/playground/src/App.vue index 16c30fee..3698f563 100644 --- a/packages/playground/src/App.vue +++ b/packages/playground/src/App.vue @@ -1,34 +1,47 @@ diff --git a/packages/playground/src/components/FormGroup.vue b/packages/playground/src/components/FormGroup.vue new file mode 100644 index 00000000..fc2e9d37 --- /dev/null +++ b/packages/playground/src/components/FormGroup.vue @@ -0,0 +1,24 @@ + + + diff --git a/packages/schema-yup/src/index.spec.ts b/packages/schema-yup/src/index.spec.ts index 15167f65..4582960a 100644 --- a/packages/schema-yup/src/index.spec.ts +++ b/packages/schema-yup/src/index.spec.ts @@ -1,4 +1,4 @@ -import { type Component, nextTick } from 'vue'; +import { type Component } from 'vue'; import { fireEvent, render, screen } from '@testing-library/vue'; import { useForm, useTextField } from '@formwerk/core'; import { defineSchema } from '.'; @@ -129,8 +129,8 @@ describe('schema-yup', () => { `, }); - await nextTick(); await fireEvent.click(screen.getByText('Submit')); + await flush(); expect(handler).not.toHaveBeenCalled(); await fireEvent.update(screen.getByTestId('test'), 'test'); await fireEvent.click(screen.getByText('Submit')); diff --git a/packages/schema-zod/src/index.spec.ts b/packages/schema-zod/src/index.spec.ts index 948bd833..33da3ff4 100644 --- a/packages/schema-zod/src/index.spec.ts +++ b/packages/schema-zod/src/index.spec.ts @@ -1,4 +1,4 @@ -import { type Component, nextTick } from 'vue'; +import { type Component } from 'vue'; import { fireEvent, render, screen } from '@testing-library/vue'; import { useForm, useTextField } from '@formwerk/core'; import { defineSchema } from '.'; @@ -77,8 +77,8 @@ describe('schema-zod', () => { `, }); - await nextTick(); await fireEvent.click(screen.getByText('Submit')); + await flush(); expect(handler).not.toHaveBeenCalled(); await fireEvent.update(screen.getByTestId('test'), 'test'); await fireEvent.click(screen.getByText('Submit')); diff --git a/tsconfig.json b/tsconfig.json index 65f84432..2be2dc61 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -26,6 +26,6 @@ "types": ["vitest/globals"], "typeRoots": ["node_modules/@types", "node_modules"] }, - "include": ["packages/*/src", "packages/*/tests", "commitlint.config.ts"], + "include": ["packages/*/src", "packages/*/tests", "commitlint.config.ts", "global.d.ts"], "exclude": ["packages/playground"] }