Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 61 additions & 0 deletions .changeset/eighty-donkeys-shake.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
---
'@dynamic-field-kit/angular': minor
'@dynamic-field-kit/core': minor
'@dynamic-field-kit/react': minor
'@dynamic-field-kit/vue': minor
---

Make async validation answerable: a status you can act on, runs that cancel
cleanly, and touched state that reaches inside repeatable groups.

`ValidationResult` gains `complete` and `status` (`'valid' | 'invalid' |
'pending'`). Combining `valid` with `pending` was the only way to tell "nothing
is wrong" from "nothing is wrong _yet_", and everyone got it wrong the same
way — a `valid: true` with async rules still in flight reads as a green light.
`status` is the single answer; `complete` says whether every applicable
validator finished. Both are always present on a result the library returns, so
reading them needs no fallback; code that constructs a `ValidationResult` by
hand (a mock, a wrapper typed to return one) has to supply them.

`FieldDescription.validationMode: 'async'` declares a validator that returns a
Promise without the `async` keyword, which detection cannot see. Declaring it
keeps the synchronous pass from invoking the validator at all — and, unlike
detection, it is an explicit opt-in, so the dev warning about a field the live
pass cannot check stays quiet for it.

`validateFieldsAsync` now takes a `ValidationContext` and forwards its
`AbortSignal` to every validator, runs independent validators in parallel
instead of awaiting them one after another, skips validators once the signal is
aborted, and reports an aborted run as `complete: false` / `status: 'pending'`.
A validator that honours the signal the conventional way — rejecting with an
`AbortError` — no longer rejects the caller's `handleSubmit`; an error that is
not an abort still propagates.

Each adapter's form helper exposes `isValidating`, `isValidationComplete` and
`validationStatus`, and applies latest-run-wins: typing cancels an in-flight
live validation so a stale result cannot overwrite a newer one. A submit is not
collateral damage of that — it validates the snapshot the user submitted under
a controller of its own, so editing a field mid-flight no longer leaves the
form with the submit silently dropped, no `onValid`/`onInvalid`, and a button
that just re-enables.

`touchAll()` now expands to the concrete leaf paths that exist in the data
(`contacts[0].email`, not `contacts`) via the new `collectFieldPaths`, skipping
fields validation itself skips — hidden by `appearCondition`, or disabled.
Repeatable group items receive `touched` and report blur with their full path,
so a UI kit that only shows an error once a field is touched now works inside a
group. An item with no touched keys still receives a map rather than
`undefined`, which previously flipped the nested input into tracking touched by
itself and left it stale after the owner cleared the map. The new
`indexGroupPathMap` is what indexes those maps by item, exported so a custom
renderer can do the same without filtering the whole map per item.

React's `isValid` is now seeded from the initial data instead of from an
effect. An effect never runs on the server, so a server-rendered form shipped
`isValid: true` for an empty required field and never corrected it — a submit
button rendered enabled and stayed that way.

`@dynamic-field-kit/angular`'s `types` entry pointed at `dist/index.d.ts`,
which is not where its type declarations are emitted any more; it and the
`exports` block now point at the file that actually ships, so TypeScript
consumers resolve the package's types again.
1 change: 1 addition & 0 deletions .github/workflows/quality-gates.yml
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,7 @@ jobs:
node scripts/verify-framework-deps.js
node scripts/check-cross-framework-imports.js
node scripts/check-renderer-prop-parity.js
node scripts/verify-package-entrypoints.js
node scripts/integration-cross-registry.js

# Packs the built packages and renders them under every React major the
Expand Down
29 changes: 24 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,9 @@ validate: zodValidator(z.string().email(), { target: 'field' });
Adapters parse **synchronously** so their result works with `validateFields` (and
therefore with `useDynamicForm`). A schema containing async refinements or async
`.test()` rules cannot be parsed synchronously — those return a Promise, so
validate through `validateFieldsAsync` instead.
validate through `validateFieldsAsync` instead, and mark the field
`validationMode: 'async'` so the live pass skips it rather than calling it on
every keystroke.

---

Expand Down Expand Up @@ -308,7 +310,8 @@ const fields: FieldDescription[] = [

| Property | Description |
| ----------------- | --------------------------------------------------------------------------------------------------- |
| validate | `(value, data, rootData?) => string \| string[] \| undefined \| Promise<...>`. Falsy means valid. |
| validate | `(value, data, rootData?, context?) => string | string[] | undefined | Promise<...>`. Falsy means valid. `context.signal` aborts when a newer run supersedes this one. |
| validationMode | `'sync' | 'async'`. Declares a validator that returns a Promise without the `async` keyword, so the live pass skips it instead of calling it. |
| validators | Built-in helpers: `required`, `email`, `minLength`, `maxLength`, `min`, `max`, `pattern`, `compose` |
| options | Array of option objects or dynamic callback function `(data, rootData?) => Option[]` |
| disabledCondition | `(data, rootData?) => boolean`. OR-ed with the static `disabled` flag. |
Expand All @@ -324,13 +327,23 @@ group items) call `validateFields` or `validateFieldsAsync`:
```ts
import { validateFields, validateFieldsAsync } from '@dynamic-field-kit/core';

const { valid, errors } = validateFields(fields, data);
const { valid, errors, status } = validateFields(fields, data);
// errors: { "email": ["Required"], "contacts[1].email": ["Invalid"] }
// status: 'valid' | 'invalid' | 'pending'

// Or for async validation:
const asyncResult = await validateFieldsAsync(fields, data);
// Async rules are never run by the sync pass - they come back as 'pending'.
// Await them for a final answer, optionally under an AbortSignal:
const controller = new AbortController();
const asyncResult = await validateFieldsAsync(fields, data, data, {
signal: controller.signal,
});
```

