@dynamic-field-kit/angular 1.6.0 → 1.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +58 -0
- package/README.md +87 -22
- package/dist/fesm2022/dynamic-field-kit-angular.mjs +189 -16
- package/dist/types/dynamic-field-kit-angular.d.ts +71 -6
- package/package.json +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,63 @@
|
|
|
1
1
|
# @dynamic-field-kit/angular
|
|
2
2
|
|
|
3
|
+
## 1.7.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- Dirty-baseline rebasing, accessible validation errors, form-level message catalogs, and async field options across React, Vue, and Angular.
|
|
8
|
+
- 9b06e3f: Validation messages can be set once per form via `useDynamicForm({ messages })`,
|
|
9
|
+
or process-wide via `setDefaultMessages`, instead of passing a string to every
|
|
10
|
+
validator on every field. Built-in validators now resolve their message when
|
|
11
|
+
they run rather than when the field description is built, which is what made a
|
|
12
|
+
catalog impossible before. A message passed directly to a validator still wins,
|
|
13
|
+
and the English defaults are unchanged when no catalog is supplied.
|
|
14
|
+
|
|
15
|
+
`ValidationContext` - already `validate`'s fourth argument - gains an optional
|
|
16
|
+
`t` resolver, so a hand-written validator can translate its own messages too.
|
|
17
|
+
|
|
18
|
+
Adds `validators.matches(otherFieldName)` for confirm-password and
|
|
19
|
+
confirm-email fields, which every consumer was hand-writing.
|
|
20
|
+
|
|
21
|
+
No locale bundles ship: the mechanism is here, the translations are yours.
|
|
22
|
+
|
|
23
|
+
- a7358f9: Fix per-field `dirty`, which was measured against a baseline captured at mount
|
|
24
|
+
and never re-based - wrong after `reset(newValues)` on all three adapters, and
|
|
25
|
+
wrong on React and Vue for values that arrive after mount, where every field
|
|
26
|
+
reported dirty forever.
|
|
27
|
+
|
|
28
|
+
Adds `baselineValues` and `getDirtyValues()` to the form store on all three
|
|
29
|
+
adapters, and an `initialProperties` prop to `MultiFieldInput` for re-basing
|
|
30
|
+
without a store. Comparison moves from `!==` to `Object.is`, so a `NaN` numeric
|
|
31
|
+
field no longer reads as permanently dirty.
|
|
32
|
+
|
|
33
|
+
React's `useDynamicForm` no longer validates the same data twice per change.
|
|
34
|
+
|
|
35
|
+
- 53ed45a: `options` can now return a promise, covering both dependent selects
|
|
36
|
+
(`optionsDeps`) and search-remote pickers (`onOptionsQuery`). Renderers receive
|
|
37
|
+
`optionsStatus` and `optionsError` alongside `options`.
|
|
38
|
+
|
|
39
|
+
`debounceMs` was declared on `FieldDescription`, published in the `.d.ts` and
|
|
40
|
+
read by no implementation anywhere - setting it did nothing. It now debounces
|
|
41
|
+
these loads.
|
|
42
|
+
|
|
43
|
+
Debounce, abort of a superseded request, and discarding a response that lands
|
|
44
|
+
out of order all live in core's `createOptionsLoader`, so the three adapters
|
|
45
|
+
share one implementation. Synchronous and static options are untouched and never
|
|
46
|
+
enter a loading state.
|
|
47
|
+
|
|
48
|
+
- e35e876: `ariaDescribedBy` is now `${id}-error` when a field has an error instead of
|
|
49
|
+
being hard-coded `undefined`, and `makeErrorId` is exported so a custom renderer
|
|
50
|
+
can put the matching id on its message element. Without this,
|
|
51
|
+
`focusFirstInvalidField` had nothing to find for anyone following the official
|
|
52
|
+
renderer recipe.
|
|
53
|
+
|
|
54
|
+
Default renderers now render the validation message they were already being
|
|
55
|
+
handed - the one visible change in this release. Custom renderers are untouched,
|
|
56
|
+
so nobody gets two copies of their own message.
|
|
57
|
+
|
|
58
|
+
Development builds now warn when `FieldDescription.props` carries a key the
|
|
59
|
+
renderer prop contract owns, which 1.6.0 made possible to lose silently.
|
|
60
|
+
|
|
3
61
|
## 1.6.0
|
|
4
62
|
|
|
5
63
|
### Minor Changes
|
package/README.md
CHANGED
|
@@ -41,7 +41,7 @@ npm install @dynamic-field-kit/core@^1.5.0 @dynamic-field-kit/angular@^1.5.0
|
|
|
41
41
|
`dirty`, `id` and the aria flags used to be missing here, which left an
|
|
42
42
|
Angular renderer no way to tell whether a field had been touched
|
|
43
43
|
- `DynamicFormOptions` — what `createDynamicFormStore` takes: `fields`,
|
|
44
|
-
`initialValues`, `validateOnBlur`, `validateOnChange`
|
|
44
|
+
`initialValues`, `validateOnBlur`, `validateOnChange`, `messages`
|
|
45
45
|
- `LayoutConfig` / `ColumnLayoutConfig` / `RowLayoutConfig` /
|
|
46
46
|
`GridLayoutConfig` — the layout config types, re-exported from core
|
|
47
47
|
- `BaseLayoutConfig` / `ResponsiveLayoutConfig` — this adapter's historical
|
|
@@ -59,7 +59,15 @@ both packages:
|
|
|
59
59
|
- `validators` — the built-in validator helpers (`required`, `email`, `minLength`, `compose`, …)
|
|
60
60
|
- `FieldDescription` / `FieldTypeKey` / `FieldRendererProps` — the schema and
|
|
61
61
|
renderer contracts every adapter shares
|
|
62
|
-
- `ValidationResult` / `ValidationContext`
|
|
62
|
+
- `ValidationResult` / `ValidationContext` — the context carries `signal` and the
|
|
63
|
+
optional `t` message resolver
|
|
64
|
+
- `buildFieldRendererProps` / `makeFieldId` / `makeErrorId` /
|
|
65
|
+
`FIELD_RENDERER_PROP_KEYS` — the renderer prop contract. `makeErrorId(id)` is
|
|
66
|
+
what a custom renderer puts on its message element so `aria-describedby`
|
|
67
|
+
resolves
|
|
68
|
+
- `createOptionsLoader` / `isAsyncOptions` — the async options engine
|
|
69
|
+
- `createMessageResolver` / `setDefaultMessages` / `MessageCatalog` — validation
|
|
70
|
+
message catalog
|
|
63
71
|
|
|
64
72
|
`createDynamicFormStore` keeps live validation synchronous - a validator declared or
|
|
65
73
|
detected as async is never invoked on that path. Its `handleSubmit` runs one
|
|
@@ -170,6 +178,7 @@ import {
|
|
|
170
178
|
[errors]="store.errors()"
|
|
171
179
|
(onChange)="store.handleChange($event)"
|
|
172
180
|
(onBlurField)="store.handleBlur($event)"
|
|
181
|
+
[initialProperties]="store.baselineValues()"
|
|
173
182
|
></dfk-multi-field-input>
|
|
174
183
|
<button [disabled]="store.isSubmitting()">Save</button>
|
|
175
184
|
</form>
|
|
@@ -181,6 +190,7 @@ export class MyForm {
|
|
|
181
190
|
fields,
|
|
182
191
|
initialValues: { country: 'VN' },
|
|
183
192
|
validateOnBlur: true, // default
|
|
193
|
+
messages: { required: 'Bắt buộc' }, // optional; see Validation & conditions
|
|
184
194
|
});
|
|
185
195
|
|
|
186
196
|
// handleSubmit returns a handler, exactly like React and Vue.
|
|
@@ -188,26 +198,28 @@ export class MyForm {
|
|
|
188
198
|
}
|
|
189
199
|
```
|
|
190
200
|
|
|
191
|
-
| Member | Description
|
|
192
|
-
| ----------------------------------- |
|
|
193
|
-
| `data()` | Current form data, with `computeValue` fields applied
|
|
194
|
-
| `errors()` | `Record<string, string[]>`, keyed like `validateFields`
|
|
195
|
-
| `isValid()` / `isDirty()` | Current synchronous validity / any value has changed
|
|
196
|
-
| `
|
|
197
|
-
| `
|
|
198
|
-
| `
|
|
199
|
-
| `
|
|
200
|
-
| `
|
|
201
|
-
| `
|
|
202
|
-
| `
|
|
203
|
-
| `
|
|
204
|
-
| `
|
|
205
|
-
| `
|
|
206
|
-
| `
|
|
207
|
-
| `
|
|
208
|
-
| `
|
|
209
|
-
| `
|
|
210
|
-
| `
|
|
201
|
+
| Member | Description |
|
|
202
|
+
| ----------------------------------- | -------------------------------------------------------------------------------------------------------------- |
|
|
203
|
+
| `data()` | Current form data, with `computeValue` fields applied |
|
|
204
|
+
| `errors()` | `Record<string, string[]>`, keyed like `validateFields` |
|
|
205
|
+
| `isValid()` / `isDirty()` | Current synchronous validity / any value has changed |
|
|
206
|
+
| `baselineValues()` | Signal holding the values `dirty` is measured against - `initialValues` until `reset(newValues)` replaces them |
|
|
207
|
+
| `getDirtyValues()` | Only the entries differing from `baselineValues`, for PATCH-style submits |
|
|
208
|
+
| `isValidating()` | An async validation pass is in flight |
|
|
209
|
+
| `isValidationComplete()` | Every applicable validator finished and none is in flight |
|
|
210
|
+
| `validationStatus()` | `'valid' | 'invalid' | 'pending'`— prefer it over`isValid`alone:`valid` cannot tell "nothing is wrong" from "nothing is wrong yet" |
|
|
211
|
+
| `isSubmitting()` / `isSubmitted()` | In-flight submit / at least one submit attempted |
|
|
212
|
+
| `touched()` | Fields that have been blurred |
|
|
213
|
+
| `handleChange(data)` | Replace the whole form data — bind to `(onChange)` |
|
|
214
|
+
| `setFieldValue(name, value)` | Change one field |
|
|
215
|
+
| `handleBlur(name)` | Mark touched, and validate when `validateOnBlur` |
|
|
216
|
+
| `setFieldTouched(name, value?)` | Set touched explicitly |
|
|
217
|
+
| `touchAll()` | Mark every field touched — `handleSubmit` already calls it |
|
|
218
|
+
| `resetTouched()` | Clear touched only, leaving data/errors/dirty alone |
|
|
219
|
+
| `validate()` | Validate now, returns a boolean |
|
|
220
|
+
| `validateAsync()` | Validate now, awaiting Promise-based rules |
|
|
221
|
+
| `reset(values?)` | Back to `initialValues` (or the values given), clearing errors/touched/submission |
|
|
222
|
+
| `handleSubmit(onValid, onInvalid?)` | Returns an async handler; calls `preventDefault`, validates, then dispatches |
|
|
211
223
|
|
|
212
224
|
`MultiFieldInput` emits `(onBlurField)` with the field's name, driven by a
|
|
213
225
|
`focusout` listener — so it works with any renderer, without the renderer
|
|
@@ -247,6 +259,13 @@ Any type you have not registered falls back to one of these. `file` emits a
|
|
|
247
259
|
`File` (or `File[]` when `multiple` is set), `range` and `number` emit numbers,
|
|
248
260
|
`checkbox` / `switch` emit booleans; everything else emits strings.
|
|
249
261
|
|
|
262
|
+
Since 1.7.0 a default renderer also renders its validation message, as
|
|
263
|
+
`<div id="{fieldId}-error" class="dfk-field-error" role="alert">`, which is what
|
|
264
|
+
`aria-describedby` points at. Before that they were handed `error` and dropped
|
|
265
|
+
it, so the form showed nothing. A registered custom renderer is unaffected — the
|
|
266
|
+
node is emitted only where a default was used, so you never get two copies. Hide
|
|
267
|
+
it with `.dfk-field-error { display: none }` if you want the old silence.
|
|
268
|
+
|
|
250
269
|
## DevTools
|
|
251
270
|
|
|
252
271
|
```html
|
|
@@ -339,6 +358,52 @@ your renderer component receives `error`, `disabled`, and `readOnly` inputs, and
|
|
|
339
358
|
|
|
340
359
|
For submit-time whole-form validation, call `validateFields(fields, data)`.
|
|
341
360
|
|
|
361
|
+
### Validation messages
|
|
362
|
+
|
|
363
|
+
Set the built-in validators' messages once per form instead of on every field:
|
|
364
|
+
|
|
365
|
+
```ts
|
|
366
|
+
const store = createDynamicFormStore({
|
|
367
|
+
fields,
|
|
368
|
+
messages: { required: 'Bắt buộc', minLength: 'Tối thiểu {min} ký tự' },
|
|
369
|
+
});
|
|
370
|
+
```
|
|
371
|
+
|
|
372
|
+
A message passed straight to a validator still wins, and any key omitted falls
|
|
373
|
+
back to the English default. `setDefaultMessages(catalog)` sets a process-wide
|
|
374
|
+
one for code calling `validateFields` directly. Full key list in the
|
|
375
|
+
[core README](../core/README.md#validation-messages). **No locale bundles
|
|
376
|
+
ship** — the mechanism is here, the translations are yours.
|
|
377
|
+
|
|
378
|
+
Forward `ariaInvalid`, `ariaRequired` and `ariaDescribedBy` from your renderer
|
|
379
|
+
too, and put `makeErrorId(id)` on whatever element shows the message.
|
|
380
|
+
`focusFirstInvalidField` selects `[aria-invalid="true"]`, so a renderer that
|
|
381
|
+
drops those props makes that helper silently do nothing. See
|
|
382
|
+
[the recipes](../../docs/ui-kit-recipes.md#forward-the-aria-props).
|
|
383
|
+
|
|
384
|
+
### Async options
|
|
385
|
+
|
|
386
|
+
`options` may return a promise. The renderer receives `optionsStatus`
|
|
387
|
+
(`'idle' | 'loading' | 'ready' | 'error'`), `optionsError` and
|
|
388
|
+
`onOptionsQuery`:
|
|
389
|
+
|
|
390
|
+
```ts
|
|
391
|
+
{
|
|
392
|
+
name: 'assignee',
|
|
393
|
+
type: 'userPicker',
|
|
394
|
+
options: async (data, _rootData, ctx) =>
|
|
395
|
+
fetch(`/api/users?q=${ctx?.query ?? ''}`, { signal: ctx?.signal })
|
|
396
|
+
.then((r) => r.json()),
|
|
397
|
+
optionsDeps: (data) => [data.team], // reload when this changes; default []
|
|
398
|
+
debounceMs: 300, // collapses rapid reloads into one fetch
|
|
399
|
+
}
|
|
400
|
+
```
|
|
401
|
+
|
|
402
|
+
Superseded requests are aborted and out-of-order responses discarded, so the
|
|
403
|
+
list always reflects the newest request. Static and synchronous options are
|
|
404
|
+
untouched and never enter a loading state. See the
|
|
405
|
+
[core README](../core/README.md#async-options).
|
|
406
|
+
|
|
342
407
|
## Repeatable field groups
|
|
343
408
|
|
|
344
409
|
A field with `fields` renders as a repeatable group: `data[name]` becomes an array of items, each shaped by the nested `fields`, with "Add"/"Remove" controls rendered automatically.
|
|
@@ -2,8 +2,8 @@ import * as i0 from '@angular/core';
|
|
|
2
2
|
import { EventEmitter, Output, Input, Component, InjectionToken, inject, ViewContainerRef, ViewChild, ChangeDetectionStrategy, HostListener, signal, computed, NgModule } from '@angular/core';
|
|
3
3
|
import * as i1 from '@angular/common';
|
|
4
4
|
import { CommonModule, NgIf, NgClass, NgFor, JsonPipe } from '@angular/common';
|
|
5
|
-
import { fieldRegistry, buildFieldRendererProps, makeFieldId, indexGroupPathMap, applyComputedValues, validateFields, canAddGroupItem, canRemoveGroupItem, createGroupItem, validateFieldsAsync, collectFieldPaths } from '@dynamic-field-kit/core';
|
|
6
|
-
export { FieldRegistry, collectFieldPaths, fieldRegistry, indexGroupPathMap, resolveDisabled, resolveOptions, resolveReadOnly, validateField, validateFieldAsync, validateFields, validateFieldsAsync, validators } from '@dynamic-field-kit/core';
|
|
5
|
+
import { fieldRegistry, makeErrorId, isAsyncOptions, createOptionsLoader, buildFieldRendererProps, makeFieldId, indexGroupPathMap, applyComputedValues, validateFields, canAddGroupItem, canRemoveGroupItem, createGroupItem, createMessageResolver, validateFieldsAsync, collectFieldPaths } from '@dynamic-field-kit/core';
|
|
6
|
+
export { FIELD_RENDERER_PROP_KEYS, FieldRegistry, buildFieldRendererProps, collectFieldPaths, fieldRegistry, indexGroupPathMap, makeErrorId, makeFieldId, resolveDisabled, resolveOptions, resolveReadOnly, validateField, validateFieldAsync, validateFields, validateFieldsAsync, validators } from '@dynamic-field-kit/core';
|
|
7
7
|
|
|
8
8
|
class BaseInputComponent {
|
|
9
9
|
cdr;
|
|
@@ -23,6 +23,10 @@ class BaseInputComponent {
|
|
|
23
23
|
dirty;
|
|
24
24
|
error;
|
|
25
25
|
options;
|
|
26
|
+
optionsStatus;
|
|
27
|
+
optionsError;
|
|
28
|
+
/** Renderer-driven refetch for a search-remote field. */
|
|
29
|
+
onOptionsQuery;
|
|
26
30
|
className;
|
|
27
31
|
description;
|
|
28
32
|
id;
|
|
@@ -48,7 +52,7 @@ class BaseInputComponent {
|
|
|
48
52
|
return changes[prop] !== undefined;
|
|
49
53
|
}
|
|
50
54
|
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.22", ngImport: i0, type: BaseInputComponent, deps: [{ token: i0.ChangeDetectorRef }], target: i0.ɵɵFactoryTarget.Component });
|
|
51
|
-
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "21.2.22", type: BaseInputComponent, isStandalone: true, selector: "ng-component", inputs: { value: "value", label: "label", placeholder: "placeholder", required: "required", disabled: "disabled", readOnly: "readOnly", touched: "touched", dirty: "dirty", error: "error", options: "options", className: "className", description: "description", id: "id", ariaInvalid: "ariaInvalid", ariaDescribedBy: "ariaDescribedBy", ariaRequired: "ariaRequired", min: "min", max: "max", step: "step", accept: "accept", multiple: "multiple" }, outputs: { valueChange: "valueChange" }, usesOnChanges: true, ngImport: i0, template: '', isInline: true });
|
|
55
|
+
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "21.2.22", type: BaseInputComponent, isStandalone: true, selector: "ng-component", inputs: { value: "value", label: "label", placeholder: "placeholder", required: "required", disabled: "disabled", readOnly: "readOnly", touched: "touched", dirty: "dirty", error: "error", options: "options", optionsStatus: "optionsStatus", optionsError: "optionsError", onOptionsQuery: "onOptionsQuery", className: "className", description: "description", id: "id", ariaInvalid: "ariaInvalid", ariaDescribedBy: "ariaDescribedBy", ariaRequired: "ariaRequired", min: "min", max: "max", step: "step", accept: "accept", multiple: "multiple" }, outputs: { valueChange: "valueChange" }, usesOnChanges: true, ngImport: i0, template: '', isInline: true });
|
|
52
56
|
}
|
|
53
57
|
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.22", ngImport: i0, type: BaseInputComponent, decorators: [{
|
|
54
58
|
type: Component,
|
|
@@ -75,6 +79,12 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.22", ngImpo
|
|
|
75
79
|
type: Input
|
|
76
80
|
}], options: [{
|
|
77
81
|
type: Input
|
|
82
|
+
}], optionsStatus: [{
|
|
83
|
+
type: Input
|
|
84
|
+
}], optionsError: [{
|
|
85
|
+
type: Input
|
|
86
|
+
}], onOptionsQuery: [{
|
|
87
|
+
type: Input
|
|
78
88
|
}], className: [{
|
|
79
89
|
type: Input
|
|
80
90
|
}], description: [{
|
|
@@ -128,6 +138,8 @@ const KNOWN_PROPS = [
|
|
|
128
138
|
'dirty',
|
|
129
139
|
'error',
|
|
130
140
|
'options',
|
|
141
|
+
'optionsStatus',
|
|
142
|
+
'optionsError',
|
|
131
143
|
'className',
|
|
132
144
|
'description',
|
|
133
145
|
'id',
|
|
@@ -186,6 +198,9 @@ class DynamicInput extends BaseInputComponent {
|
|
|
186
198
|
this.inputInstance[prop] = this[prop];
|
|
187
199
|
}
|
|
188
200
|
}
|
|
201
|
+
if (changes['onOptionsQuery']) {
|
|
202
|
+
this.applyCallbackProps(this.inputInstance);
|
|
203
|
+
}
|
|
189
204
|
if (changes['extraProps']) {
|
|
190
205
|
this.applyExtraProps(this.inputInstance);
|
|
191
206
|
}
|
|
@@ -204,12 +219,33 @@ class DynamicInput extends BaseInputComponent {
|
|
|
204
219
|
this.compRef = undefined;
|
|
205
220
|
this.inputInstance = undefined;
|
|
206
221
|
}
|
|
222
|
+
/** The id `ariaDescribedBy` points at. See core's `makeErrorId`. */
|
|
223
|
+
errorNodeId() {
|
|
224
|
+
return makeErrorId(this.id ?? '');
|
|
225
|
+
}
|
|
226
|
+
/** `error` may arrive as a bare string, so index 0 would be a character. */
|
|
227
|
+
firstError() {
|
|
228
|
+
return Array.isArray(this.error) ? this.error[0] : this.error;
|
|
229
|
+
}
|
|
230
|
+
/**
|
|
231
|
+
* Whether the adapter should render the validation message itself.
|
|
232
|
+
*
|
|
233
|
+
* Asks the registry directly rather than reading a flag set by `render()`:
|
|
234
|
+
* `render()` runs in `ngAfterViewInit`, by which point this template's
|
|
235
|
+
* bindings have already been checked for the pass, and under `OnPush`
|
|
236
|
+
* nothing would mark them dirty again. A registered renderer owns its own
|
|
237
|
+
* error presentation, so only the built-in fallback gets a message here.
|
|
238
|
+
*/
|
|
239
|
+
showDefaultError() {
|
|
240
|
+
return Boolean(!this.registry.get(this.type) && this.firstError() && this.id);
|
|
241
|
+
}
|
|
207
242
|
render() {
|
|
208
243
|
const Renderer = this.registry.get(this.type);
|
|
209
244
|
this.cleanup();
|
|
210
245
|
this.host.clear();
|
|
211
246
|
if (!Renderer) {
|
|
212
247
|
if (this.renderDefaultFallbackHTML5(this.type)) {
|
|
248
|
+
this.applyFallbackAria();
|
|
213
249
|
return;
|
|
214
250
|
}
|
|
215
251
|
this.renderError(`Unknown field type: ${this.type}`);
|
|
@@ -273,6 +309,7 @@ class DynamicInput extends BaseInputComponent {
|
|
|
273
309
|
...this.extraProps,
|
|
274
310
|
value: this.value,
|
|
275
311
|
onValueChange: (v) => this.emitValue(v),
|
|
312
|
+
onOptionsQuery: this.onOptionsQuery,
|
|
276
313
|
label: this.label ?? '',
|
|
277
314
|
placeholder: this.placeholder ?? '',
|
|
278
315
|
required: this.required ?? false,
|
|
@@ -320,8 +357,22 @@ class DynamicInput extends BaseInputComponent {
|
|
|
320
357
|
instanceObj[prop] = this[prop];
|
|
321
358
|
}
|
|
322
359
|
}
|
|
360
|
+
this.applyCallbackProps(instance);
|
|
323
361
|
this.applyExtraProps(instance);
|
|
324
362
|
}
|
|
363
|
+
/**
|
|
364
|
+
* Props that are callbacks rather than resolved values, so they are not in
|
|
365
|
+
* `KNOWN_PROPS` / `FIELD_RENDERER_PROP_KEYS` and the loop above never sees
|
|
366
|
+
* them. Without this a renderer declaring `onOptionsQuery` always got
|
|
367
|
+
* `undefined` and could never trigger a search-remote refetch.
|
|
368
|
+
*/
|
|
369
|
+
applyCallbackProps(instance) {
|
|
370
|
+
if (!instance) {
|
|
371
|
+
return;
|
|
372
|
+
}
|
|
373
|
+
instance['onOptionsQuery'] =
|
|
374
|
+
this.onOptionsQuery;
|
|
375
|
+
}
|
|
325
376
|
applyExtraProps(instance) {
|
|
326
377
|
if (!instance || !this.extraProps) {
|
|
327
378
|
return;
|
|
@@ -348,6 +399,35 @@ class DynamicInput extends BaseInputComponent {
|
|
|
348
399
|
this.valueChange.emit(value);
|
|
349
400
|
this.onChange.emit(value);
|
|
350
401
|
}
|
|
402
|
+
/**
|
|
403
|
+
* Puts the aria flags on whatever control the HTML5 fallback just built.
|
|
404
|
+
*
|
|
405
|
+
* Applied here, once, rather than in each of the fallback's branches: they
|
|
406
|
+
* hand-build an input, textarea, checkbox or select, and every one of them
|
|
407
|
+
* would otherwise need the same four lines. Without this the fallback emits
|
|
408
|
+
* the `{id}-error` node with nothing pointing at it, and
|
|
409
|
+
* `focusFirstInvalidField` - which selects `[aria-invalid="true"]` - still
|
|
410
|
+
* finds nothing on this adapter.
|
|
411
|
+
*
|
|
412
|
+
* `render()` re-runs on every ngOnChanges while the fallback is in use (there
|
|
413
|
+
* is no component instance to sync props into), so this stays current as the
|
|
414
|
+
* field's error comes and goes.
|
|
415
|
+
*/
|
|
416
|
+
applyFallbackAria() {
|
|
417
|
+
const control = this.host.element.nativeElement.querySelector('input, select, textarea');
|
|
418
|
+
if (!control) {
|
|
419
|
+
return;
|
|
420
|
+
}
|
|
421
|
+
if (this.ariaInvalid !== undefined) {
|
|
422
|
+
control.setAttribute('aria-invalid', String(this.ariaInvalid));
|
|
423
|
+
}
|
|
424
|
+
if (this.ariaRequired !== undefined) {
|
|
425
|
+
control.setAttribute('aria-required', String(this.ariaRequired));
|
|
426
|
+
}
|
|
427
|
+
if (this.ariaDescribedBy) {
|
|
428
|
+
control.setAttribute('aria-describedby', this.ariaDescribedBy);
|
|
429
|
+
}
|
|
430
|
+
}
|
|
351
431
|
renderDefaultFallbackHTML5(type) {
|
|
352
432
|
const nativeEl = this.host.element.nativeElement;
|
|
353
433
|
if (type === 'text' ||
|
|
@@ -479,7 +559,15 @@ class DynamicInput extends BaseInputComponent {
|
|
|
479
559
|
this.host.element.nativeElement.appendChild(el);
|
|
480
560
|
}
|
|
481
561
|
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.22", ngImport: i0, type: DynamicInput, deps: null, target: i0.ɵɵFactoryTarget.Component });
|
|
482
|
-
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "21.2.22", type: DynamicInput, isStandalone: true, selector: "dfk-dynamic-input", inputs: { type: "type", extraProps: "extraProps" }, outputs: { valueChange: "valueChange", onChange: "onChange" }, viewQueries: [{ propertyName: "host", first: true, predicate: ["host"], descendants: true, read: ViewContainerRef }], usesInheritance: true, usesOnChanges: true, ngImport: i0, template: `<div #host style="display: contents;"></div
|
|
562
|
+
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "21.2.22", type: DynamicInput, isStandalone: true, selector: "dfk-dynamic-input", inputs: { type: "type", extraProps: "extraProps" }, outputs: { valueChange: "valueChange", onChange: "onChange" }, viewQueries: [{ propertyName: "host", first: true, predicate: ["host"], descendants: true, read: ViewContainerRef }], usesInheritance: true, usesOnChanges: true, ngImport: i0, template: `<div #host style="display: contents;"></div>
|
|
563
|
+
<div
|
|
564
|
+
*ngIf="showDefaultError()"
|
|
565
|
+
[id]="errorNodeId()"
|
|
566
|
+
class="dfk-field-error"
|
|
567
|
+
role="alert"
|
|
568
|
+
>
|
|
569
|
+
{{ firstError() }}
|
|
570
|
+
</div>`, isInline: true, dependencies: [{ kind: "ngmodule", type: CommonModule }, { kind: "directive", type: i1.NgIf, selector: "[ngIf]", inputs: ["ngIf", "ngIfThen", "ngIfElse"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
|
|
483
571
|
}
|
|
484
572
|
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.22", ngImport: i0, type: DynamicInput, decorators: [{
|
|
485
573
|
type: Component,
|
|
@@ -488,7 +576,17 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.22", ngImpo
|
|
|
488
576
|
standalone: true,
|
|
489
577
|
imports: [CommonModule],
|
|
490
578
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
|
491
|
-
|
|
579
|
+
// *ngIf rather than @if: the peer range starts at Angular 16, and the
|
|
580
|
+
// built-in control flow block syntax is 17+.
|
|
581
|
+
template: `<div #host style="display: contents;"></div>
|
|
582
|
+
<div
|
|
583
|
+
*ngIf="showDefaultError()"
|
|
584
|
+
[id]="errorNodeId()"
|
|
585
|
+
class="dfk-field-error"
|
|
586
|
+
role="alert"
|
|
587
|
+
>
|
|
588
|
+
{{ firstError() }}
|
|
589
|
+
</div>`,
|
|
492
590
|
}]
|
|
493
591
|
}], propDecorators: { type: [{
|
|
494
592
|
type: Input
|
|
@@ -545,10 +643,45 @@ class FieldInput {
|
|
|
545
643
|
constructor(cdr) {
|
|
546
644
|
this.cdr = cdr;
|
|
547
645
|
}
|
|
646
|
+
// Async options only; a static or synchronous list allocates nothing here.
|
|
647
|
+
loader;
|
|
648
|
+
optionsState;
|
|
649
|
+
/** Bound into the template so a renderer can drive a search-remote refetch. */
|
|
650
|
+
onOptionsQuery = (query) => {
|
|
651
|
+
this.loader?.setQuery(query);
|
|
652
|
+
};
|
|
548
653
|
ngOnChanges(_changes) {
|
|
654
|
+
this.syncOptionsLoader();
|
|
549
655
|
this.rendererProps = this.buildProps();
|
|
550
656
|
this.cdr.markForCheck();
|
|
551
657
|
}
|
|
658
|
+
ngOnDestroy() {
|
|
659
|
+
this.loader?.dispose();
|
|
660
|
+
}
|
|
661
|
+
syncOptionsLoader() {
|
|
662
|
+
const field = this.fieldDescription;
|
|
663
|
+
if (!field || !isAsyncOptions(field)) {
|
|
664
|
+
// A field can be swapped for one with synchronous options. Without this,
|
|
665
|
+
// the stale optionsState would keep winning in buildFieldRendererProps
|
|
666
|
+
// (`optionsState ? optionsState.options : resolveOptions(...)`) and the
|
|
667
|
+
// old async list would be served forever, with the loader never disposed.
|
|
668
|
+
this.loader?.dispose();
|
|
669
|
+
this.loader = undefined;
|
|
670
|
+
this.optionsState = undefined;
|
|
671
|
+
return;
|
|
672
|
+
}
|
|
673
|
+
if (!this.loader) {
|
|
674
|
+
this.optionsState = { status: 'idle' };
|
|
675
|
+
this.loader = createOptionsLoader(field, (state) => {
|
|
676
|
+
this.optionsState = state;
|
|
677
|
+
this.rendererProps = this.buildProps();
|
|
678
|
+
// OnPush: an async arrival happens outside any event this view is
|
|
679
|
+
// checked for, so without this the options would load and never appear.
|
|
680
|
+
this.cdr.markForCheck();
|
|
681
|
+
});
|
|
682
|
+
}
|
|
683
|
+
this.loader.update(this.data ?? {}, this.rootData);
|
|
684
|
+
}
|
|
552
685
|
buildProps() {
|
|
553
686
|
const field = this.fieldDescription;
|
|
554
687
|
if (!field) {
|
|
@@ -562,6 +695,7 @@ class FieldInput {
|
|
|
562
695
|
id: makeFieldId(field, this.idPrefix),
|
|
563
696
|
touched: this.touched,
|
|
564
697
|
dirty: this.dirty,
|
|
698
|
+
optionsState: this.optionsState,
|
|
565
699
|
});
|
|
566
700
|
// Explicitly bound inputs override what the field description resolves to,
|
|
567
701
|
// so a host can still drive options/disabled/error itself.
|
|
@@ -588,6 +722,8 @@ class FieldInput {
|
|
|
588
722
|
[required]="p.required"
|
|
589
723
|
[description]="$any(p.description)"
|
|
590
724
|
[options]="$any(p.options)"
|
|
725
|
+
[optionsStatus]="p.optionsStatus"
|
|
726
|
+
[optionsError]="p.optionsError"
|
|
591
727
|
[className]="p.className"
|
|
592
728
|
[disabled]="p.disabled"
|
|
593
729
|
[readOnly]="p.readOnly"
|
|
@@ -608,6 +744,7 @@ class FieldInput {
|
|
|
608
744
|
"
|
|
609
745
|
(focusout)="onBlurField.emit(fieldDescription!.name)"
|
|
610
746
|
[extraProps]="p.extraProps"
|
|
747
|
+
[onOptionsQuery]="onOptionsQuery"
|
|
611
748
|
></dfk-dynamic-input>
|
|
612
749
|
`, isInline: true, dependencies: [{ kind: "directive", type: NgIf, selector: "[ngIf]", inputs: ["ngIf", "ngIfThen", "ngIfElse"] }, { kind: "component", type: DynamicInput, selector: "dfk-dynamic-input", inputs: ["type", "extraProps"], outputs: ["valueChange", "onChange"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
|
|
613
750
|
}
|
|
@@ -628,6 +765,8 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.22", ngImpo
|
|
|
628
765
|
[required]="p.required"
|
|
629
766
|
[description]="$any(p.description)"
|
|
630
767
|
[options]="$any(p.options)"
|
|
768
|
+
[optionsStatus]="p.optionsStatus"
|
|
769
|
+
[optionsError]="p.optionsError"
|
|
631
770
|
[className]="p.className"
|
|
632
771
|
[disabled]="p.disabled"
|
|
633
772
|
[readOnly]="p.readOnly"
|
|
@@ -648,6 +787,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.22", ngImpo
|
|
|
648
787
|
"
|
|
649
788
|
(focusout)="onBlurField.emit(fieldDescription!.name)"
|
|
650
789
|
[extraProps]="p.extraProps"
|
|
790
|
+
[onOptionsQuery]="onOptionsQuery"
|
|
651
791
|
></dfk-dynamic-input>
|
|
652
792
|
`,
|
|
653
793
|
}]
|
|
@@ -700,6 +840,13 @@ class MultiFieldInput {
|
|
|
700
840
|
cdr;
|
|
701
841
|
fieldDescriptions = [];
|
|
702
842
|
properties;
|
|
843
|
+
/**
|
|
844
|
+
* The values per-field `dirty` is measured against. Defaults to the first
|
|
845
|
+
* non-`undefined` `properties` this component sees. This adapter has no
|
|
846
|
+
* `form` shorthand, so pass `store.baselineValues()` here to keep `dirty`
|
|
847
|
+
* correct across `store.reset(newValues)`.
|
|
848
|
+
*/
|
|
849
|
+
initialProperties;
|
|
703
850
|
onChange = new EventEmitter();
|
|
704
851
|
validityChange = new EventEmitter();
|
|
705
852
|
/**
|
|
@@ -734,7 +881,7 @@ class MultiFieldInput {
|
|
|
734
881
|
// component is client-rendered by the time ids matter, so a module counter
|
|
735
882
|
// is enough.
|
|
736
883
|
instanceId = nextInstanceId();
|
|
737
|
-
|
|
884
|
+
firstSeenProperties = {};
|
|
738
885
|
indexedErrorsSource;
|
|
739
886
|
indexedErrors = new Map();
|
|
740
887
|
indexedTouchedSource;
|
|
@@ -760,7 +907,8 @@ class MultiFieldInput {
|
|
|
760
907
|
}
|
|
761
908
|
/** Whether this field's value differs from the one the form opened with. */
|
|
762
909
|
isFieldDirty(fieldName) {
|
|
763
|
-
|
|
910
|
+
const baseline = this.initialProperties ?? this.firstSeenProperties;
|
|
911
|
+
return !Object.is(this.data[fieldName], baseline[fieldName]);
|
|
764
912
|
}
|
|
765
913
|
fieldErrors(fieldName) {
|
|
766
914
|
return this.errors?.[fieldName];
|
|
@@ -903,7 +1051,7 @@ class MultiFieldInput {
|
|
|
903
1051
|
// Baseline for the `dirty` flag: the values the form opened with, not
|
|
904
1052
|
// whatever `properties` happens to hold after later edits.
|
|
905
1053
|
if (!this.initialised) {
|
|
906
|
-
this.
|
|
1054
|
+
this.firstSeenProperties = { ...this.properties };
|
|
907
1055
|
this.initialised = true;
|
|
908
1056
|
}
|
|
909
1057
|
}
|
|
@@ -962,7 +1110,7 @@ class MultiFieldInput {
|
|
|
962
1110
|
this.cdr.markForCheck();
|
|
963
1111
|
}
|
|
964
1112
|
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.22", ngImport: i0, type: MultiFieldInput, deps: [{ token: i0.ChangeDetectorRef }], target: i0.ɵɵFactoryTarget.Component });
|
|
965
|
-
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "21.2.22", type: MultiFieldInput, isStandalone: true, selector: "dfk-multi-field-input", inputs: { fieldDescriptions: "fieldDescriptions", properties: "properties", touched: "touched", errors: "errors", idPrefix: "idPrefix", layout: "layout", rootData: "rootData" }, outputs: { onChange: "onChange", validityChange: "validityChange", onBlurField: "onBlurField", touchedChange: "touchedChange" }, host: { listeners: { "window:resize": "onWindowResize()" } }, usesOnChanges: true, ngImport: i0, template: `
|
|
1113
|
+
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "21.2.22", type: MultiFieldInput, isStandalone: true, selector: "dfk-multi-field-input", inputs: { fieldDescriptions: "fieldDescriptions", properties: "properties", initialProperties: "initialProperties", touched: "touched", errors: "errors", idPrefix: "idPrefix", layout: "layout", rootData: "rootData" }, outputs: { onChange: "onChange", validityChange: "validityChange", onBlurField: "onBlurField", touchedChange: "touchedChange" }, host: { listeners: { "window:resize": "onWindowResize()" } }, usesOnChanges: true, ngImport: i0, template: `
|
|
966
1114
|
<div
|
|
967
1115
|
class="p-4 border rounded-lg bg-gray-50"
|
|
968
1116
|
[ngClass]="{
|
|
@@ -1039,7 +1187,7 @@ class MultiFieldInput {
|
|
|
1039
1187
|
</div>
|
|
1040
1188
|
</ng-container>
|
|
1041
1189
|
</div>
|
|
1042
|
-
`, isInline: true, dependencies: [{ kind: "component", type: MultiFieldInput, selector: "dfk-multi-field-input", inputs: ["fieldDescriptions", "properties", "touched", "errors", "idPrefix", "layout", "rootData"], outputs: ["onChange", "validityChange", "onBlurField", "touchedChange"] }, { kind: "directive", type: NgClass, selector: "[ngClass]", inputs: ["class", "ngClass"] }, { kind: "directive", type: NgFor, selector: "[ngFor][ngForOf]", inputs: ["ngForOf", "ngForTrackBy", "ngForTemplate"] }, { kind: "directive", type: NgIf, selector: "[ngIf]", inputs: ["ngIf", "ngIfThen", "ngIfElse"] }, { kind: "component", type: FieldInput, selector: "dfk-field-input", inputs: ["fieldDescription", "data", "rootData", "value", "options", "disabled", "readOnly", "error", "validationControlled", "touched", "dirty", "idPrefix"], outputs: ["onValueChangeField", "onBlurField"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
|
|
1190
|
+
`, isInline: true, dependencies: [{ kind: "component", type: MultiFieldInput, selector: "dfk-multi-field-input", inputs: ["fieldDescriptions", "properties", "initialProperties", "touched", "errors", "idPrefix", "layout", "rootData"], outputs: ["onChange", "validityChange", "onBlurField", "touchedChange"] }, { kind: "directive", type: NgClass, selector: "[ngClass]", inputs: ["class", "ngClass"] }, { kind: "directive", type: NgFor, selector: "[ngFor][ngForOf]", inputs: ["ngForOf", "ngForTrackBy", "ngForTemplate"] }, { kind: "directive", type: NgIf, selector: "[ngIf]", inputs: ["ngIf", "ngIfThen", "ngIfElse"] }, { kind: "component", type: FieldInput, selector: "dfk-field-input", inputs: ["fieldDescription", "data", "rootData", "value", "options", "disabled", "readOnly", "error", "validationControlled", "touched", "dirty", "idPrefix"], outputs: ["onValueChangeField", "onBlurField"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
|
|
1043
1191
|
}
|
|
1044
1192
|
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.22", ngImport: i0, type: MultiFieldInput, decorators: [{
|
|
1045
1193
|
type: Component,
|
|
@@ -1137,6 +1285,8 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.22", ngImpo
|
|
|
1137
1285
|
type: Input
|
|
1138
1286
|
}], properties: [{
|
|
1139
1287
|
type: Input
|
|
1288
|
+
}], initialProperties: [{
|
|
1289
|
+
type: Input
|
|
1140
1290
|
}], onChange: [{
|
|
1141
1291
|
type: Output
|
|
1142
1292
|
}], validityChange: [{
|
|
@@ -1471,13 +1621,20 @@ function createDynamicFormStore(options) {
|
|
|
1471
1621
|
const initialValues = options.initialValues || {};
|
|
1472
1622
|
const validateOnBlur = options.validateOnBlur ?? true;
|
|
1473
1623
|
const validateOnChange = options.validateOnChange ?? false;
|
|
1624
|
+
const validationContext = {
|
|
1625
|
+
t: createMessageResolver(options.messages),
|
|
1626
|
+
};
|
|
1474
1627
|
const data = signal(applyComputedValues(fields, initialValues), ...(ngDevMode ? [{ debugName: "data" }] : /* istanbul ignore next */ []));
|
|
1628
|
+
// The baseline `dirty` is measured against: the initialValues option until
|
|
1629
|
+
// reset(newValues) replaces it. Distinct from that option, which never
|
|
1630
|
+
// changes. See the React adapter for the full rationale.
|
|
1631
|
+
const baselineValues = signal({ ...data() }, ...(ngDevMode ? [{ debugName: "baselineValues" }] : /* istanbul ignore next */ []));
|
|
1475
1632
|
const errors = signal({}, ...(ngDevMode ? [{ debugName: "errors" }] : /* istanbul ignore next */ []));
|
|
1476
1633
|
const isDirty = signal(false, ...(ngDevMode ? [{ debugName: "isDirty" }] : /* istanbul ignore next */ []));
|
|
1477
1634
|
const touched = signal({}, ...(ngDevMode ? [{ debugName: "touched" }] : /* istanbul ignore next */ []));
|
|
1478
1635
|
const isSubmitting = signal(false, ...(ngDevMode ? [{ debugName: "isSubmitting" }] : /* istanbul ignore next */ []));
|
|
1479
1636
|
const isSubmitted = signal(false, ...(ngDevMode ? [{ debugName: "isSubmitted" }] : /* istanbul ignore next */ []));
|
|
1480
|
-
const validationResult = signal(validateFields(fields, data()), ...(ngDevMode ? [{ debugName: "validationResult" }] : /* istanbul ignore next */ []));
|
|
1637
|
+
const validationResult = signal(validateFields(fields, data(), undefined, validationContext), ...(ngDevMode ? [{ debugName: "validationResult" }] : /* istanbul ignore next */ []));
|
|
1481
1638
|
const isValidating = signal(false, ...(ngDevMode ? [{ debugName: "isValidating" }] : /* istanbul ignore next */ []));
|
|
1482
1639
|
let validationRun = 0;
|
|
1483
1640
|
let validationController;
|
|
@@ -1493,7 +1650,7 @@ function createDynamicFormStore(options) {
|
|
|
1493
1650
|
return res.valid;
|
|
1494
1651
|
}
|
|
1495
1652
|
function validate() {
|
|
1496
|
-
const res = validateFields(fields, data());
|
|
1653
|
+
const res = validateFields(fields, data(), undefined, validationContext);
|
|
1497
1654
|
errors.set(res.errors);
|
|
1498
1655
|
return commitSyncResult(res);
|
|
1499
1656
|
}
|
|
@@ -1506,6 +1663,7 @@ function createDynamicFormStore(options) {
|
|
|
1506
1663
|
isValidating.set(true);
|
|
1507
1664
|
try {
|
|
1508
1665
|
const res = await validateFieldsAsync(fields, snapshot, snapshot, {
|
|
1666
|
+
...validationContext,
|
|
1509
1667
|
signal: controller.signal,
|
|
1510
1668
|
});
|
|
1511
1669
|
if (run !== validationRun || data() !== snapshot) {
|
|
@@ -1528,7 +1686,7 @@ function createDynamicFormStore(options) {
|
|
|
1528
1686
|
validationController?.abort();
|
|
1529
1687
|
validationRun += 1;
|
|
1530
1688
|
isValidating.set(false);
|
|
1531
|
-
const res = validateFields(fields, next);
|
|
1689
|
+
const res = validateFields(fields, next, undefined, validationContext);
|
|
1532
1690
|
commitSyncResult(res);
|
|
1533
1691
|
if (validateOnChange) {
|
|
1534
1692
|
errors.set(res.errors);
|
|
@@ -1549,13 +1707,24 @@ function createDynamicFormStore(options) {
|
|
|
1549
1707
|
touched.set(Object.fromEntries(collectFieldPaths(fields, data()).map((path) => [path, true])));
|
|
1550
1708
|
}
|
|
1551
1709
|
/** Clears the touched map without touching data, errors or dirty state. */
|
|
1710
|
+
function getDirtyValues() {
|
|
1711
|
+
const baseline = baselineValues();
|
|
1712
|
+
const current = data();
|
|
1713
|
+
const dirty = {};
|
|
1714
|
+
for (const key of Object.keys(current)) {
|
|
1715
|
+
if (!Object.is(current[key], baseline[key])) {
|
|
1716
|
+
dirty[key] = current[key];
|
|
1717
|
+
}
|
|
1718
|
+
}
|
|
1719
|
+
return dirty;
|
|
1720
|
+
}
|
|
1552
1721
|
function resetTouched() {
|
|
1553
1722
|
touched.set({});
|
|
1554
1723
|
}
|
|
1555
1724
|
function handleBlur(fieldName) {
|
|
1556
1725
|
setFieldTouched(fieldName, true);
|
|
1557
1726
|
if (validateOnBlur) {
|
|
1558
|
-
const res = validateFields(fields, data());
|
|
1727
|
+
const res = validateFields(fields, data(), undefined, validationContext);
|
|
1559
1728
|
errors.set(res.errors);
|
|
1560
1729
|
commitSyncResult(res);
|
|
1561
1730
|
}
|
|
@@ -1564,6 +1733,7 @@ function createDynamicFormStore(options) {
|
|
|
1564
1733
|
const seed = newValues ?? initialValues;
|
|
1565
1734
|
const next = applyComputedValues(fields, seed);
|
|
1566
1735
|
data.set(next);
|
|
1736
|
+
baselineValues.set({ ...next });
|
|
1567
1737
|
errors.set({});
|
|
1568
1738
|
isDirty.set(false);
|
|
1569
1739
|
touched.set({});
|
|
@@ -1572,7 +1742,7 @@ function createDynamicFormStore(options) {
|
|
|
1572
1742
|
validationController?.abort();
|
|
1573
1743
|
validationRun += 1;
|
|
1574
1744
|
isValidating.set(false);
|
|
1575
|
-
commitSyncResult(validateFields(fields, next));
|
|
1745
|
+
commitSyncResult(validateFields(fields, next, undefined, validationContext));
|
|
1576
1746
|
}
|
|
1577
1747
|
/**
|
|
1578
1748
|
* Returns a submit handler, mirroring the React and Vue `useDynamicForm`
|
|
@@ -1602,6 +1772,7 @@ function createDynamicFormStore(options) {
|
|
|
1602
1772
|
const snapshot = data();
|
|
1603
1773
|
isValidating.set(true);
|
|
1604
1774
|
const res = await validateFieldsAsync(fields, snapshot, snapshot, {
|
|
1775
|
+
...validationContext,
|
|
1605
1776
|
signal: controller.signal,
|
|
1606
1777
|
});
|
|
1607
1778
|
if (thisSubmit !== submitRun) {
|
|
@@ -1616,7 +1787,7 @@ function createDynamicFormStore(options) {
|
|
|
1616
1787
|
validationResult.set(res);
|
|
1617
1788
|
}
|
|
1618
1789
|
else {
|
|
1619
|
-
const live = validateFields(fields, data());
|
|
1790
|
+
const live = validateFields(fields, data(), undefined, validationContext);
|
|
1620
1791
|
errors.set(live.errors);
|
|
1621
1792
|
validationResult.set(live);
|
|
1622
1793
|
}
|
|
@@ -1644,6 +1815,8 @@ function createDynamicFormStore(options) {
|
|
|
1644
1815
|
isValidationComplete,
|
|
1645
1816
|
validationStatus,
|
|
1646
1817
|
isDirty,
|
|
1818
|
+
baselineValues,
|
|
1819
|
+
getDirtyValues,
|
|
1647
1820
|
touched,
|
|
1648
1821
|
isSubmitting,
|
|
1649
1822
|
isSubmitted,
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import * as i0 from '@angular/core';
|
|
2
2
|
import { OnChanges, ChangeDetectorRef, EventEmitter, SimpleChanges, AfterViewInit, OnDestroy, ViewContainerRef, OnInit, TemplateRef, Type, InjectionToken } from '@angular/core';
|
|
3
|
-
import { FieldTypeKey, Properties, FieldDescription, ResolvedFieldRendererProps, BaseLayout, ResponsiveLayout, ValidationResult, LayoutConfig, FieldRegistry } from '@dynamic-field-kit/core';
|
|
4
|
-
export { ColumnLayoutConfig, FieldDescription, FieldRegistry, FieldRendererProps, FieldTypeKey, GridLayoutConfig, LayoutConfig, RowLayoutConfig, ValidationContext, ValidationResult, collectFieldPaths, fieldRegistry, indexGroupPathMap, resolveDisabled, resolveOptions, resolveReadOnly, validateField, validateFieldAsync, validateFields, validateFieldsAsync, validators } from '@dynamic-field-kit/core';
|
|
3
|
+
import { OptionsStatus, FieldTypeKey, Properties, FieldDescription, ResolvedFieldRendererProps, BaseLayout, ResponsiveLayout, ValidationResult, LayoutConfig, MessageCatalog, FieldRegistry } from '@dynamic-field-kit/core';
|
|
4
|
+
export { ColumnLayoutConfig, FIELD_RENDERER_PROP_KEYS, FieldDescription, FieldRegistry, FieldRendererProps, FieldTypeKey, GridLayoutConfig, LayoutConfig, RowLayoutConfig, ValidationContext, ValidationResult, buildFieldRendererProps, collectFieldPaths, fieldRegistry, indexGroupPathMap, makeErrorId, makeFieldId, resolveDisabled, resolveOptions, resolveReadOnly, validateField, validateFieldAsync, validateFields, validateFieldsAsync, validators } from '@dynamic-field-kit/core';
|
|
5
5
|
import * as i1 from '@angular/common';
|
|
6
6
|
|
|
7
7
|
interface FieldInputProps {
|
|
@@ -15,6 +15,9 @@ interface FieldInputProps {
|
|
|
15
15
|
dirty?: boolean;
|
|
16
16
|
error?: string | string[];
|
|
17
17
|
options?: unknown[];
|
|
18
|
+
optionsStatus?: OptionsStatus;
|
|
19
|
+
optionsError?: unknown;
|
|
20
|
+
onOptionsQuery?: (query: string) => void;
|
|
18
21
|
className?: string;
|
|
19
22
|
description?: string;
|
|
20
23
|
id?: string;
|
|
@@ -45,6 +48,10 @@ declare abstract class BaseInputComponent implements OnChanges {
|
|
|
45
48
|
dirty?: boolean;
|
|
46
49
|
error?: string | string[];
|
|
47
50
|
options?: unknown[];
|
|
51
|
+
optionsStatus?: OptionsStatus;
|
|
52
|
+
optionsError?: unknown;
|
|
53
|
+
/** Renderer-driven refetch for a search-remote field. */
|
|
54
|
+
onOptionsQuery?: (query: string) => void;
|
|
48
55
|
className?: string;
|
|
49
56
|
description?: string;
|
|
50
57
|
id?: string;
|
|
@@ -62,7 +69,7 @@ declare abstract class BaseInputComponent implements OnChanges {
|
|
|
62
69
|
protected detectChanges(): void;
|
|
63
70
|
protected hasChanges(changes: SimpleChanges, prop: string): boolean;
|
|
64
71
|
static ɵfac: i0.ɵɵFactoryDeclaration<BaseInputComponent, never>;
|
|
65
|
-
static ɵcmp: i0.ɵɵComponentDeclaration<BaseInputComponent, "ng-component", never, { "value": { "alias": "value"; "required": false; }; "label": { "alias": "label"; "required": false; }; "placeholder": { "alias": "placeholder"; "required": false; }; "required": { "alias": "required"; "required": false; }; "disabled": { "alias": "disabled"; "required": false; }; "readOnly": { "alias": "readOnly"; "required": false; }; "touched": { "alias": "touched"; "required": false; }; "dirty": { "alias": "dirty"; "required": false; }; "error": { "alias": "error"; "required": false; }; "options": { "alias": "options"; "required": false; }; "className": { "alias": "className"; "required": false; }; "description": { "alias": "description"; "required": false; }; "id": { "alias": "id"; "required": false; }; "ariaInvalid": { "alias": "ariaInvalid"; "required": false; }; "ariaDescribedBy": { "alias": "ariaDescribedBy"; "required": false; }; "ariaRequired": { "alias": "ariaRequired"; "required": false; }; "min": { "alias": "min"; "required": false; }; "max": { "alias": "max"; "required": false; }; "step": { "alias": "step"; "required": false; }; "accept": { "alias": "accept"; "required": false; }; "multiple": { "alias": "multiple"; "required": false; }; }, { "valueChange": "valueChange"; }, never, never, true, never>;
|
|
72
|
+
static ɵcmp: i0.ɵɵComponentDeclaration<BaseInputComponent, "ng-component", never, { "value": { "alias": "value"; "required": false; }; "label": { "alias": "label"; "required": false; }; "placeholder": { "alias": "placeholder"; "required": false; }; "required": { "alias": "required"; "required": false; }; "disabled": { "alias": "disabled"; "required": false; }; "readOnly": { "alias": "readOnly"; "required": false; }; "touched": { "alias": "touched"; "required": false; }; "dirty": { "alias": "dirty"; "required": false; }; "error": { "alias": "error"; "required": false; }; "options": { "alias": "options"; "required": false; }; "optionsStatus": { "alias": "optionsStatus"; "required": false; }; "optionsError": { "alias": "optionsError"; "required": false; }; "onOptionsQuery": { "alias": "onOptionsQuery"; "required": false; }; "className": { "alias": "className"; "required": false; }; "description": { "alias": "description"; "required": false; }; "id": { "alias": "id"; "required": false; }; "ariaInvalid": { "alias": "ariaInvalid"; "required": false; }; "ariaDescribedBy": { "alias": "ariaDescribedBy"; "required": false; }; "ariaRequired": { "alias": "ariaRequired"; "required": false; }; "min": { "alias": "min"; "required": false; }; "max": { "alias": "max"; "required": false; }; "step": { "alias": "step"; "required": false; }; "accept": { "alias": "accept"; "required": false; }; "multiple": { "alias": "multiple"; "required": false; }; }, { "valueChange": "valueChange"; }, never, never, true, never>;
|
|
66
73
|
}
|
|
67
74
|
|
|
68
75
|
declare class DynamicInput extends BaseInputComponent implements OnChanges, AfterViewInit, OnDestroy {
|
|
@@ -83,22 +90,58 @@ declare class DynamicInput extends BaseInputComponent implements OnChanges, Afte
|
|
|
83
90
|
private cleanup;
|
|
84
91
|
private cleanupSubscriptions;
|
|
85
92
|
private cleanupRenderedComponent;
|
|
93
|
+
/** The id `ariaDescribedBy` points at. See core's `makeErrorId`. */
|
|
94
|
+
errorNodeId(): string;
|
|
95
|
+
/** `error` may arrive as a bare string, so index 0 would be a character. */
|
|
96
|
+
firstError(): string | undefined;
|
|
97
|
+
/**
|
|
98
|
+
* Whether the adapter should render the validation message itself.
|
|
99
|
+
*
|
|
100
|
+
* Asks the registry directly rather than reading a flag set by `render()`:
|
|
101
|
+
* `render()` runs in `ngAfterViewInit`, by which point this template's
|
|
102
|
+
* bindings have already been checked for the pass, and under `OnPush`
|
|
103
|
+
* nothing would mark them dirty again. A registered renderer owns its own
|
|
104
|
+
* error presentation, so only the built-in fallback gets a message here.
|
|
105
|
+
*/
|
|
106
|
+
showDefaultError(): boolean;
|
|
86
107
|
private render;
|
|
87
108
|
private isComponentType;
|
|
88
109
|
private renderComponent;
|
|
89
110
|
private renderFallback;
|
|
90
111
|
private getFallbackProps;
|
|
91
112
|
private applyProps;
|
|
113
|
+
/**
|
|
114
|
+
* Props that are callbacks rather than resolved values, so they are not in
|
|
115
|
+
* `KNOWN_PROPS` / `FIELD_RENDERER_PROP_KEYS` and the loop above never sees
|
|
116
|
+
* them. Without this a renderer declaring `onOptionsQuery` always got
|
|
117
|
+
* `undefined` and could never trigger a search-remote refetch.
|
|
118
|
+
*/
|
|
119
|
+
private applyCallbackProps;
|
|
92
120
|
private applyExtraProps;
|
|
93
121
|
private bindOutputs;
|
|
94
122
|
private emitValue;
|
|
123
|
+
/**
|
|
124
|
+
* Puts the aria flags on whatever control the HTML5 fallback just built.
|
|
125
|
+
*
|
|
126
|
+
* Applied here, once, rather than in each of the fallback's branches: they
|
|
127
|
+
* hand-build an input, textarea, checkbox or select, and every one of them
|
|
128
|
+
* would otherwise need the same four lines. Without this the fallback emits
|
|
129
|
+
* the `{id}-error` node with nothing pointing at it, and
|
|
130
|
+
* `focusFirstInvalidField` - which selects `[aria-invalid="true"]` - still
|
|
131
|
+
* finds nothing on this adapter.
|
|
132
|
+
*
|
|
133
|
+
* `render()` re-runs on every ngOnChanges while the fallback is in use (there
|
|
134
|
+
* is no component instance to sync props into), so this stays current as the
|
|
135
|
+
* field's error comes and goes.
|
|
136
|
+
*/
|
|
137
|
+
private applyFallbackAria;
|
|
95
138
|
private renderDefaultFallbackHTML5;
|
|
96
139
|
private renderError;
|
|
97
140
|
static ɵfac: i0.ɵɵFactoryDeclaration<DynamicInput, never>;
|
|
98
141
|
static ɵcmp: i0.ɵɵComponentDeclaration<DynamicInput, "dfk-dynamic-input", never, { "type": { "alias": "type"; "required": false; }; "extraProps": { "alias": "extraProps"; "required": false; }; }, { "valueChange": "valueChange"; "onChange": "onChange"; }, never, never, true, never>;
|
|
99
142
|
}
|
|
100
143
|
|
|
101
|
-
declare class FieldInput implements OnChanges {
|
|
144
|
+
declare class FieldInput implements OnChanges, OnDestroy {
|
|
102
145
|
private cdr;
|
|
103
146
|
fieldDescription?: FieldDescription;
|
|
104
147
|
/**
|
|
@@ -141,7 +184,13 @@ declare class FieldInput implements OnChanges {
|
|
|
141
184
|
*/
|
|
142
185
|
rendererProps: ResolvedFieldRendererProps | null;
|
|
143
186
|
constructor(cdr: ChangeDetectorRef);
|
|
187
|
+
private loader?;
|
|
188
|
+
private optionsState?;
|
|
189
|
+
/** Bound into the template so a renderer can drive a search-remote refetch. */
|
|
190
|
+
onOptionsQuery: (query: string) => void;
|
|
144
191
|
ngOnChanges(_changes: SimpleChanges): void;
|
|
192
|
+
ngOnDestroy(): void;
|
|
193
|
+
private syncOptionsLoader;
|
|
145
194
|
private buildProps;
|
|
146
195
|
static ɵfac: i0.ɵɵFactoryDeclaration<FieldInput, never>;
|
|
147
196
|
static ɵcmp: i0.ɵɵComponentDeclaration<FieldInput, "dfk-field-input", never, { "fieldDescription": { "alias": "fieldDescription"; "required": false; }; "data": { "alias": "data"; "required": false; }; "rootData": { "alias": "rootData"; "required": false; }; "value": { "alias": "value"; "required": false; }; "options": { "alias": "options"; "required": false; }; "disabled": { "alias": "disabled"; "required": false; }; "readOnly": { "alias": "readOnly"; "required": false; }; "error": { "alias": "error"; "required": false; }; "validationControlled": { "alias": "validationControlled"; "required": false; }; "touched": { "alias": "touched"; "required": false; }; "dirty": { "alias": "dirty"; "required": false; }; "idPrefix": { "alias": "idPrefix"; "required": false; }; }, { "onValueChangeField": "onValueChangeField"; "onBlurField": "onBlurField"; }, never, never, true, never>;
|
|
@@ -154,6 +203,13 @@ declare class MultiFieldInput implements OnInit, OnChanges {
|
|
|
154
203
|
private cdr;
|
|
155
204
|
fieldDescriptions: FieldDescription[];
|
|
156
205
|
properties?: Properties;
|
|
206
|
+
/**
|
|
207
|
+
* The values per-field `dirty` is measured against. Defaults to the first
|
|
208
|
+
* non-`undefined` `properties` this component sees. This adapter has no
|
|
209
|
+
* `form` shorthand, so pass `store.baselineValues()` here to keep `dirty`
|
|
210
|
+
* correct across `store.reset(newValues)`.
|
|
211
|
+
*/
|
|
212
|
+
initialProperties?: Properties;
|
|
157
213
|
onChange: EventEmitter<Properties>;
|
|
158
214
|
validityChange: EventEmitter<ValidationResult>;
|
|
159
215
|
/**
|
|
@@ -185,7 +241,7 @@ declare class MultiFieldInput implements OnInit, OnChanges {
|
|
|
185
241
|
idPrefix?: string;
|
|
186
242
|
private touchedFields;
|
|
187
243
|
private readonly instanceId;
|
|
188
|
-
private
|
|
244
|
+
private firstSeenProperties;
|
|
189
245
|
private indexedErrorsSource?;
|
|
190
246
|
private indexedErrors;
|
|
191
247
|
private indexedTouchedSource?;
|
|
@@ -242,7 +298,7 @@ declare class MultiFieldInput implements OnInit, OnChanges {
|
|
|
242
298
|
onGroupItemChange(field: FieldDescription, index: number, next: Properties): void;
|
|
243
299
|
private commitData;
|
|
244
300
|
static ɵfac: i0.ɵɵFactoryDeclaration<MultiFieldInput, never>;
|
|
245
|
-
static ɵcmp: i0.ɵɵComponentDeclaration<MultiFieldInput, "dfk-multi-field-input", never, { "fieldDescriptions": { "alias": "fieldDescriptions"; "required": false; }; "properties": { "alias": "properties"; "required": false; }; "touched": { "alias": "touched"; "required": false; }; "errors": { "alias": "errors"; "required": false; }; "idPrefix": { "alias": "idPrefix"; "required": false; }; "layout": { "alias": "layout"; "required": false; }; "rootData": { "alias": "rootData"; "required": false; }; }, { "onChange": "onChange"; "validityChange": "validityChange"; "onBlurField": "onBlurField"; "touchedChange": "touchedChange"; }, never, never, true, never>;
|
|
301
|
+
static ɵcmp: i0.ɵɵComponentDeclaration<MultiFieldInput, "dfk-multi-field-input", never, { "fieldDescriptions": { "alias": "fieldDescriptions"; "required": false; }; "properties": { "alias": "properties"; "required": false; }; "initialProperties": { "alias": "initialProperties"; "required": false; }; "touched": { "alias": "touched"; "required": false; }; "errors": { "alias": "errors"; "required": false; }; "idPrefix": { "alias": "idPrefix"; "required": false; }; "layout": { "alias": "layout"; "required": false; }; "rootData": { "alias": "rootData"; "required": false; }; }, { "onChange": "onChange"; "validityChange": "validityChange"; "onBlurField": "onBlurField"; "touchedChange": "touchedChange"; }, never, never, true, never>;
|
|
246
302
|
}
|
|
247
303
|
|
|
248
304
|
declare class DynamicFormDevToolsComponent {
|
|
@@ -265,6 +321,13 @@ interface DynamicFormOptions {
|
|
|
265
321
|
initialValues?: Properties;
|
|
266
322
|
validateOnBlur?: boolean;
|
|
267
323
|
validateOnChange?: boolean;
|
|
324
|
+
/**
|
|
325
|
+
* Messages for the built-in validators, set once for the whole form instead
|
|
326
|
+
* of per field. A message passed directly to a validator still wins, and any
|
|
327
|
+
* key omitted here falls back to the validator's English default. See core's
|
|
328
|
+
* `MessageCatalog`.
|
|
329
|
+
*/
|
|
330
|
+
messages?: MessageCatalog;
|
|
268
331
|
}
|
|
269
332
|
declare function createDynamicFormStore(options: DynamicFormOptions): {
|
|
270
333
|
data: i0.WritableSignal<Properties>;
|
|
@@ -274,6 +337,8 @@ declare function createDynamicFormStore(options: DynamicFormOptions): {
|
|
|
274
337
|
isValidationComplete: i0.Signal<boolean>;
|
|
275
338
|
validationStatus: i0.Signal<"invalid" | "valid" | "pending">;
|
|
276
339
|
isDirty: i0.WritableSignal<boolean>;
|
|
340
|
+
baselineValues: i0.WritableSignal<Properties>;
|
|
341
|
+
getDirtyValues: () => Properties;
|
|
277
342
|
touched: i0.WritableSignal<Record<string, boolean>>;
|
|
278
343
|
isSubmitting: i0.WritableSignal<boolean>;
|
|
279
344
|
isSubmitted: i0.WritableSignal<boolean>;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dynamic-field-kit/angular",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.7.0",
|
|
4
4
|
"description": "Angular renderer for dynamic-field-kit",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"private": false,
|
|
@@ -54,7 +54,7 @@
|
|
|
54
54
|
"@angular/core": "^21.2.0",
|
|
55
55
|
"@angular/forms": "^21.2.0",
|
|
56
56
|
"@angular/platform-browser": "^21.2.0",
|
|
57
|
-
"@dynamic-field-kit/core": "^1.
|
|
57
|
+
"@dynamic-field-kit/core": "^1.7.0",
|
|
58
58
|
"@vitest/coverage-istanbul": "^4.1.11",
|
|
59
59
|
"jsdom": "^29.1.1",
|
|
60
60
|
"ng-packagr": "^21.2.0",
|