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
38 changes: 38 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,44 @@ jobs:
env:
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
# The tag alone is not what people look at. Tagging and publishing left
# the Releases page untouched, so v1.5.0 had to be written by hand, and
# v1.5.1 and v1.6.0 were missing from it entirely while being live on
# npm - the tags existed, the Releases did not.
#
# The notes are core's own CHANGELOG section for this version, so there
# is one source of truth and nothing to keep in sync by hand. An existing
# release is left alone, which makes re-running this workflow safe rather
# than a hard failure at the last step.
- name: Create the GitHub Release
if: ${{ !inputs.dry_run }}
run: |
version=$(node -p "require('./packages/core/package.json').version")

if gh release view "v$version" >/dev/null 2>&1; then
echo "Release v$version already exists - leaving it alone."
exit 0
fi

{
echo 'All four packages ship at this version with npm provenance:'
echo
for pkg in core react vue angular; do
echo "- \`@dynamic-field-kit/$pkg@$(node -p "require('./packages/$pkg/package.json').version")\`"
done
echo
awk -v heading="## $version" \
'$0 == heading { found = 1; next } /^## / { if (found) exit } found' \
packages/core/CHANGELOG.md
} > "$RUNNER_TEMP/release-notes.md"

gh release create "v$version" \
--title "v$version" \
--notes-file "$RUNNER_TEMP/release-notes.md" \
--verify-tag \
--latest
env:
GH_TOKEN: ${{ github.token }}

- name: Dry run summary
if: ${{ inputs.dry_run }}
Expand Down
117 changes: 109 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -308,14 +308,14 @@ const fields: FieldDescription[] = [
];
```

| Property | Description |
| ----------------- | --------------------------------------------------------------------------------------------------- |
| 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. |
| readOnlyCondition | `(data, rootData?) => boolean`. |
| Property | Description |
| ----------------- | -------------------------------------------------------------------------------------------------------------- |
| 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`, `matches`, `compose` |
| options | Array of option objects or dynamic callback function `(data, rootData?) => Option[]` |
| disabledCondition | `(data, rootData?) => boolean`. OR-ed with the static `disabled` flag. |
| readOnlyCondition | `(data, rootData?) => boolean`. |