Read `status` rather than `valid` alone: with a remote rule still unanswered,
`valid` is `true` and means only "no synchronous rule failed". The form helpers
surface the same distinction as `isValidating`, `isValidationComplete` and
`validationStatus`, and cancel a superseded run for you.

**Default Built-in HTML5 Renderers (Zero Config)**

All framework adapters (`react`, `vue`, `angular`) ship with **built-in HTML5 fallback renderers**:
Expand Down Expand Up @@ -431,6 +444,12 @@ renderer that gates its error on `touched` shows it even for fields the user
never focused, and `reset()` clears it again. Omit it and `MultiFieldInput`
keeps its own blur-only tracker, which only a ref (`resetTouched()`) can clear.

The map is keyed by full path, so it reaches inside repeatable groups:
`touchAll()` produces `contacts[0].email`, not `contacts`, and a group item
reports its blur under the same key. Fields that are hidden by
`appearCondition` or disabled are left out - validation skips them too, so they
can never carry an error to reveal.

```ts
// Vue — same names, refs instead of plain values
const form = useDynamicForm({ fields });
Expand Down
31 changes: 29 additions & 2 deletions docs/ui-kit-recipes.md
Original file line number Diff line number Diff line change
Expand Up @@ -179,5 +179,32 @@ and [input](https://material.angular.dev/components/input/overview).

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.
reflects synchronous rules only; call `validateAsync()` when UI must check an
async rule before submit.

Declare a Promise-returning validator so the live pass skips it instead of
firing a request per keystroke, and honour the signal it is handed:

```ts
{
name: 'username',
type: 'text',
validationMode: 'async',
validate: (value, _data, _rootData, context) =>
fetch(`/api/available?u=${value}`, { signal: context?.signal })
.then((r) => (r.ok ? undefined : 'Already taken')),
}
```

For the UI, bind the three status members rather than `isValid` alone -
`isValid` is `true` while an async rule is still unanswered:

| Member | Use it for |
| ---------------------- | ----------------------------------------------------------- |
| `isValidating` | A spinner on the field or a busy state on the submit button |
| `validationStatus` | `'valid' | 'invalid' | 'pending'` — what to actually render |
| `isValidationComplete` | Enabling submit only once everything has been answered |

Typing cancels a live run in flight, so a stale result never overwrites a newer
one. A submit is not cancelled by typing: it validates the snapshot it was
given and always calls `onValid` or `onInvalid`.
18 changes: 9 additions & 9 deletions example/angular-app/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,21 +9,21 @@
"prebuild": "node scripts/embed-demo-sources.js"
},
"dependencies": {
"@angular/common": "^19.0.0",
"@angular/core": "^19.0.0",
"@angular/platform-browser": "^19.0.0",
"@angular/platform-browser-dynamic": "^19.0.0",
"@angular/common": "^21.2.0",
"@angular/core": "^21.2.0",
"@angular/platform-browser": "^21.2.0",
"@angular/platform-browser-dynamic": "^21.2.0",
"@dynamic-field-kit/angular": "file:../../packages/angular/dist",
"@dynamic-field-kit/core": "file:../../packages/core",
"zone.js": "^0.15.0"
"zone.js": "~0.16.0"
},
"devDependencies": {
"@angular-devkit/build-angular": "^19.0.0",
"@angular/cli": "^19.0.0",
"@angular/compiler-cli": "^19.0.0",
"@angular-devkit/build-angular": "^21.2.0",
"@angular/cli": "^21.2.0",
"@angular/compiler-cli": "^21.2.0",
"autoprefixer": "^10.4.27",
"postcss": "^8.5.8",
"tailwindcss": "^3.4.19",
"typescript": "~5.5.0"
"typescript": "~5.9.3"
}
}
8 changes: 6 additions & 2 deletions example/angular-app/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,12 @@
"downlevelIteration": true,
"experimentalDecorators": true,
"emitDecoratorMetadata": true,
"module": "es2020",
"moduleResolution": "node",
"module": "ES2022",
// Angular 21 exposes its secondary entry points (@angular/common/http,
// @angular/core/primitives/di) through `exports` only. node10 resolution
// cannot read an exports map, so the CLI build failed to typecheck
// Angular's own .d.ts files. Matches packages/angular/tsconfig.json.
"moduleResolution": "bundler",
"target": "ES2022",
"useDefineForClassFields": false,
"typeRoots": ["node_modules/@types"],
Expand Down
Loading