From 66cc394bc55c2ed76c9fcfbdd978c21936e9a27e Mon Sep 17 00:00:00 2001 From: Van Nguyen Date: Thu, 3 Sep 2026 22:45:27 +0700 Subject: [PATCH] feat: complete live and async form validation --- .changeset/brown-buttons-shake.md | 15 ++ docs/ui-kit-recipes.md | 183 +++++++++++++++++ packages/angular/README.md | 19 +- packages/angular/src/components/FieldInput.ts | 6 +- .../angular/src/components/MultiFieldInput.ts | 24 +++ .../angular/src/lib/dynamic-form.store.ts | 14 +- .../angular/test/dynamicFormStore.spec.ts | 36 ++++ packages/angular/test/reportedIssues.spec.ts | 9 + packages/core/README.md | 17 +- packages/core/src/rendererProps.ts | 9 +- packages/core/src/validation.ts | 113 ++++++++++- .../core/test/asyncValidatorReporting.test.ts | 148 ++++++++++++++ packages/core/test/rendererProps.test.ts | 15 +- packages/react/README.md | 18 +- .../react/src/components/FieldGroupInput.tsx | 15 ++ packages/react/src/components/FieldInput.tsx | 5 + .../react/src/components/MultiFieldInput.tsx | 6 + packages/react/src/useDynamicForm.ts | 28 ++- packages/react/test/reportedIssues.test.tsx | 37 +++- .../react/test/useDynamicFormAsync.test.tsx | 184 ++++++++++++++++++ packages/vue/README.md | 18 +- packages/vue/src/components/FieldInput.ts | 6 + .../vue/src/components/MultiFieldInput.ts | 29 +++ packages/vue/src/useDynamicForm.ts | 14 +- packages/vue/test/reportedIssues.test.ts | 37 +++- packages/vue/test/useDynamicForm.test.ts | 36 ++++ 26 files changed, 990 insertions(+), 51 deletions(-) create mode 100644 docs/ui-kit-recipes.md create mode 100644 packages/core/test/asyncValidatorReporting.test.ts create mode 100644 packages/react/test/useDynamicFormAsync.test.tsx diff --git a/.changeset/brown-buttons-shake.md b/.changeset/brown-buttons-shake.md index 441f6c7..835bd91 100644 --- a/.changeset/brown-buttons-shake.md +++ b/.changeset/brown-buttons-shake.md @@ -60,3 +60,18 @@ resolves all of it through core. Keeping a second copy of that logic beside the shared one is how the adapters drifted apart to begin with; the equivalents are `resolveOptions`, `resolveDisabled`, `resolveReadOnly` and `validateField`, already re-exported from this package. + +Form validity now reflects current data immediately instead of merely checking +the lazily populated `errors` map. The error map remains lazy for display, and +passing a form binding (or the new controlled `errors` input) makes that same +map the renderer's source of truth, removing the previous timing mismatch. + +Promise-based validators are no longer silently accepted on submit. +`validateFields` reports unresolved field names in `pending`; every framework +form helper uses one async-capable validation pass before dispatching submit +callbacks. React, Vue and Angular also expose +`validateAsync()` for explicit pre-submit checks. Live `isValid` remains a +synchronous answer because a property/computed/signal cannot await. + +The new UI-kit recipes show complete touched/error wiring for Ant Design, +Vuetify and Angular Material. diff --git a/docs/ui-kit-recipes.md b/docs/ui-kit-recipes.md new file mode 100644 index 0000000..ee70cfe --- /dev/null +++ b/docs/ui-kit-recipes.md @@ -0,0 +1,183 @@ +# UI kit recipes + +These recipes keep `dynamic-field-kit` in charge of values, touched state and +validation while a UI kit owns presentation. The rule is the same everywhere: +display `error` only when `touched`, forward `id`, and call the supplied value +and blur callbacks. + +## React + Ant Design + +```tsx +import { Form, Input } from 'antd'; +import { + fieldRegistry, + type FieldRendererProps, +} from '@dynamic-field-kit/react'; + +function AntText(props: FieldRendererProps) { + const message = props.touched + ? [props.error].flat().filter(Boolean)[0] + : undefined; + + return ( + + props.onValueChange?.(event.target.value)} + onBlur={props.onBlur} + /> + + ); +} + +fieldRegistry.register('text', AntText); +fieldRegistry.register('email', AntText); +fieldRegistry.register('password', AntText); +``` + +```tsx +const form = useDynamicForm({ fields, initialValues }); + +
+ + +; +``` + +API references: [Ant Design Input](https://ant.design/components/input/) and +[Form](https://ant.design/components/form/). + +## Vue + Vuetify + +```ts +import { defineComponent, h, type PropType } from 'vue'; +import { VTextField } from 'vuetify/components'; +import { fieldRegistry } from '@dynamic-field-kit/vue'; + +const VuetifyText = defineComponent({ + props: { + id: String, + value: String, + label: String, + placeholder: String, + disabled: Boolean, + readOnly: Boolean, + touched: Boolean, + error: [String, Array] as PropType, + onValueChange: Function as PropType<(value: string) => void>, + onBlur: Function as PropType<() => void>, + }, + setup(props) { + return () => + h(VTextField, { + id: props.id, + modelValue: props.value ?? '', + label: props.label, + placeholder: props.placeholder, + disabled: props.disabled, + readonly: props.readOnly, + errorMessages: props.touched ? props.error : undefined, + 'onUpdate:modelValue': props.onValueChange, + onBlur: props.onBlur, + }); + }, +}); + +fieldRegistry.register('text', VuetifyText); +fieldRegistry.register('email', VuetifyText); +fieldRegistry.register('password', VuetifyText); +``` + +```vue + + +
+ + + Save + + +``` + +API reference: [Vuetify text fields](https://vuetifyjs.com/en/components/text-fields/). + +## Angular + Angular Material + +```ts +import { Component } from '@angular/core'; +import { ErrorStateMatcher } from '@angular/material/core'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { MatInputModule } from '@angular/material/input'; +import { BaseInputComponent, fieldRegistry } from '@dynamic-field-kit/angular'; + +@Component({ + selector: 'app-material-text', + standalone: true, + imports: [MatFormFieldModule, MatInputModule], + template: ` + + {{ label }} + + @if (touched && error) { + {{ errorText }} + } + + `, +}) +export class MaterialTextRenderer extends BaseInputComponent { + readonly errorStateMatcher: ErrorStateMatcher = { + isErrorState: () => Boolean(this.touched && this.error), + }; + + get errorText(): string { + return ([] as string[]).concat(this.error ?? [])[0] ?? ''; + } +} + +fieldRegistry.register('text', MaterialTextRenderer as never); +``` + +Bind both metadata maps so the store is the single source of truth: + +```html + +``` + +API references: [Angular Material form field](https://material.angular.dev/components/form-field/overview) +and [input](https://material.angular.dev/components/input/overview). + +## Async validation + +All three form helpers expose `validateAsync()`. Their `handleSubmit()` methods +run one async-capable validation pass before calling `onValid`. Live `isValid` +reflects synchronous rules; call +`validateAsync()` when UI must check an async rule before submit. diff --git a/packages/angular/README.md b/packages/angular/README.md index adf4fac..53598ff 100644 --- a/packages/angular/README.md +++ b/packages/angular/README.md @@ -59,13 +59,15 @@ both packages: renderer contracts every adapter shares - `ValidationResult` -`createDynamicFormStore` validates **synchronously** via `validateFields`, -including on submit. Fields whose `validate` hook returns a Promise are treated -as valid on that path, so run async rules through `validateFieldsAsync` -yourself. See the +`createDynamicFormStore` keeps live validation synchronous. Its `handleSubmit` +runs one async-capable validation pass; call the exposed `validateAsync()` when +you need that result before submit. See the [core README](https://github.com/vannt-dev/dynamic-field-kit/tree/develop/packages/core#sync-vs-async-validation) for the full rules. +For a complete UI integration, see the +[Angular Material recipe](../../docs/ui-kit-recipes.md#angular--angular-material). + ## Basic setup (Angular 19+) 1. Import the component and register fields before bootstrap. @@ -144,6 +146,7 @@ import { [fieldDescriptions]="fields" [properties]="store.data()" [touched]="store.touched()" + [errors]="store.errors()" (onChange)="store.handleChange($event)" (onBlurField)="store.handleBlur($event)" > @@ -168,7 +171,7 @@ export class MyForm { | ----------------------------------- | --------------------------------------------------------------------------------- | | `data()` | Current form data, with `computeValue` fields applied | | `errors()` | `Record`, keyed like `validateFields` | -| `isValid()` / `isDirty()` | No errors recorded / any value has changed | +| `isValid()` / `isDirty()` | Current synchronous validity / any value has changed | | `isSubmitting()` / `isSubmitted()` | In-flight submit / at least one submit attempted | | `touched()` | Fields that have been blurred | | `handleChange(data)` | Replace the whole form data — bind to `(onChange)` | @@ -178,6 +181,7 @@ export class MyForm { | `touchAll()` | Mark every field touched — `handleSubmit` already calls it | | `resetTouched()` | Clear touched only, leaving data/errors/dirty alone | | `validate()` | Validate now, returns a boolean | +| `validateAsync()` | Validate now, awaiting Promise-based rules | | `reset(values?)` | Back to `initialValues` (or the values given), clearing errors/touched/submission | | `handleSubmit(onValid, onInvalid?)` | Returns an async handler; calls `preventDefault`, validates, then dispatches | @@ -185,8 +189,9 @@ export class MyForm { `focusout` listener — so it works with any renderer, without the renderer needing a blur output of its own. -Binding `[touched]="store.touched()"` makes the store the single source of -truth, and is what makes an invalid submit visible: `handleSubmit` marks every +Binding `[touched]="store.touched()"` and `[errors]="store.errors()"` makes the +store the single source of truth for renderer metadata. Touched state is what +makes an invalid submit visible: `handleSubmit` marks every field touched before validating, so a renderer that gates its error on the `touched` input shows it even for fields the user never focused. `reset()` clears touched the same way. Leave `[touched]` unbound and `MultiFieldInput` diff --git a/packages/angular/src/components/FieldInput.ts b/packages/angular/src/components/FieldInput.ts index 957573a..eb543ec 100644 --- a/packages/angular/src/components/FieldInput.ts +++ b/packages/angular/src/components/FieldInput.ts @@ -73,6 +73,8 @@ export class FieldInput implements OnChanges { @Input() disabled?: boolean; @Input() readOnly?: boolean; @Input() error?: string | string[]; + /** Distinguishes a controlled empty error from an omitted error input. */ + @Input() validationControlled = false; /** Whether the field has been blurred, or marked touched by a form store. */ @Input() touched?: boolean; /** Whether the value differs from the one the form opened with. */ @@ -122,7 +124,9 @@ export class FieldInput implements OnChanges { // Explicitly bound inputs override what the field description resolves to, // so a host can still drive options/disabled/error itself. - const error = this.error ?? base.error; + const error = this.validationControlled + ? this.error + : (this.error ?? base.error); return { ...base, options: this.options ?? base.options, diff --git a/packages/angular/src/components/MultiFieldInput.ts b/packages/angular/src/components/MultiFieldInput.ts index 5395c56..ecb6c9e 100644 --- a/packages/angular/src/components/MultiFieldInput.ts +++ b/packages/angular/src/components/MultiFieldInput.ts @@ -67,6 +67,8 @@ function nextInstanceId(): number { [idPrefix]="effectiveIdPrefix" [touched]="isTouched(field.name)" [dirty]="isFieldDirty(field.name)" + [error]="fieldErrors(field.name)" + [validationControlled]="errors !== undefined" (onValueChangeField)="onFieldChange($event)" (onBlurField)="handleBlurField($event)" > @@ -86,6 +88,7 @@ function nextInstanceId(): number { [fieldDescriptions]="field.fields" [properties]="item" [rootData]="rootData ?? data" + [errors]="errorsForItem(field.name, i)" (onChange)="onGroupItemChange(field, i, $event)" > @@ -139,6 +142,8 @@ export class MultiFieldInput implements OnInit, OnChanges { * internal, blur-only tracker. */ @Input() touched?: Record; + /** Controlled error map; bind the form store's `errors()` signal here. */ + @Input() errors?: Record; /** Emits the next touched map whenever a field is blurred. */ @Output() touchedChange = new EventEmitter>(); /** @@ -185,6 +190,25 @@ export class MultiFieldInput implements OnInit, OnChanges { return this.data[fieldName] !== this.initialProperties[fieldName]; } + fieldErrors(fieldName: string): string[] | undefined { + return this.errors?.[fieldName]; + } + + errorsForItem( + fieldName: string, + index: number, + ): Record | undefined { + if (this.errors === undefined) { + return undefined; + } + const prefix = `${fieldName}[${index}].`; + return Object.fromEntries( + Object.entries(this.errors) + .filter(([key]) => key.startsWith(prefix)) + .map(([key, messages]) => [key.slice(prefix.length), messages]), + ); + } + /** * Clears the internally tracked touched state. Only meaningful in * uncontrolled mode - when `touched` is bound, resetting the form store diff --git a/packages/angular/src/lib/dynamic-form.store.ts b/packages/angular/src/lib/dynamic-form.store.ts index f739233..a991b0c 100644 --- a/packages/angular/src/lib/dynamic-form.store.ts +++ b/packages/angular/src/lib/dynamic-form.store.ts @@ -4,6 +4,7 @@ import { FieldDescription, Properties, validateFields, + validateFieldsAsync, } from '@dynamic-field-kit/core'; export interface DynamicFormOptions { @@ -26,7 +27,9 @@ export function createDynamicFormStore(options: DynamicFormOptions) { const isSubmitting = signal(false); const isSubmitted = signal(false); - const isValid = computed(() => Object.keys(errors()).length === 0); + // Errors remain lazy for display, while validity always reflects current + // data. Promise-based rules are provisional until validateAsync/submit. + const isValid = computed(() => validateFields(fields, data()).valid); function validate(): boolean { const res = validateFields(fields, data()); @@ -34,6 +37,12 @@ export function createDynamicFormStore(options: DynamicFormOptions) { return res.valid; } + async function validateAsync(): Promise { + const res = await validateFieldsAsync(fields, data()); + errors.set(res.errors); + return res.valid; + } + function handleChange(newData: Properties) { const next = applyComputedValues(fields, newData); data.set(next); @@ -106,7 +115,7 @@ export function createDynamicFormStore(options: DynamicFormOptions) { // show its error. Without this, submitting an untouched form appears // to do nothing at all. touchAll(); - const res = validateFields(fields, data()); + const res = await validateFieldsAsync(fields, data()); errors.set(res.errors); isSubmitted.set(true); if (res.valid) { @@ -136,6 +145,7 @@ export function createDynamicFormStore(options: DynamicFormOptions) { handleBlur, reset, validate, + validateAsync, handleSubmit, }; } diff --git a/packages/angular/test/dynamicFormStore.spec.ts b/packages/angular/test/dynamicFormStore.spec.ts index 196bfae..1a943bc 100644 --- a/packages/angular/test/dynamicFormStore.spec.ts +++ b/packages/angular/test/dynamicFormStore.spec.ts @@ -12,6 +12,16 @@ const fields: FieldDescription[] = [ ]; describe('Angular Signal DynamicFormStore', () => { + it('reports live validity before errors have been populated', () => { + const store = createDynamicFormStore({ fields }); + + expect(store.isValid()).toBe(false); + expect(store.errors()).toEqual({}); + + store.setFieldValue('username', 'ada'); + expect(store.isValid()).toBe(true); + }); + it('initializes signal data and handles changes', () => { const store = createDynamicFormStore({ fields, @@ -130,6 +140,32 @@ describe('Angular Signal DynamicFormStore', () => { expect(store.errors().username).toEqual(['Username is required']); }); + it('awaits async validation explicitly and during submit', async () => { + const asyncFields: FieldDescription[] = [ + { + name: 'username', + type: 'text', + validate: async (value) => + value === 'taken' ? 'Already taken' : undefined, + }, + ]; + const store = createDynamicFormStore({ + fields: asyncFields, + initialValues: { username: 'taken' }, + }); + const onValid = vi.fn(); + const onInvalid = vi.fn(); + + await expect(store.validateAsync()).resolves.toBe(false); + expect(store.errors()).toEqual({ username: ['Already taken'] }); + + await store.handleSubmit(onValid, onInvalid)(); + expect(onValid).not.toHaveBeenCalled(); + expect(onInvalid).toHaveBeenCalledWith({ + username: ['Already taken'], + }); + }); + it('resets to explicitly supplied values', () => { const store = createDynamicFormStore({ fields, diff --git a/packages/angular/test/reportedIssues.spec.ts b/packages/angular/test/reportedIssues.spec.ts index d0f832d..3d96a35 100644 --- a/packages/angular/test/reportedIssues.spec.ts +++ b/packages/angular/test/reportedIssues.spec.ts @@ -142,6 +142,7 @@ describe('reported issues (Angular)', () => { const fixture = mount({ properties: store.data(), touched: store.touched(), + errors: store.errors(), }); expect(errorText(fixture)).toBeNull(); @@ -150,10 +151,18 @@ describe('reported issues (Angular)', () => { fixture.componentRef.setInput('touched', store.touched()); fixture.detectChanges(); + // Controlled errors remain lazy until the store validates. + expect(errorText(fixture)).toBeNull(); + + store.validate(); + fixture.componentRef.setInput('errors', store.errors()); + fixture.detectChanges(); + expect(errorText(fixture)).toBe('Required'); store.reset(); fixture.componentRef.setInput('touched', store.touched()); + fixture.componentRef.setInput('errors', store.errors()); fixture.detectChanges(); expect(errorText(fixture)).toBeNull(); diff --git a/packages/core/README.md b/packages/core/README.md index 79a98a8..f63a9f0 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -317,11 +317,10 @@ reactively. ### Sync vs async validation -`validateField` and `validateFields` are synchronous, and that has a consequence -worth knowing: **when a `validate` hook returns a Promise, the sync path treats -the field as valid.** It cannot await, so it discards the pending result rather -than blocking. Any field whose `validate` is `async` — or whose schema has async -refinements — must go through the async pair: +`validateField` and `validateFields` are synchronous. When a `validate` hook +returns a Promise, `validateFields` lists the field in `result.pending`; its +`valid` flag then means only that no synchronous rule failed. Async validators +must go through the async pair for a final answer: ```ts import { @@ -335,14 +334,14 @@ import { const errors = validateField(field, value, data, rootData); // string[] const errorsAsync = await validateFieldAsync(field, value, data, rootData); -// A whole schema. Both return ValidationResult -> { valid, errors }. +// A whole schema. The sync result may also include `pending` field names. const result = validateFields(fields, data); // sync hooks only const resultAsync = await validateFieldsAsync(fields, data); // awaits each hook ``` -The framework form hooks (`useDynamicForm`, `createDynamicFormStore`) validate -synchronously, so wire async rules up through `validateFieldsAsync` yourself — -for example on submit — rather than expecting them to surface on change. +The framework form helpers keep live validation synchronous, but their submit +handlers automatically run an async-capable validation pass. They also +expose `validateAsync()` for checks that must finish before submit. `resolveOptions(field, data, rootData?)` returns `Properties[] | undefined`, calling `field.options` when it is a callback and passing it through when it is a diff --git a/packages/core/src/rendererProps.ts b/packages/core/src/rendererProps.ts index 160f896..9fb0db4 100644 --- a/packages/core/src/rendererProps.ts +++ b/packages/core/src/rendererProps.ts @@ -63,6 +63,11 @@ export interface BuildFieldRendererPropsInput { id: string; touched?: boolean; dirty?: boolean; + /** + * Errors supplied by an owning form store. An empty array explicitly means + * valid; `undefined` keeps the legacy live-validation behaviour. + */ + validationErrors?: string[]; } /** @@ -91,6 +96,7 @@ export function buildFieldRendererProps({ id, touched, dirty, + validationErrors, }: BuildFieldRendererPropsInput): ResolvedFieldRendererProps { const { name, @@ -116,7 +122,8 @@ export function buildFieldRendererProps({ // the user cannot act on. const errors = disabled ? [] - : validateField(fieldDescription, data[name], data, rootData); + : (validationErrors ?? + validateField(fieldDescription, data[name], data, rootData)); const error = errors.length > 0 ? errors : undefined; return { diff --git a/packages/core/src/validation.ts b/packages/core/src/validation.ts index 179c2a3..7adf987 100644 --- a/packages/core/src/validation.ts +++ b/packages/core/src/validation.ts @@ -4,6 +4,54 @@ import type { FieldDescription, Properties } from './types'; export interface ValidationResult { valid: boolean; errors: Record; + /** + * Fields whose `validate` hook returned a Promise, keyed exactly like + * `errors` (so a grouped field reads `contacts[0].username`). The sync pass + * cannot await, so it neither found nor ruled out an error for these - a + * `valid: true` alongside a non-empty `pending` means "nothing wrong that + * could be checked synchronously", not "valid". + * + * Left undefined when every validator resolved synchronously, which is the + * common case, and always undefined from `validateFieldsAsync` - it awaits + * everything, so nothing is left pending. + */ + pending?: string[]; +} + +function isDev(): boolean { + return ( + typeof process !== 'undefined' && + !!process.env && + process.env.NODE_ENV !== 'production' + ); +} + +// Warn at most once per field name. `validateFields` runs on every keystroke +// through MultiFieldInput's validity reporting, and a warning per keystroke +// would be worse than the silence it replaces. +const warnedAsyncFields = new Set(); + +function warnAsyncValidator(key: string): void { + if (!isDev() || warnedAsyncFields.has(key)) { + return; + } + warnedAsyncFields.add(key); + console.warn( + `[dynamic-field-kit] the validate hook for "${key}" returned a Promise. ` + + `Submitting awaits it (handleSubmit uses validateFieldsAsync), but the ` + + `live passes cannot: this field contributes nothing ` + + `to the inline error shown while typing, nor to isValid/errors, so it ` + + `reads as valid there until submit. Await validateFieldsAsync yourself ` + + `if you need it sooner.`, + ); +} + +function isPromiseLike(value: unknown): value is PromiseLike { + return ( + (typeof value === 'object' || typeof value === 'function') && + value !== null && + typeof (value as PromiseLike).then === 'function' + ); } /** Effective disabled state: the static flag OR the dynamic condition. */ @@ -42,21 +90,48 @@ export function resolveOptions( return field.options; } -/** Run one field's validate hook; always returns an array (empty when valid). Synchronous. */ -export function validateField( +/** + * The sync validate pass, plus whether the hook returned a Promise this pass + * could not await. `validateField` hides that second half for callers that only + * want messages; `validateFields` needs it to fill in `ValidationResult.pending`. + */ +function runSyncValidate( field: FieldDescription, value: unknown, data: Properties, - rootData?: Properties, -): string[] { + rootData: Properties | undefined, + /** Key to report this field under - a grouped field is not just `name`. */ + reportKey = field.name, +): { errors: string[]; isPending: boolean } { if (!field.validate) { - return []; + return { errors: [], isPending: false }; } const result = field.validate(value, data, rootData); - if (!result || result instanceof Promise) { - return []; + if (isPromiseLike(result)) { + // A rejected async result has no observer on the synchronous path. Attach + // one so live validation does not create an unhandled rejection; callers + // that need the result run the validator again through the async API. + void Promise.resolve(result).catch(() => undefined); + warnAsyncValidator(reportKey); + return { errors: [], isPending: true }; } - return Array.isArray(result) ? result : [result]; + if (!result) { + return { errors: [], isPending: false }; + } + return { + errors: Array.isArray(result) ? result : [result], + isPending: false, + }; +} + +/** Run one field's validate hook; always returns an array (empty when valid). Synchronous. */ +export function validateField( + field: FieldDescription, + value: unknown, + data: Properties, + rootData?: Properties, +): string[] { + return runSyncValidate(field, value, data, rootData).errors; } /** Run one field's validate hook asynchronously; always returns a Promise resolving to string[]. */ @@ -88,6 +163,7 @@ export function validateFields( rootData: Properties = data, ): ValidationResult { const errors: Record = {}; + const pending: string[] = []; for (const field of fields) { if (field.appearCondition && !field.appearCondition(data, rootData)) { @@ -106,17 +182,34 @@ export function validateFields( for (const [key, messages] of Object.entries(sub.errors)) { errors[`${field.name}[${index}].${key}`] = messages; } + for (const key of sub.pending ?? []) { + pending.push(`${field.name}[${index}].${key}`); + } }); continue; } - const fieldErrors = validateField(field, data[field.name], data, rootData); + const { errors: fieldErrors, isPending } = runSyncValidate( + field, + data[field.name], + data, + rootData, + ); if (fieldErrors.length > 0) { errors[field.name] = fieldErrors; } + if (isPending) { + pending.push(field.name); + } } - return { valid: Object.keys(errors).length === 0, errors }; + return { + valid: Object.keys(errors).length === 0, + errors, + // Omitted rather than empty so the common all-sync case keeps the shape it + // has always had, and `pending` reads as "something needs awaiting". + ...(pending.length > 0 ? { pending } : {}), + }; } /** diff --git a/packages/core/test/asyncValidatorReporting.test.ts b/packages/core/test/asyncValidatorReporting.test.ts new file mode 100644 index 0000000..c55fca3 --- /dev/null +++ b/packages/core/test/asyncValidatorReporting.test.ts @@ -0,0 +1,148 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { FieldDescription } from '../src/types'; +import { validateFields, validateFieldsAsync } from '../src/validation'; + +const sync: FieldDescription = { + name: 'name', + type: 'text', + validate: (v) => (v ? undefined : 'Required'), +}; + +const asyncField: FieldDescription = { + name: 'username', + type: 'text', + validate: async (v) => (v === 'taken' ? 'Already taken' : undefined), +}; + +// The dev warning is memoised per field name for the life of the module, so +// every test below that cares about it uses a field name of its own rather +// than resetting shared state. This keeps the rest of the suite quiet. +beforeEach(() => { + vi.spyOn(console, 'warn').mockImplementation(() => {}); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe('validateFields reports fields it could not resolve', () => { + it('leaves `pending` off entirely when every validator is sync', () => { + const res = validateFields([sync], { name: '' }); + + expect(res.valid).toBe(false); + expect(res.pending).toBeUndefined(); + }); + + it('names the field whose validator returned a Promise', () => { + const res = validateFields([asyncField], { username: 'taken' }); + + expect(res.pending).toEqual(['username']); + }); + + it('does not claim valid on the strength of an unresolved field alone', () => { + // The sync pass genuinely found no errors, so `valid` stays true - but + // `pending` is what tells a caller that answer is provisional. + const res = validateFields([asyncField], { username: 'taken' }); + + expect(res.valid).toBe(true); + expect(res.pending).toHaveLength(1); + }); + + it('keys a pending group field the way it keys a group error', () => { + const group: FieldDescription = { + name: 'contacts', + type: 'text', + fields: [asyncField], + }; + + const res = validateFields([group], { + contacts: [{ username: 'a' }, { username: 'taken' }], + }); + + expect(res.pending).toEqual([ + 'contacts[0].username', + 'contacts[1].username', + ]); + }); + + it('ignores an unresolved validator on a field that is hidden or disabled', () => { + const hidden: FieldDescription = { + ...asyncField, + appearCondition: () => false, + }; + const disabled: FieldDescription = { ...asyncField, disabled: true }; + + expect(validateFields([hidden], {}).pending).toBeUndefined(); + expect(validateFields([disabled], {}).pending).toBeUndefined(); + }); + + it('reports both real errors and pending fields together', () => { + const res = validateFields([sync, asyncField], { + name: '', + username: 'taken', + }); + + expect(res.errors).toEqual({ name: ['Required'] }); + expect(res.pending).toEqual(['username']); + expect(res.valid).toBe(false); + }); +}); + +describe('development warning for a validator the sync pass cannot check', () => { + it('warns once per field, not once per call', () => { + // A name used by no other test, since the memo is module-scoped. + const once: FieldDescription = { + name: 'warnsOnlyOnce', + type: 'text', + validate: async () => undefined, + }; + const warn = vi.mocked(console.warn); + + validateFields([once], { warnsOnlyOnce: 'a' }); + validateFields([once], { warnsOnlyOnce: 'b' }); + validateFields([once], { warnsOnlyOnce: 'c' }); + + expect(warn).toHaveBeenCalledTimes(1); + expect(warn.mock.calls[0][0]).toContain('warnsOnlyOnce'); + expect(warn.mock.calls[0][0]).toContain('validateFieldsAsync'); + // Says what actually happens now that handleSubmit awaits, so it does not + // read as an alarm to someone who has wired everything correctly. + expect(warn.mock.calls[0][0]).toContain('Submitting awaits it'); + }); + + it('warns once for a field name shared across group items', () => { + const child: FieldDescription = { + name: 'sharedAcrossItems', + type: 'text', + validate: async () => undefined, + }; + const group: FieldDescription = { + name: 'rows', + type: 'text', + fields: [child], + }; + const warn = vi.mocked(console.warn); + + validateFields([group], { rows: [{}, {}, {}] }); + + expect(warn).toHaveBeenCalledTimes(1); + }); + + it('stays quiet for sync validators', () => { + const warn = vi.mocked(console.warn); + + validateFields([sync], { name: '' }); + + expect(warn).not.toHaveBeenCalled(); + }); +}); + +describe('validateFieldsAsync resolves what the sync pass could not', () => { + it('finds the error and reports nothing pending', async () => { + const res = await validateFieldsAsync([asyncField], { username: 'taken' }); + + expect(res.valid).toBe(false); + expect(res.errors).toEqual({ username: ['Already taken'] }); + expect(res.pending).toBeUndefined(); + }); +}); diff --git a/packages/core/test/rendererProps.test.ts b/packages/core/test/rendererProps.test.ts index 115ea52..b505a8b 100644 --- a/packages/core/test/rendererProps.test.ts +++ b/packages/core/test/rendererProps.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; import { buildFieldRendererProps, FIELD_RENDERER_PROP_KEYS, @@ -73,6 +73,19 @@ describe('buildFieldRendererProps', () => { expect(p.dirty).toBe(true); }); + it('uses controlled validation errors without running the field validator', () => { + const validate = vi.fn(() => 'live error'); + const result = buildFieldRendererProps({ + fieldDescription: { name: 'name', type: 'text', validate }, + data: { name: '' }, + id: 'field-name', + validationErrors: [], + }); + + expect(validate).not.toHaveBeenCalled(); + expect(result.error).toBeUndefined(); + }); + it('validates and sets aria flags', () => { const required: FieldDescription = { name: 'a', diff --git a/packages/react/README.md b/packages/react/README.md index 05d0d34..1e11bd8 100644 --- a/packages/react/README.md +++ b/packages/react/README.md @@ -45,12 +45,15 @@ both packages: - `validators` — the built-in validator helpers (`required`, `email`, `minLength`, `compose`, …) - `ValidationResult` -`useDynamicForm` validates **synchronously** via `validateFields`, including on -submit. Fields whose `validate` hook returns a Promise are treated as valid on -that path, so run async rules through `validateFieldsAsync` yourself. See the +`useDynamicForm` keeps live validation synchronous. Its `handleSubmit` runs one +async-capable validation pass; call the +exposed `validateAsync()` when you need that result before submit. See the [core README](https://github.com/vannt-dev/dynamic-field-kit/tree/develop/packages/core#sync-vs-async-validation) for the full rules. +For a complete UI integration, see the +[Ant Design recipe](../../docs/ui-kit-recipes.md#react--ant-design). + `FieldGroupInput` (repeatable field groups) is used internally by `FieldInput` and doesn't need to be imported directly - see "Repeatable field groups" below. Default layouts are registered automatically when you import the package root. @@ -139,7 +142,7 @@ const form = useDynamicForm({ ; ``` -`form` is shorthand for four props at once, and is the recommended wiring: +`form` is shorthand for five state/callback props, and is the recommended wiring: ```tsx ``` -Passing `touched` is what makes an invalid submit visible: `handleSubmit` +Passing `touched` and `errors` gives the form store ownership of renderer +metadata. `touched` is what makes an invalid submit visible: `handleSubmit` marks every field touched before validating, so a renderer that gates its error on `touched` shows it even for fields the user never focused. `reset()` clears touched the same way. Individually passed props win over the ones `form` @@ -161,7 +166,7 @@ derives, so you can pass `form` and still override one wire. | ----------------------------------- | --------------------------------------------------------------------------------- | | `data` | Current form data, with `computeValue` fields applied | | `errors` | `Record`, keyed like `validateFields` | -| `isValid` / `isDirty` | No errors recorded / any value has changed | +| `isValid` / `isDirty` | Current synchronous validity / any value has changed | | `isSubmitting` / `isSubmitted` | In-flight submit / at least one submit attempted | | `touched` | Fields that have been blurred | | `handleChange(data)` | Replace the whole form data — pass to `MultiFieldInput`'s `onChange` | @@ -173,6 +178,7 @@ derives, so you can pass `form` and still override one wire. | `setTouched` | Raw setter for the whole touched map | | `setData` | Raw state setter, for escape hatches | | `validate()` | Validate now, returns a boolean | +| `validateAsync()` | Validate now, awaiting Promise-based rules | | `reset(values?)` | Back to `initialValues` (or the values given), clearing errors/touched/submission | | `handleSubmit(onValid, onInvalid?)` | Returns a submit handler; calls `preventDefault`, validates, then dispatches | diff --git a/packages/react/src/components/FieldGroupInput.tsx b/packages/react/src/components/FieldGroupInput.tsx index aa2dc58..fce00c3 100644 --- a/packages/react/src/components/FieldGroupInput.tsx +++ b/packages/react/src/components/FieldGroupInput.tsx @@ -12,6 +12,7 @@ interface Props { fieldDescription: FieldDescription; items: Properties[]; rootData?: Properties; + errors?: Record; onChange: (items: Properties[]) => void; } @@ -19,6 +20,7 @@ const FieldGroupInput = ({ fieldDescription, items, rootData, + errors, onChange, }: Props) => { const { @@ -36,6 +38,18 @@ const FieldGroupInput = ({ const removeText = removeLabel ?? 'Remove'; const groupName = label ?? fieldDescription.name; + const errorsForItem = (index: number) => { + if (errors === undefined) { + return undefined; + } + const prefix = `${fieldDescription.name}[${index}].`; + return Object.fromEntries( + Object.entries(errors) + .filter(([key]) => key.startsWith(prefix)) + .map(([key, messages]) => [key.slice(prefix.length), messages]), + ); + }; + const handleItemChange = useCallback( (index: number, next: Properties) => { const nextItems = items.slice(); @@ -75,6 +89,7 @@ const FieldGroupInput = ({ fieldDescriptions={fields} properties={item} rootData={rootData} + errors={errorsForItem(index)} onChange={(next) => handleItemChange(index, next)} /> diff --git a/packages/react/src/components/FieldInput.tsx b/packages/react/src/components/FieldInput.tsx index 3e9905e..afc8c49 100644 --- a/packages/react/src/components/FieldInput.tsx +++ b/packages/react/src/components/FieldInput.tsx @@ -16,6 +16,7 @@ interface Props { idPrefix?: string; touched?: boolean; dirty?: boolean; + errors?: Record; onBlurField?: (key: string) => void; onValueChangeField: (value: unknown, key: string) => void; } @@ -27,6 +28,7 @@ const FieldInputInner = ({ idPrefix = 'dfk-field', touched, dirty, + errors, onBlurField, onValueChangeField, }: Props) => { @@ -54,6 +56,7 @@ const FieldInputInner = ({ fieldDescription={fieldDescription} items={items} rootData={rootData} + errors={errors} onChange={handleChange} /> ); @@ -66,6 +69,7 @@ const FieldInputInner = ({ id: makeFieldId(fieldDescription, idPrefix), touched, dirty, + validationErrors: errors === undefined ? undefined : (errors[name] ?? []), }); return ( @@ -91,6 +95,7 @@ const FieldInput = /* @__PURE__ */ React.memo(FieldInputInner, (prev, next) => { prev.idPrefix === next.idPrefix && prev.touched === next.touched && prev.dirty === next.dirty && + prev.errors === next.errors && prev.renderInfos[name] === next.renderInfos[name] ); }); diff --git a/packages/react/src/components/MultiFieldInput.tsx b/packages/react/src/components/MultiFieldInput.tsx index bc55feb..213b3b6 100644 --- a/packages/react/src/components/MultiFieldInput.tsx +++ b/packages/react/src/components/MultiFieldInput.tsx @@ -24,6 +24,7 @@ import FieldInput from './FieldInput'; */ export interface DynamicFormBinding { data: Properties; + errors: Record; touched: Record; handleChange: (data: Properties) => void; handleBlur: (fieldName: string) => void; @@ -82,6 +83,8 @@ interface Props { * blur-only tracker. */ touched?: Record; + /** Controlled validation errors. Empty means no renderer error. */ + errors?: Record; /** Fires with the next touched map whenever a field is blurred. */ onTouchedChange?: (touched: Record) => void; /** @@ -113,6 +116,7 @@ const MultiFieldInputInner = ( onValidityChange, onBlurField, touched: touchedProp, + errors: errorsProp, onTouchedChange, form, }: Props, @@ -124,6 +128,7 @@ const MultiFieldInputInner = ( const effectiveOnChange = onChange ?? form?.handleChange; const effectiveOnBlurField = onBlurField ?? form?.handleBlur; const controlledTouched = touchedProp ?? form?.touched; + const effectiveErrors = errorsProp ?? form?.errors; const [data, setData] = useState({}); const [internalTouched, setInternalTouched] = useState< @@ -261,6 +266,7 @@ const MultiFieldInputInner = ( rootData={effectiveRoot} idPrefix={effectiveIdPrefix} touched={Boolean(effectiveTouched[f.name])} + errors={effectiveErrors} dirty={data[f.name] !== initialPropertiesRef.current[f.name]} onBlurField={handleBlurField} onValueChangeField={handleValueChangeField} diff --git a/packages/react/src/useDynamicForm.ts b/packages/react/src/useDynamicForm.ts index 5a89aaa..d15c23d 100644 --- a/packages/react/src/useDynamicForm.ts +++ b/packages/react/src/useDynamicForm.ts @@ -3,8 +3,9 @@ import { FieldDescription, Properties, validateFields, + validateFieldsAsync, } from '@dynamic-field-kit/core'; -import React, { useCallback, useState } from 'react'; +import React, { useCallback, useMemo, useState } from 'react'; export interface UseDynamicFormOptions { fields: FieldDescription[]; @@ -38,6 +39,8 @@ export interface UseDynamicFormResult { handleBlur: (fieldName: string) => void; reset: (newValues?: Properties) => void; validate: () => boolean; + /** Validate all fields and await Promise-based rules. */ + validateAsync: () => Promise; handleSubmit: ( onValid: (data: Properties) => void | Promise, onInvalid?: (errors: Record) => void, @@ -65,6 +68,12 @@ export function useDynamicForm({ return res.valid; }, [fields, data]); + const validateAsync = useCallback(async () => { + const res = await validateFieldsAsync(fields, data); + setErrors(res.errors); + return res.valid; + }, [fields, data]); + const handleChange = useCallback( (newData: Properties) => { const next = applyComputedValues(fields, newData); @@ -137,7 +146,10 @@ export function useDynamicForm({ // show its error. Without this, submitting an untouched form appears // to do nothing at all. touchAll(); - const res = validateFields(fields, data); + // A submit handler is already async, so use one async-capable pass. + // Sync hooks still run once; Promise-based hooks are awaited instead + // of being invoked once for detection and a second time for results. + const res = await validateFieldsAsync(fields, data); setErrors(res.errors); setIsSubmitted(true); if (res.valid) { @@ -152,7 +164,16 @@ export function useDynamicForm({ [fields, data, touchAll], ); - const isValid = Object.keys(errors).length === 0; + // Derived from the current data, not from `errors`. `errors` is deliberately + // lazy - it fills in on validate/blur/submit so a pristine form does not show + // messages - but deriving `isValid` from it meant an empty required field + // reported `isValid: true` until one of those happened, which is precisely + // when a submit button wants to be disabled. Async validators still read as + // valid here; only submitting (or `validateAsync`) can await them. + const isValid = useMemo( + () => validateFields(fields, data).valid, + [fields, data], + ); return { data, @@ -172,6 +193,7 @@ export function useDynamicForm({ handleBlur, reset, validate, + validateAsync, handleSubmit, }; } diff --git a/packages/react/test/reportedIssues.test.tsx b/packages/react/test/reportedIssues.test.tsx index 2020a56..906c833 100644 --- a/packages/react/test/reportedIssues.test.tsx +++ b/packages/react/test/reportedIssues.test.tsx @@ -133,14 +133,14 @@ describe('issue 2: submitting an untouched form surfaces errors', () => { ); } - it('shows the error without the user ever focusing the field', () => { + it('shows the error without the user ever focusing the field', async () => { render(
); expect(screen.queryByRole('alert')).toBeNull(); fireEvent.click(screen.getByText('Submit')); - expect(screen.getByRole('alert')).toHaveTextContent('Required'); + expect(await screen.findByRole('alert')).toHaveTextContent('Required'); }); it('touchAll marks every field touched', () => { @@ -207,6 +207,39 @@ describe('issue 3: resetting clears touched', () => { }); }); +describe('issue 5: form errors are the renderer source of truth', () => { + const fields: FieldDescription[] = [ + { + name: 'username', + type: 'text', + label: 'Username', + validate: () => 'Required', + }, + ]; + + it('does not show a live error before the form store records it', () => { + function Form() { + const form = useDynamicForm({ fields }); + return ( + <> + + + + + ); + } + render(); + + fireEvent.click(screen.getByText('touch')); + expect(screen.queryByRole('alert')).toBeNull(); + + fireEvent.click(screen.getByText('validate')); + expect(screen.getByRole('alert')).toHaveTextContent('Required'); + }); +}); + describe('issue 4: FieldDescription props reach the renderer', () => { it('forwards placeholder declared at the top level', () => { render( diff --git a/packages/react/test/useDynamicFormAsync.test.tsx b/packages/react/test/useDynamicFormAsync.test.tsx new file mode 100644 index 0000000..74d63a4 --- /dev/null +++ b/packages/react/test/useDynamicFormAsync.test.tsx @@ -0,0 +1,184 @@ +/** + * The two DX gaps the 1.5.1 report raised alongside the bugs: + * - `isValid` was derived from the lazily-populated `errors` state, so a form + * with an empty required field reported `isValid: true` until something + * happened to validate it. Anyone using it to disable a submit button got + * the wrong answer exactly when it mattered - at first paint. + * - a `validate` hook returning a Promise was treated as valid on the sync + * submit path, so an async rule could not block a submit at all. + */ +import type { FieldDescription } from '@dynamic-field-kit/core'; +import { act, renderHook, waitFor } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { useDynamicForm } from '../src/useDynamicForm'; + +const required: FieldDescription[] = [ + { + name: 'username', + type: 'text', + required: true, + validate: (v) => (v ? undefined : 'Required'), + }, +]; + +beforeEach(() => { + vi.spyOn(console, 'warn').mockImplementation(() => {}); +}); + +describe('isValid reflects the data, not the last validation run', () => { + it('is false at first render for an empty required field', () => { + const { result } = renderHook(() => + useDynamicForm({ fields: required, initialValues: { username: '' } }), + ); + + expect(result.current.isValid).toBe(false); + }); + + it('flips to true as soon as the data is valid, with no validate() call', () => { + const { result } = renderHook(() => + useDynamicForm({ fields: required, initialValues: { username: '' } }), + ); + + act(() => result.current.setFieldValue('username', 'ada')); + + expect(result.current.isValid).toBe(true); + }); + + it('leaves `errors` lazy - it still only fills in on validate/blur/submit', () => { + const { result } = renderHook(() => + useDynamicForm({ fields: required, initialValues: { username: '' } }), + ); + + expect(result.current.isValid).toBe(false); + expect(result.current.errors).toEqual({}); + + act(() => { + result.current.validate(); + }); + + expect(result.current.errors).toEqual({ username: ['Required'] }); + }); + + it('ignores a field that is hidden or disabled, as validateFields does', () => { + const hidden: FieldDescription[] = [ + { ...required[0], appearCondition: () => false }, + ]; + const { result } = renderHook(() => + useDynamicForm({ fields: hidden, initialValues: { username: '' } }), + ); + + expect(result.current.isValid).toBe(true); + }); +}); + +describe('handleSubmit awaits async validators instead of ignoring them', () => { + const asyncFields: FieldDescription[] = [ + { + name: 'username', + type: 'text', + validate: async (v) => (v === 'taken' ? 'Already taken' : undefined), + }, + ]; + + it('blocks the submit when an async rule fails', async () => { + const onValid = vi.fn(); + const onInvalid = vi.fn(); + const { result } = renderHook(() => + useDynamicForm({ + fields: asyncFields, + initialValues: { username: 'taken' }, + }), + ); + + await act(async () => { + await result.current.handleSubmit(onValid, onInvalid)(); + }); + + expect(onValid).not.toHaveBeenCalled(); + expect(onInvalid).toHaveBeenCalledWith({ username: ['Already taken'] }); + expect(result.current.errors).toEqual({ username: ['Already taken'] }); + }); + + it('lets the submit through when the async rule passes', async () => { + const onValid = vi.fn(); + const { result } = renderHook(() => + useDynamicForm({ + fields: asyncFields, + initialValues: { username: 'free' }, + }), + ); + + await act(async () => { + await result.current.handleSubmit(onValid)(); + }); + + expect(onValid).toHaveBeenCalledWith({ username: 'free' }); + expect(result.current.errors).toEqual({}); + }); + + it('does not run a second pass when every validator is sync', async () => { + const validate = vi.fn((v: unknown) => (v ? undefined : 'Required')); + const syncFields: FieldDescription[] = [ + { name: 'a', type: 'text', validate }, + ]; + const { result } = renderHook(() => + useDynamicForm({ + fields: syncFields, + initialValues: { a: 'x' }, + }), + ); + validate.mockClear(); + + await act(async () => { + await result.current.handleSubmit(() => {})(); + }); + + expect(validate).toHaveBeenCalledTimes(1); + }); + + it('clears isSubmitting once the async pass settles', async () => { + const { result } = renderHook(() => + useDynamicForm({ + fields: asyncFields, + initialValues: { username: 'taken' }, + }), + ); + + await act(async () => { + await result.current.handleSubmit(() => {})(); + }); + + await waitFor(() => expect(result.current.isSubmitting).toBe(false)); + expect(result.current.isSubmitted).toBe(true); + }); +}); + +describe('validateAsync', () => { + it('resolves async rules that validate() cannot', async () => { + const asyncFields: FieldDescription[] = [ + { + name: 'username', + type: 'text', + validate: async () => 'Always fails', + }, + ]; + const { result } = renderHook(() => + useDynamicForm({ fields: asyncFields, initialValues: { username: 'x' } }), + ); + + let sync: boolean | undefined; + act(() => { + sync = result.current.validate(); + }); + expect(sync).toBe(true); + expect(result.current.errors).toEqual({}); + + let asyncValid: boolean | undefined; + await act(async () => { + asyncValid = await result.current.validateAsync(); + }); + + expect(asyncValid).toBe(false); + expect(result.current.errors).toEqual({ username: ['Always fails'] }); + }); +}); diff --git a/packages/vue/README.md b/packages/vue/README.md index a71ac4e..679cf6d 100644 --- a/packages/vue/README.md +++ b/packages/vue/README.md @@ -43,12 +43,15 @@ both packages: - `validators` — the built-in validator helpers (`required`, `email`, `minLength`, `compose`, …) - `ValidationResult` -`useDynamicForm` validates **synchronously** via `validateFields`, including on -submit. Fields whose `validate` hook returns a Promise are treated as valid on -that path, so run async rules through `validateFieldsAsync` yourself. See the +`useDynamicForm` keeps live validation synchronous. Its `handleSubmit` runs one +async-capable validation pass; call the +exposed `validateAsync()` when you need that result before submit. See the [core README](https://github.com/vannt-dev/dynamic-field-kit/tree/develop/packages/core#sync-vs-async-validation) for the full rules. +For a complete UI integration, see the +[Vuetify recipe](../../docs/ui-kit-recipes.md#vue--vuetify). + Default layouts are registered automatically when you import the package root. Built-in layouts: @@ -147,7 +150,7 @@ const onSubmit = form.handleSubmit((data) => save(data)); ``` -`:form` is shorthand for four props at once, and is the recommended wiring: +`:form` is shorthand for five state/callback props, and is the recommended wiring: ```vue save(data)); :on-change="form.handleChange" :on-blur-field="form.handleBlur" :touched="form.touched.value" + :errors="form.errors.value" /> ``` -Passing `touched` is what makes an invalid submit visible: `handleSubmit` marks +Passing `touched` and `errors` makes the form store the renderer's source of +truth. `handleSubmit` marks every field touched before validating, so a renderer that gates its error on `touched` shows it even for fields the user never focused. `reset()` clears touched the same way. Individually passed props win over the ones `form` @@ -169,7 +174,7 @@ derives. | ----------------------------------- | --------------------------------------------------------------------------------- | | `data` | `Ref` of the form data, with `computeValue` fields applied | | `errors` | `Ref>`, keyed like `validateFields` | -| `isValid` / `isDirty` | `computed` / `Ref` | +| `isValid` / `isDirty` | Live synchronous validity (`computed`) / changed state (`Ref`) | | `isSubmitting` / `isSubmitted` | In-flight submit / at least one submit attempted | | `touched` | Fields that have been blurred | | `handleChange(data)` | Replace the whole form data — pass to `MultiFieldInput`'s `onChange` | @@ -179,6 +184,7 @@ derives. | `touchAll()` | Mark every field touched — `handleSubmit` already calls it | | `resetTouched()` | Clear touched only, leaving data/errors/dirty alone | | `validate()` | Validate now, returns a boolean | +| `validateAsync()` | Validate now, awaiting Promise-based rules | | `reset(values?)` | Back to `initialValues` (or the values given), clearing errors/touched/submission | | `handleSubmit(onValid, onInvalid?)` | Returns a submit handler; calls `preventDefault`, validates, then dispatches | diff --git a/packages/vue/src/components/FieldInput.ts b/packages/vue/src/components/FieldInput.ts index 5da4820..389b9fd 100644 --- a/packages/vue/src/components/FieldInput.ts +++ b/packages/vue/src/components/FieldInput.ts @@ -43,6 +43,10 @@ const FieldInput = /* @__PURE__ */ defineComponent({ type: Boolean, default: undefined, }, + errors: { + type: Object as PropType>, + default: undefined, + }, }, setup(props) { return () => { @@ -55,6 +59,8 @@ const FieldInput = /* @__PURE__ */ defineComponent({ id: makeFieldId(props.fieldDescription, props.idPrefix), touched: props.touched, dirty: props.dirty, + validationErrors: + props.errors === undefined ? undefined : (props.errors[name] ?? []), }); return h(DynamicInput, { diff --git a/packages/vue/src/components/MultiFieldInput.ts b/packages/vue/src/components/MultiFieldInput.ts index c4f5425..40335fe 100644 --- a/packages/vue/src/components/MultiFieldInput.ts +++ b/packages/vue/src/components/MultiFieldInput.ts @@ -29,6 +29,7 @@ import FieldInput from './FieldInput'; */ export interface DynamicFormBinding { data: Ref | Properties; + errors: Ref> | Record; touched: Ref> | Record; handleChange: (data: Properties) => void; handleBlur: (fieldName: string) => void; @@ -130,6 +131,12 @@ const MultiFieldInput = /* @__PURE__ */ defineComponent({ type: Object as PropType>, default: undefined, }, + // Controlled error map. When supplied (directly or through `form`), it is + // the renderer's source of truth instead of live per-field validation. + errors: { + type: Object as PropType>, + default: undefined, + }, // Fires with the next touched map whenever a field is blurred. onTouchedChange: { type: Function as PropType<(touched: Record) => void>, @@ -178,6 +185,14 @@ const MultiFieldInput = /* @__PURE__ */ defineComponent({ const effectiveTouched = computed>( () => controlledTouched.value ?? touchedFields, ); + const effectiveErrors = computed | undefined>( + () => + props.errors !== undefined + ? props.errors + : props.form + ? unref(props.form.errors) + : undefined, + ); const emitChange = (next: Properties) => { props.onChange?.(next); if (!props.onChange) { @@ -324,6 +339,18 @@ const MultiFieldInput = /* @__PURE__ */ defineComponent({ ? ((item[field.keyField] as string | number) ?? index) : index; + const errorsForItem = (fieldName: string, index: number) => { + if (effectiveErrors.value === undefined) { + return undefined; + } + const prefix = `${fieldName}[${index}].`; + return Object.fromEntries( + Object.entries(effectiveErrors.value) + .filter(([key]) => key.startsWith(prefix)) + .map(([key, messages]) => [key.slice(prefix.length), messages]), + ); + }; + const renderGroupField = (field: FieldDescription) => { const items = getItems(field); const fields = field.fields ?? []; @@ -346,6 +373,7 @@ const MultiFieldInput = /* @__PURE__ */ defineComponent({ fieldDescriptions: fields, properties: item, rootData: props.rootData ?? data, + errors: errorsForItem(field.name, index), onChange: (next: Properties) => handleGroupItemChange(field, index, next), }), @@ -391,6 +419,7 @@ const MultiFieldInput = /* @__PURE__ */ defineComponent({ rootData: props.rootData ?? data, idPrefix: effectiveIdPrefix.value, touched: Boolean(effectiveTouched.value[f.name]), + errors: effectiveErrors.value, dirty: data[f.name] !== initialProperties[f.name], onValueChangeField: handleValueChange, onBlurField: handleBlurField, diff --git a/packages/vue/src/useDynamicForm.ts b/packages/vue/src/useDynamicForm.ts index 12a4a94..a4e5d47 100644 --- a/packages/vue/src/useDynamicForm.ts +++ b/packages/vue/src/useDynamicForm.ts @@ -3,6 +3,7 @@ import { FieldDescription, Properties, validateFields, + validateFieldsAsync, } from '@dynamic-field-kit/core'; import { computed, ref } from 'vue'; @@ -26,7 +27,9 @@ export function useDynamicForm({ const isSubmitting = ref(false); const isSubmitted = ref(false); - const isValid = computed(() => Object.keys(errors.value).length === 0); + // Errors remain lazy for display, while validity always reflects current + // data. Promise-based rules are provisional until validateAsync/submit. + const isValid = computed(() => validateFields(fields, data.value).valid); function validate() { const res = validateFields(fields, data.value); @@ -34,6 +37,12 @@ export function useDynamicForm({ return res.valid; } + async function validateAsync() { + const res = await validateFieldsAsync(fields, data.value); + errors.value = res.errors; + return res.valid; + } + function handleChange(newData: Properties) { const next = applyComputedValues(fields, newData); data.value = next; @@ -103,7 +112,7 @@ export function useDynamicForm({ // show its error. Without this, submitting an untouched form appears // to do nothing at all. touchAll(); - const res = validateFields(fields, data.value); + const res = await validateFieldsAsync(fields, data.value); errors.value = res.errors; isSubmitted.value = true; if (res.valid) { @@ -133,6 +142,7 @@ export function useDynamicForm({ handleBlur, reset, validate, + validateAsync, handleSubmit, }; } diff --git a/packages/vue/test/reportedIssues.test.ts b/packages/vue/test/reportedIssues.test.ts index d87cd6a..e114752 100644 --- a/packages/vue/test/reportedIssues.test.ts +++ b/packages/vue/test/reportedIssues.test.ts @@ -5,7 +5,7 @@ */ import type { FieldDescription } from '@dynamic-field-kit/core'; import { fieldRegistry } from '@dynamic-field-kit/core'; -import { mount } from '@vue/test-utils'; +import { flushPromises, mount } from '@vue/test-utils'; import { beforeEach, describe, expect, it } from 'vitest'; import { defineComponent, h, PropType } from 'vue'; import MultiFieldInput from '../src/components/MultiFieldInput'; @@ -127,6 +127,7 @@ describe('issue 2/3: touched is controllable and resettable (Vue)', () => { expect(wrapper.find('[role="alert"]').exists()).toBe(false); await wrapper.find('form').trigger('submit'); + await flushPromises(); expect(wrapper.find('[role="alert"]').text()).toBe('Required'); }); @@ -223,6 +224,40 @@ describe('issue 4: FieldDescription props reach the renderer (Vue)', () => { }); }); +describe('issue 5: form errors are the renderer source of truth (Vue)', () => { + it('does not show a live error before the form store records it', async () => { + const fields: FieldDescription[] = [ + { + name: 'username', + type: 'text', + validate: () => 'Required', + }, + ]; + const Form = defineComponent({ + setup() { + const form = useDynamicForm({ fields }); + return () => + h('div', [ + h(MultiFieldInput, { fieldDescriptions: fields, form }), + h( + 'button', + { onClick: () => form.setFieldTouched('username') }, + 'touch', + ), + h('button', { onClick: form.validate }, 'validate'), + ]); + }, + }); + const wrapper = mount(Form); + + await wrapper.findAll('button')[0].trigger('click'); + expect(wrapper.find('[role="alert"]').exists()).toBe(false); + + await wrapper.findAll('button')[1].trigger('click'); + expect(wrapper.find('[role="alert"]').text()).toBe('Required'); + }); +}); + describe('issue 1, worst case: repeatable groups (Vue)', () => { it('gives every group item its own id for the same field name', () => { const wrapper = mount(MultiFieldInput, { diff --git a/packages/vue/test/useDynamicForm.test.ts b/packages/vue/test/useDynamicForm.test.ts index b91fbd5..e4497c3 100644 --- a/packages/vue/test/useDynamicForm.test.ts +++ b/packages/vue/test/useDynamicForm.test.ts @@ -86,6 +86,16 @@ describe('useDynamicForm submission state', () => { }); describe('useDynamicForm behaviour', () => { + it('reports live validity before errors have been populated', () => { + const form = useDynamicForm({ fields }); + + expect(form.isValid.value).toBe(false); + expect(form.errors.value).toEqual({}); + + form.setFieldValue('name', 'Ada'); + expect(form.isValid.value).toBe(true); + }); + it('tracks dirty state and touched fields', () => { const form = useDynamicForm({ fields }); @@ -140,6 +150,32 @@ describe('useDynamicForm behaviour', () => { expect(form.errors.value.name).toEqual(['Name is required']); }); + it('awaits async validation explicitly and during submit', async () => { + const asyncFields: FieldDescription[] = [ + { + name: 'username', + type: 'text', + validate: async (value) => + value === 'taken' ? 'Already taken' : undefined, + }, + ]; + const form = useDynamicForm({ + fields: asyncFields, + initialValues: { username: 'taken' }, + }); + const onValid = vi.fn(); + const onInvalid = vi.fn(); + + await expect(form.validateAsync()).resolves.toBe(false); + expect(form.errors.value).toEqual({ username: ['Already taken'] }); + + await form.handleSubmit(onValid, onInvalid)(); + expect(onValid).not.toHaveBeenCalled(); + expect(onInvalid).toHaveBeenCalledWith({ + username: ['Already taken'], + }); + }); + it('applies computed values to the initial data', () => { const computed: FieldDescription[] = [ { name: 'first', type: 'text' },