`MultiFieldInput` passes each field's current `error` and effective
`disabled`/`readOnly` to its renderer (via `FieldRendererProps`), and emits an
Expand Down Expand Up @@ -722,3 +722,104 @@ MIT © [vannt-dev](https://github.com/vannt-dev)
## 🤝 Contributing

Contributions welcome! Please see individual package READMEs for setup and development instructions.

### Validation messages

Set the built-in validators' messages once for a whole form instead of passing a
string to every validator on every field:

```ts
const form = useDynamicForm({
fields,
messages: {
required: 'Bắt buộc',
minLength: 'Tối thiểu {min} ký tự',
matches: 'Phải khớp {other}',
},
});
```

A message passed directly to a validator still wins, and any key you omit falls
back to the English default. For code that calls `validateFields` directly and
has no form to hang a catalog on, `setDefaultMessages(catalog)` sets a
process-wide one; a per-form catalog takes precedence over it.

| Key | Params | English default |
| ----------- | --------- | ----------------------- |
| `required` | — | Field is required |
| `email` | — | Invalid email address |
| `minLength` | `{min}` | Minimum length is {min} |
| `maxLength` | `{max}` | Maximum length is {max} |
| `min` | `{min}` | Minimum value is {min} |
| `max` | `{max}` | Maximum value is {max} |
| `pattern` | — | Invalid format |
| `matches` | `{other}` | Must match {other} |

**No locale bundles ship with this library.** Supply your own catalog — the
mechanism is here, the translations are yours.

A placeholder with no matching param is left in the string verbatim rather than
replaced with `undefined`, so a typo shows up as a visible `{unit}` instead of
a mystery.

### Async options

`options` can return a promise. Two shapes are covered, and the difference is
what triggers a reload.

**Dependent options** — the reload is driven by form data:

```ts
{
name: 'city',
type: 'select',
options: async (data, _rootData, ctx) =>
fetch(`/api/cities?country=${data.country}`, { signal: ctx?.signal })
.then((r) => r.json()),
optionsDeps: (data) => [data.country],
debounceMs: 200,
}
```

**Search-remote** — the reload is driven by the renderer's own search box,
which the form data never sees. The renderer calls `onOptionsQuery`:

```ts
{
name: 'assignee',
type: 'userPicker',
options: async (_data, _rootData, ctx) =>
fetch(`/api/users?q=${ctx?.query ?? ''}`, { signal: ctx?.signal })
.then((r) => r.json()),
debounceMs: 300,
}
```

The renderer receives `optionsStatus` (`'idle' | 'loading' | 'ready' | 'error'`),
`optionsError`, and `onOptionsQuery`.

| Field property | Effect |
| -------------- | ------------------------------------------------------------------------------ |
| `optionsDeps` | Values a reload depends on, compared shallowly. Defaults to `[]` — fetch once. |
| `optionsMode` | `'async'` for a loader that returns a promise without the `async` keyword. |
| `debounceMs` | Collapses rapid reloads into one fetch. Applies to async options only. |

Superseded requests are aborted through `ctx.signal`, and a slow response that
lands after a newer one is discarded, so the option list always reflects the
most recent request rather than the last one to arrive.

Native `async` functions are detected automatically. A loader wrapped in a
memoiser, a spy or a transpiler helper is **not** — `constructor.name` is no
longer `'AsyncFunction'`. Declare `optionsMode: 'async'` for those; without it
the promise is dropped and a development warning says so.

Note that a form whose `properties` arrive after mount sees its data change
twice (empty, then loaded), which is two loads without a `debounceMs`. Setting
one collapses them.

The loader is bound to the field description it first saw. Changing a field's
`name` swaps it — every adapter keys each field by name, so that remounts —
but changing only the `options` closure on a same-named field does not. That is
deliberate: a `fields` array built inline in a component body gets a fresh
closure on every render, and rebuilding on closure identity would refetch in a
loop.
193 changes: 193 additions & 0 deletions docs/MIGRATING.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,193 @@
# Migrating

Version-by-version tables of what changed and what to do about it. For the full
list of changes see each package's `CHANGELOG.md`.

## 1.6.x → 1.7.0

Everything here is additive or a bugfix. No API was removed, and no peer range
moved.

### Per-field `dirty` is now correct after a reset, and after a late load

`dirty` was measured against a baseline captured once at mount and never
reassigned. Two cases were wrong:

- after `reset(newValues)` every field compared against the **pre-reset**
values, so fields the reset had just changed read as clean and fields it left
alone read as dirty;
- on React and Vue, when `properties` arrived from a fetch **after** mount (the
normal shape of an edit form) the baseline was `{}`, so every field reported
`dirty: true` forever.

The baseline now comes from whoever owns the values.

| Before | After |
| ---------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------- |
| `<MultiFieldInput properties={data} />` — baseline frozen at mount, `{}` if `data` was not ready yet | unchanged call; the baseline is the first non-`undefined` `properties` |
| no way to re-base the baseline | `<MultiFieldInput initialProperties={original} />` |
| `form={form}` — baseline frozen at mount | unchanged call; the baseline follows `form.reset(newValues)` |

**No change is required.** If you worked around this by remounting the form with
a changing `key`, you can drop the workaround.

**One case the automatic baseline cannot cover.** It falls back to the first
`properties` that is not `undefined`, and `{}` is a real value - a form that
genuinely opens blank is indistinguishable from one still waiting on a fetch.
So `properties={data}` where `data` starts as `{}` locks the baseline to `{}`
and every field reads dirty once the values land. Pass `initialProperties`
explicitly, start from `undefined` rather than `{}`, or drive the form through a
store and use `reset(fetched)`.

Angular is the exception: it never had the late-load bug, because its `init()`
only records a baseline once `properties` is actually set. It did have the reset
bug. Since the Angular adapter has no `form` shorthand, pass the store's
baseline in explicitly:

```html
<dfk-multi-field-input
[fieldDescriptions]="fields"
[properties]="store.data()"
[initialProperties]="store.baselineValues()"
/>
```

### `getDirtyValues()`

For PATCH-style submits that should carry only what the user actually edited.

| Before | After |
| ----------------------------------------------------------------------------------------------------- | --------------------------------------- |
| `const changed = Object.fromEntries(Object.entries(form.data).filter(([k, v]) => v !== original[k]))` | `const changed = form.getDirtyValues()` |

Keys present in the data but absent from the baseline count as dirty. Comparison
is by `Object.is`, so a `NaN` numeric field does not read as permanently dirty.

### `baselineValues`

The values `dirty` is measured against, now exposed on the form store. React
returns a plain object, Vue a `Ref`, Angular a `Signal`.

This is **not** the same as the `initialValues` option: that option never
changes, while the baseline is replaced by `reset(newValues)`. They differ as
soon as a form is reset.

### React only: one fewer validation pass per keystroke

`useDynamicForm` validated the same data twice on every change — once eagerly in
`handleChange`, once again in an effect after commit. It now validates once.

Synchronous validators are pure by contract, so this is invisible unless you
were counting calls in a test or relying on a side effect inside a validator.
Vue and Angular were never affected; their stores run no watch or effect and
already validated exactly once.

### Default renderers now show validation messages

**This is the one visible change in 1.7.0.** If a field uses the built-in
renderers — that is, you registered no renderer for its type — an invalid field
now renders

```html
<div id="<field id>-error" class="dfk-field-error" role="alert">…</div>
```

as a sibling of the control. Previously the default renderers were handed
`error` and dropped it, so the form showed nothing at all.

Custom renderers are **not** affected. The node is emitted only where a default
renderer was used, so nobody who renders their own message gets a second copy.

The node ships with no styling beyond that class hook. To keep the old silence:

```css
.dfk-field-error {
display: none;
}
```

### `ariaDescribedBy` now has a value

It was hard-coded `undefined`. It is now `` `${id}-error` `` when the field has
an error, and `undefined` while it is valid.

If your renderer forwards `aria-describedby`, put the matching id on your
message element. `makeErrorId(id)` is exported from core and re-exported by all
three adapters:

| Before | After |
| ------------------------------------- | ----------------------------------------------------------- |
| `help={message}` | `help={<span id={makeErrorId(id)}>{message}</span>}` |
| `aria-describedby` always `undefined` | bind `props.ariaDescribedBy` directly — no need to clear it |

This matters beyond screen readers: `focusFirstInvalidField` selects
`[aria-invalid="true"]`, so a renderer that never forwards `ariaInvalid` makes
that helper silently do nothing. See
[Forward the aria props](./ui-kit-recipes.md#forward-the-aria-props).

### Dev-mode warning when `props` shadows the contract

1.6.0 moved `placeholder`, `min`, `max`, `step`, `accept` and `multiple` to the
top level of `FieldDescription`. Values left behind in `props` were discarded
silently — no throw, no warning, the value just vanished.

A development-only `console.warn` now names the field and the key, once per
pair. Production builds are unchanged and emit nothing.

### Validation messages can be set per form

Previously the only way to change a built-in validator's message was to pass a
string on every field of every form. The validator baked that string in when the
field description was built, so nothing set later could reach it.

| Before | After |
| ------------------------------------------------------------ | ---------------------------------------------------------------- |
| `validators.required('Bắt buộc')` on every field | `useDynamicForm({ fields, messages: { required: 'Bắt buộc' } })` |
| no way to change messages for a direct `validateFields` call | `setDefaultMessages(catalog)` |

Fully backward compatible: a message passed to a validator still wins over any
catalog, and with no catalog the English defaults are unchanged.

`ValidationContext` — already the fourth argument to `validate`, carrying
`signal` — gains an optional `t`. A hand-written validator can use it to
translate its own messages. Nothing is required of existing validators.

### `validators.matches`

| Before | After |
| --------------------------------------------------------------------------------- | ------------------------------------------ |
| `validate: (value, data) => (value !== data.password ? 'Must match' : undefined)` | `validate: validators.matches('password')` |

Skips empty values so `required` owns that case rather than both firing at once,
and compares with `Object.is` so two `NaN`s match.

### `debounceMs` finally does something

Before 1.7.0 `debounceMs` was declared on `FieldDescription` and published in
the `.d.ts`, but no implementation read it anywhere. Setting it did nothing at
all.

It now debounces async options loading. **Nobody's behaviour changes**, because
there was no behaviour — but if you set it hoping it would debounce
_validation_, it still does not. Async validation is debounced by not running on
the live pass at all; see `validationMode`.

### Async options

`options` may now return a promise, and the renderer gets `optionsStatus`,
`optionsError` and `onOptionsQuery` alongside it.

| Before | After |
| --------------------------------------------------------------- | ---------------------------------------- |
| renderer manages its own `loading`/`options` state and debounce | `options: async (data, _root, ctx) => …` |
| no way to express "reload when country changes" | `optionsDeps: (data) => [data.country]` |
| search box wired by hand inside the renderer | `onOptionsQuery(query)` |

Fully additive. A static array or a synchronous `(data, rootData) => Options[]`
behaves exactly as before and never enters a loading state.

One detail worth knowing: `options` keeps a **single** signature,
`(data, rootData?, ctx?)`, rather than becoming a union of a sync and an async
shape. A union of two function types defeats TypeScript's contextual inference,
which would have made every existing `options: (data) => …` an implicit-`any`
error. Returning a promise is what makes a loader async.
Loading