@dynamic-field-kit/angular 1.10.0 → 1.11.1
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 +21 -0
- package/README.md +10 -3
- package/dist/fesm2022/dynamic-field-kit-angular.mjs +415 -116
- package/dist/types/dynamic-field-kit-angular.d.ts +46 -2
- package/package.json +6 -6
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,26 @@
|
|
|
1
1
|
# @dynamic-field-kit/angular
|
|
2
2
|
|
|
3
|
+
## 1.11.1
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- ba3538b: Angular 22 is accepted: `@angular/core` and `@angular/common` are now `>=16 <23`. An app on Angular 22 could not install the package before, as npm refused the peer range. `scripts/verify-angular-peer-range.js` now checks Angular 16 and 22, and the demo app was built with the Angular 22 CLI and used in a browser.
|
|
8
|
+
- ba3538b: A registered field component that uses `OnPush` change detection now shows the props the form hands it after the first render. They were assigned to the component without marking it for check, so a value the form set itself, such as a computed field, did not show. From Angular 22 every component that names no strategy is `OnPush`, so on 22 this hit every registered field component.
|
|
9
|
+
- Angular 22 support.
|
|
10
|
+
|
|
11
|
+
## 1.11.0
|
|
12
|
+
|
|
13
|
+
### Minor Changes
|
|
14
|
+
|
|
15
|
+
- Input masks, titled sections and errors from the server; the built-in controls of the Vue and Angular adapters keep the focus and stay one element.
|
|
16
|
+
- b7ffb5c: Input masks. A field takes `mask`: a pattern such as `'(999) 999-9999'` (`9` a digit, `a` a letter, `*` either, anything else fixed) or a `{ format, parse }` pair. The renderer receives the formatted text as `value`, the form stores the unformatted value (`'5551234567'`), and validators, `computeValue`, drafts and `onSubmit` see that stored value. A separator appears with the character after it, a character that does not fit is neither stored nor left on screen, and Backspace on a separator removes the character before it. Renderers need no change. Core gains `createInputMask(pattern)` for the same formatting outside a form, the `InputMask` and `FieldMask` types, and `applyMask` / `parseInput` on `buildFieldRendererProps` for adapters; a mask takes effect when both core and the adapter are 1.11 or newer.
|
|
17
|
+
- 4326470: Sections. A field with `section: FieldDescription[]` is a titled section: `data[name]` is one object shaped by the nested fields, the non-repeating sibling of a group. Every adapter's `MultiFieldInput` renders it as a `<fieldset>` with `label` as its `<legend>`. Errors, pending rules and touched flags of the fields inside are keyed by path (`address.street`), `appearCondition` and `disabled` / `disabledCondition` on the section apply to all of it, and sections nest in groups and in each other. `validateFields`, `validateFieldsAsync` and `collectFieldPaths` look inside sections; `fieldsFromJsonSchema` now turns a nested object into a section instead of reporting it, and `fieldsToJsonSchema` writes a section as a nested object. Core gains `isFieldSection` and `sectionPathMap`. Sections need core and the adapter at 1.11 or newer.
|
|
18
|
+
- 5eaa3ca: Errors from the server. Every form store (`useDynamicForm` in React and Vue, `createDynamicForm` in Svelte, `createDynamicFormStore` in Angular) gains `setServerErrors(errors)`, `clearServerErrors(name?)` and `serverErrors`: what a backend refused is shown in `errors` after a field's own errors, its fields are marked touched, and an error stays until the value it was about changes, until it is cleared or the form is reset, or until the next submit. `isValid` keeps speaking for the form's own rules. Core gains `serverErrorsFrom(body, { paths })`, which reads the usual response shapes (a field map, `errors` as a map or a list, Zod's `issues` and flattened error, JSON Pointers) into that map, and `FORM_ERROR_KEY` for errors that name no field.
|
|
19
|
+
|
|
20
|
+
### Patch Changes
|
|
21
|
+
|
|
22
|
+
- 673fce2: The built-in controls, used when no renderer is registered for a field type, are no longer drawn again on every change. Each change used to add a new input, textarea, select or checkbox to the page and leave the earlier ones there, so a form filled in with the built-in controls ended up with several copies of each field; the same happened to the HTML of a function renderer and to the "Unknown field type" message. A built-in control is now updated where it stands: it stays one element, keeps the focus while it is typed in, and a number that is still being typed (`1.50`) is not rewritten under the caret. Its `aria-describedby` is taken off again when the error it pointed at is gone.
|
|
23
|
+
|
|
3
24
|
## 1.10.0
|
|
4
25
|
|
|
5
26
|
### Minor Changes
|
package/README.md
CHANGED
|
@@ -86,16 +86,23 @@ For a complete UI integration, see the
|
|
|
86
86
|
|
|
87
87
|
## Supported Angular versions
|
|
88
88
|
|
|
89
|
-
The package declares `@angular/core` and `@angular/common` as `>=16 <
|
|
89
|
+
The package declares `@angular/core` and `@angular/common` as `>=16 <23`,
|
|
90
90
|
and `scripts/verify-angular-peer-range.js` proves both ends in CI by installing
|
|
91
|
-
the packed tarballs against Angular 16 and
|
|
91
|
+
the packed tarballs against Angular 16 and 22 outside the workspace.
|
|
92
92
|
|
|
93
93
|
The floor is 16 because the form store is built on `signal` and `computed`,
|
|
94
94
|
which Angular introduced in 16. It read `>=14` until 1.6.0: npm accepted the
|
|
95
95
|
install on 14 and 15 and the package then failed on import.
|
|
96
96
|
|
|
97
97
|
The suite, the build and the demo app all run Angular 21, which is the version
|
|
98
|
-
the setup below is written for.
|
|
98
|
+
the setup below is written for. The setup works unchanged on 22.
|
|
99
|
+
|
|
100
|
+
Angular 22 makes `OnPush` the default for a component that names no change
|
|
101
|
+
detection strategy, and that includes the field components you register. They
|
|
102
|
+
need nothing extra for it: the adapter marks a rendered field component for
|
|
103
|
+
check whenever it hands it new props. Up to 1.11.0 it did not, so on Angular 22
|
|
104
|
+
a value the form set itself, such as a computed field, did not show in a
|
|
105
|
+
registered component.
|
|
99
106
|
|
|
100
107
|
## Basic setup
|
|
101
108
|
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import * as i0 from '@angular/core';
|
|
2
|
-
import { EventEmitter, Output, Input, Component, InjectionToken, inject, ViewContainerRef, ViewChild, ChangeDetectionStrategy, HostListener, signal, computed, NgModule } from '@angular/core';
|
|
2
|
+
import { EventEmitter, Output, Input, Component, InjectionToken, inject, ChangeDetectorRef, 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
5
|
import { fieldRegistry, makeErrorId, isAsyncOptions, createOptionsLoader, buildFieldRendererProps, makeFieldId, indexGroupPathMap, applyComputedValues, validateFields, canAddGroupItem, canRemoveGroupItem, createGroupItem, createMessageResolver, validateFieldsAsync, collectFieldPaths } from '@dynamic-field-kit/core';
|
|
@@ -162,6 +162,11 @@ class DynamicInput extends BaseInputComponent {
|
|
|
162
162
|
host;
|
|
163
163
|
compRef;
|
|
164
164
|
inputInstance;
|
|
165
|
+
// The built-in control in use, the type it was built for and the options it lists. Kept so that a
|
|
166
|
+
// change updates the control where it stands.
|
|
167
|
+
builtIn;
|
|
168
|
+
builtInType;
|
|
169
|
+
builtInOptions;
|
|
165
170
|
subscriptions = [];
|
|
166
171
|
// Tracks which KNOWN_PROPS were actually supplied to DynamicInput (via
|
|
167
172
|
// SimpleChanges), independent of TS class-field emit. See applyProps.
|
|
@@ -204,6 +209,12 @@ class DynamicInput extends BaseInputComponent {
|
|
|
204
209
|
if (changes['extraProps']) {
|
|
205
210
|
this.applyExtraProps(this.inputInstance);
|
|
206
211
|
}
|
|
212
|
+
// The props above are assigned to the instance, which Angular does not
|
|
213
|
+
// see. Checking the host view alone skips an OnPush renderer that is not
|
|
214
|
+
// marked, and from Angular 22 every renderer that names no strategy is
|
|
215
|
+
// OnPush: a value the form set itself (a computed field, a reset) never
|
|
216
|
+
// showed. The renderer's own view is marked first.
|
|
217
|
+
this.compRef?.injector.get(ChangeDetectorRef).markForCheck();
|
|
207
218
|
this.compRef?.changeDetectorRef?.detectChanges();
|
|
208
219
|
}
|
|
209
220
|
cleanup() {
|
|
@@ -219,6 +230,21 @@ class DynamicInput extends BaseInputComponent {
|
|
|
219
230
|
this.compRef = undefined;
|
|
220
231
|
this.inputInstance = undefined;
|
|
221
232
|
}
|
|
233
|
+
/**
|
|
234
|
+
* Removes what this component put into the host element itself: a built-in
|
|
235
|
+
* control, the HTML of a function renderer, an error message. `host.clear()`
|
|
236
|
+
* does not: it only knows the components it created, which sit beside the
|
|
237
|
+
* host element, not inside it.
|
|
238
|
+
*/
|
|
239
|
+
clearOwnNodes() {
|
|
240
|
+
const element = this.host.element.nativeElement;
|
|
241
|
+
while (element.firstChild) {
|
|
242
|
+
element.removeChild(element.firstChild);
|
|
243
|
+
}
|
|
244
|
+
this.builtIn = undefined;
|
|
245
|
+
this.builtInType = undefined;
|
|
246
|
+
this.builtInOptions = undefined;
|
|
247
|
+
}
|
|
222
248
|
/** The id `ariaDescribedBy` points at. See core's `makeErrorId`. */
|
|
223
249
|
errorNodeId() {
|
|
224
250
|
return makeErrorId(this.id ?? '');
|
|
@@ -241,8 +267,17 @@ class DynamicInput extends BaseInputComponent {
|
|
|
241
267
|
}
|
|
242
268
|
render() {
|
|
243
269
|
const Renderer = this.registry.get(this.type);
|
|
270
|
+
// A built-in control of the same type is updated where it stands. Built
|
|
271
|
+
// again on every change, as it used to be, it would lose the focus after
|
|
272
|
+
// each character typed - and the ones built before stayed in the page.
|
|
273
|
+
if (!Renderer && this.builtIn && this.builtInType === this.type) {
|
|
274
|
+
this.syncBuiltIn(this.builtIn);
|
|
275
|
+
this.applyFallbackAria();
|
|
276
|
+
return;
|
|
277
|
+
}
|
|
244
278
|
this.cleanup();
|
|
245
279
|
this.host.clear();
|
|
280
|
+
this.clearOwnNodes();
|
|
246
281
|
if (!Renderer) {
|
|
247
282
|
if (this.renderDefaultFallbackHTML5(this.type)) {
|
|
248
283
|
this.applyFallbackAria();
|
|
@@ -411,146 +446,155 @@ class DynamicInput extends BaseInputComponent {
|
|
|
411
446
|
*
|
|
412
447
|
* `render()` re-runs on every ngOnChanges while the fallback is in use (there
|
|
413
448
|
* is no component instance to sync props into), so this stays current as the
|
|
414
|
-
* field's error comes and goes.
|
|
449
|
+
* field's error comes and goes. The control is the same one from change to
|
|
450
|
+
* change, so a flag that is no longer given is taken off again.
|
|
415
451
|
*/
|
|
416
452
|
applyFallbackAria() {
|
|
417
453
|
const control = this.host.element.nativeElement.querySelector('input, select, textarea');
|
|
418
454
|
if (!control) {
|
|
419
455
|
return;
|
|
420
456
|
}
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
457
|
+
const flag = (name, value) => {
|
|
458
|
+
if (value === undefined) {
|
|
459
|
+
control.removeAttribute(name);
|
|
460
|
+
}
|
|
461
|
+
else {
|
|
462
|
+
control.setAttribute(name, value);
|
|
463
|
+
}
|
|
464
|
+
};
|
|
465
|
+
flag('aria-invalid', this.ariaInvalid === undefined ? undefined : String(this.ariaInvalid));
|
|
466
|
+
flag('aria-required', this.ariaRequired === undefined ? undefined : String(this.ariaRequired));
|
|
467
|
+
flag('aria-describedby', this.ariaDescribedBy || undefined);
|
|
430
468
|
}
|
|
469
|
+
/**
|
|
470
|
+
* Builds the built-in control for `type` and puts it in the host element.
|
|
471
|
+
* What the control shows is set by `syncBuiltIn`, here and on every later
|
|
472
|
+
* change.
|
|
473
|
+
*/
|
|
431
474
|
renderDefaultFallbackHTML5(type) {
|
|
432
|
-
const
|
|
475
|
+
const control = this.createBuiltIn(type);
|
|
476
|
+
if (!control) {
|
|
477
|
+
return false;
|
|
478
|
+
}
|
|
479
|
+
this.builtIn = control;
|
|
480
|
+
this.builtInType = type;
|
|
481
|
+
this.syncBuiltIn(control);
|
|
482
|
+
this.host.element.nativeElement.appendChild(control);
|
|
483
|
+
return true;
|
|
484
|
+
}
|
|
485
|
+
createBuiltIn(type) {
|
|
433
486
|
if (type === 'text' ||
|
|
434
487
|
type === 'password' ||
|
|
435
488
|
type === 'email' ||
|
|
436
489
|
type === 'number') {
|
|
437
490
|
const input = document.createElement('input');
|
|
438
491
|
input.type = type;
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
}
|
|
442
|
-
if (this.className) {
|
|
443
|
-
input.className = this.className;
|
|
444
|
-
}
|
|
445
|
-
if (this.placeholder) {
|
|
446
|
-
input.placeholder = this.placeholder;
|
|
447
|
-
}
|
|
448
|
-
if (this.disabled) {
|
|
449
|
-
input.disabled = true;
|
|
450
|
-
}
|
|
451
|
-
if (this.readOnly) {
|
|
452
|
-
input.readOnly = true;
|
|
453
|
-
}
|
|
454
|
-
if (this.required) {
|
|
455
|
-
input.required = true;
|
|
456
|
-
}
|
|
457
|
-
input.value = (this.value ?? '').toString();
|
|
458
|
-
input.addEventListener('input', (e) => {
|
|
459
|
-
const raw = e.target.value;
|
|
492
|
+
input.addEventListener('input', () => {
|
|
493
|
+
const raw = input.value;
|
|
460
494
|
const val = type === 'number' ? (raw === '' ? undefined : Number(raw)) : raw;
|
|
461
495
|
this.emitValue(val);
|
|
462
496
|
});
|
|
463
|
-
|
|
464
|
-
return true;
|
|
497
|
+
return input;
|
|
465
498
|
}
|
|
466
499
|
if (type === 'textarea') {
|
|
467
500
|
const textarea = document.createElement('textarea');
|
|
468
|
-
|
|
469
|
-
textarea.
|
|
470
|
-
}
|
|
471
|
-
if (this.className) {
|
|
472
|
-
textarea.className = this.className;
|
|
473
|
-
}
|
|
474
|
-
if (this.placeholder) {
|
|
475
|
-
textarea.placeholder = this.placeholder;
|
|
476
|
-
}
|
|
477
|
-
if (this.disabled) {
|
|
478
|
-
textarea.disabled = true;
|
|
479
|
-
}
|
|
480
|
-
if (this.readOnly) {
|
|
481
|
-
textarea.readOnly = true;
|
|
482
|
-
}
|
|
483
|
-
if (this.required) {
|
|
484
|
-
textarea.required = true;
|
|
485
|
-
}
|
|
486
|
-
textarea.value = this.value ?? '';
|
|
487
|
-
textarea.addEventListener('input', (e) => {
|
|
488
|
-
this.emitValue(e.target.value);
|
|
501
|
+
textarea.addEventListener('input', () => {
|
|
502
|
+
this.emitValue(textarea.value);
|
|
489
503
|
});
|
|
490
|
-
|
|
491
|
-
return true;
|
|
504
|
+
return textarea;
|
|
492
505
|
}
|
|
493
506
|
if (type === 'checkbox') {
|
|
494
507
|
const checkbox = document.createElement('input');
|
|
495
508
|
checkbox.type = 'checkbox';
|
|
496
|
-
|
|
497
|
-
checkbox.
|
|
498
|
-
}
|
|
499
|
-
if (this.className) {
|
|
500
|
-
checkbox.className = this.className;
|
|
501
|
-
}
|
|
502
|
-
if (this.disabled || this.readOnly) {
|
|
503
|
-
checkbox.disabled = true;
|
|
504
|
-
}
|
|
505
|
-
if (this.required) {
|
|
506
|
-
checkbox.required = true;
|
|
507
|
-
}
|
|
508
|
-
checkbox.checked = Boolean(this.value);
|
|
509
|
-
checkbox.addEventListener('change', (e) => {
|
|
510
|
-
this.emitValue(e.target.checked);
|
|
509
|
+
checkbox.addEventListener('change', () => {
|
|
510
|
+
this.emitValue(checkbox.checked);
|
|
511
511
|
});
|
|
512
|
-
|
|
513
|
-
return true;
|
|
512
|
+
return checkbox;
|
|
514
513
|
}
|
|
515
514
|
if (type === 'select') {
|
|
516
515
|
const select = document.createElement('select');
|
|
517
|
-
|
|
518
|
-
select.
|
|
519
|
-
}
|
|
520
|
-
|
|
521
|
-
|
|
516
|
+
select.addEventListener('change', () => {
|
|
517
|
+
this.emitValue(select.value);
|
|
518
|
+
});
|
|
519
|
+
return select;
|
|
520
|
+
}
|
|
521
|
+
return null;
|
|
522
|
+
}
|
|
523
|
+
/** Brings a built-in control in line with the current inputs. */
|
|
524
|
+
syncBuiltIn(control) {
|
|
525
|
+
const attribute = (name, value) => {
|
|
526
|
+
if (value) {
|
|
527
|
+
control.setAttribute(name, value);
|
|
522
528
|
}
|
|
523
|
-
|
|
524
|
-
|
|
529
|
+
else {
|
|
530
|
+
control.removeAttribute(name);
|
|
525
531
|
}
|
|
526
|
-
|
|
527
|
-
|
|
532
|
+
};
|
|
533
|
+
attribute('id', this.id);
|
|
534
|
+
attribute('class', this.className);
|
|
535
|
+
control.required = Boolean(this.required);
|
|
536
|
+
if (control instanceof HTMLSelectElement) {
|
|
537
|
+
// a select has no read-only state of its own
|
|
538
|
+
control.disabled = Boolean(this.disabled || this.readOnly);
|
|
539
|
+
this.syncBuiltInOptions(control);
|
|
540
|
+
return;
|
|
541
|
+
}
|
|
542
|
+
if (control instanceof HTMLInputElement && control.type === 'checkbox') {
|
|
543
|
+
// nor has a checkbox
|
|
544
|
+
control.disabled = Boolean(this.disabled || this.readOnly);
|
|
545
|
+
control.checked = Boolean(this.value);
|
|
546
|
+
return;
|
|
547
|
+
}
|
|
548
|
+
attribute('placeholder', this.placeholder);
|
|
549
|
+
control.disabled = Boolean(this.disabled);
|
|
550
|
+
control.readOnly = Boolean(this.readOnly);
|
|
551
|
+
const next = (this.value ?? '').toString();
|
|
552
|
+
// Written only when it says something else than the control already
|
|
553
|
+
// does: writing the text a user is in the middle of typing moves the
|
|
554
|
+
// caret to its end. For a number, "the same" is the same number: "1.50"
|
|
555
|
+
// is 1.5, and must not be rewritten to "1.5" under the caret.
|
|
556
|
+
const same = control.type === 'number' && control.value !== '' && next !== ''
|
|
557
|
+
? Number(control.value) === Number(next)
|
|
558
|
+
: control.value === next;
|
|
559
|
+
if (!same) {
|
|
560
|
+
control.value = next;
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
/** The options of a built-in select, rebuilt only when the list changed, and the one that is chosen. */
|
|
564
|
+
syncBuiltInOptions(select) {
|
|
565
|
+
const options = (this.options || []).map((opt) => {
|
|
566
|
+
const optObj = opt;
|
|
567
|
+
const optVal = optObj['value'] ?? optObj['id'] ?? opt;
|
|
568
|
+
const optLabel = optObj['label'] ?? optObj['name'] ?? String(optVal);
|
|
569
|
+
return { value: String(optVal), label: String(optLabel) };
|
|
570
|
+
});
|
|
571
|
+
const listed = JSON.stringify(options);
|
|
572
|
+
if (listed !== this.builtInOptions) {
|
|
573
|
+
this.builtInOptions = listed;
|
|
574
|
+
while (select.firstChild) {
|
|
575
|
+
select.removeChild(select.firstChild);
|
|
528
576
|
}
|
|
529
577
|
const defaultOpt = document.createElement('option');
|
|
530
578
|
defaultOpt.value = '';
|
|
531
579
|
defaultOpt.disabled = true;
|
|
532
580
|
defaultOpt.textContent = '-- Select --';
|
|
533
581
|
select.appendChild(defaultOpt);
|
|
534
|
-
const
|
|
535
|
-
options.forEach((opt) => {
|
|
536
|
-
const optObj = opt;
|
|
537
|
-
const optVal = optObj['value'] ?? optObj['id'] ?? opt;
|
|
538
|
-
const optLabel = optObj['label'] ?? optObj['name'] ?? String(optVal);
|
|
582
|
+
for (const option of options) {
|
|
539
583
|
const optionEl = document.createElement('option');
|
|
540
|
-
optionEl.value =
|
|
541
|
-
optionEl.textContent =
|
|
542
|
-
if (String(optVal) === String(this.value)) {
|
|
543
|
-
optionEl.selected = true;
|
|
544
|
-
}
|
|
584
|
+
optionEl.value = option.value;
|
|
585
|
+
optionEl.textContent = option.label;
|
|
545
586
|
select.appendChild(optionEl);
|
|
546
|
-
}
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
const wanted = String(this.value);
|
|
590
|
+
const all = Array.from(select.options);
|
|
591
|
+
// Without a match the first option that can be chosen is shown, which is
|
|
592
|
+
// what a select built from scratch shows.
|
|
593
|
+
const chosen = all.find((option) => !option.disabled && option.value === wanted) ??
|
|
594
|
+
all.find((option) => !option.disabled);
|
|
595
|
+
if (chosen && !chosen.selected) {
|
|
596
|
+
chosen.selected = true;
|
|
552
597
|
}
|
|
553
|
-
return false;
|
|
554
598
|
}
|
|
555
599
|
renderError(message) {
|
|
556
600
|
const el = document.createElement('div');
|
|
@@ -650,6 +694,33 @@ class FieldInput {
|
|
|
650
694
|
onOptionsQuery = (query) => {
|
|
651
695
|
this.loader?.setQuery(query);
|
|
652
696
|
};
|
|
697
|
+
/**
|
|
698
|
+
* A masked field shows formatted text; `parseInput` turns what its renderer
|
|
699
|
+
* reports back into the value to store.
|
|
700
|
+
*/
|
|
701
|
+
handleValueChange(input) {
|
|
702
|
+
const props = this.rendererProps;
|
|
703
|
+
const parseInput = props?.parseInput;
|
|
704
|
+
if (props &&
|
|
705
|
+
parseInput &&
|
|
706
|
+
typeof input === 'string' &&
|
|
707
|
+
input !== props.value) {
|
|
708
|
+
// The text on screen is no longer the text this field last handed down,
|
|
709
|
+
// and the text to show next may well be that same one: the mask takes
|
|
710
|
+
// nothing from a letter where a digit belongs, or from a character past
|
|
711
|
+
// its last place. A binding that does not change writes nothing, so the
|
|
712
|
+
// refused character would stay on screen. Tell the renderer what it
|
|
713
|
+
// holds now; whatever is shown next is then a change.
|
|
714
|
+
this.rendererProps = { ...props, value: input };
|
|
715
|
+
this.cdr.detectChanges();
|
|
716
|
+
this.rendererProps = props;
|
|
717
|
+
this.cdr.markForCheck();
|
|
718
|
+
}
|
|
719
|
+
this.onValueChangeField.emit({
|
|
720
|
+
value: parseInput ? parseInput(input) : input,
|
|
721
|
+
key: this.fieldDescription.name,
|
|
722
|
+
});
|
|
723
|
+
}
|
|
653
724
|
ngOnChanges(_changes) {
|
|
654
725
|
this.syncOptionsLoader();
|
|
655
726
|
this.rendererProps = this.buildProps();
|
|
@@ -696,6 +767,7 @@ class FieldInput {
|
|
|
696
767
|
touched: this.touched,
|
|
697
768
|
dirty: this.dirty,
|
|
698
769
|
optionsState: this.optionsState,
|
|
770
|
+
applyMask: true,
|
|
699
771
|
});
|
|
700
772
|
// Explicitly bound inputs override what the field description resolves to,
|
|
701
773
|
// so a host can still drive options/disabled/error itself.
|
|
@@ -739,9 +811,7 @@ class FieldInput {
|
|
|
739
811
|
[step]="p.step"
|
|
740
812
|
[accept]="p.accept"
|
|
741
813
|
[multiple]="p.multiple"
|
|
742
|
-
(valueChange)="
|
|
743
|
-
onValueChangeField.emit({ value: $event, key: fieldDescription!.name })
|
|
744
|
-
"
|
|
814
|
+
(valueChange)="handleValueChange($event)"
|
|
745
815
|
(focusout)="onBlurField.emit(fieldDescription!.name)"
|
|
746
816
|
[extraProps]="p.extraProps"
|
|
747
817
|
[onOptionsQuery]="onOptionsQuery"
|
|
@@ -782,9 +852,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.22", ngImpo
|
|
|
782
852
|
[step]="p.step"
|
|
783
853
|
[accept]="p.accept"
|
|
784
854
|
[multiple]="p.multiple"
|
|
785
|
-
(valueChange)="
|
|
786
|
-
onValueChangeField.emit({ value: $event, key: fieldDescription!.name })
|
|
787
|
-
"
|
|
855
|
+
(valueChange)="handleValueChange($event)"
|
|
788
856
|
(focusout)="onBlurField.emit(fieldDescription!.name)"
|
|
789
857
|
[extraProps]="p.extraProps"
|
|
790
858
|
[onOptionsQuery]="onOptionsQuery"
|
|
@@ -821,6 +889,50 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.22", ngImpo
|
|
|
821
889
|
type: Output
|
|
822
890
|
}] } });
|
|
823
891
|
|
|
892
|
+
const NO_VALUES = Object.freeze({});
|
|
893
|
+
/**
|
|
894
|
+
* The fields of a titled section, or undefined for any other field. A field
|
|
895
|
+
* that also has `fields` is a repeatable group.
|
|
896
|
+
*/
|
|
897
|
+
function sectionFieldsOf(field) {
|
|
898
|
+
return Array.isArray(field.section) && !Array.isArray(field.fields)
|
|
899
|
+
? field.section
|
|
900
|
+
: undefined;
|
|
901
|
+
}
|
|
902
|
+
/** The object a section stores, or an empty one that keeps its identity. */
|
|
903
|
+
function sectionValuesOf(data, name) {
|
|
904
|
+
const value = data[name];
|
|
905
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
906
|
+
? value
|
|
907
|
+
: NO_VALUES;
|
|
908
|
+
}
|
|
909
|
+
/**
|
|
910
|
+
* The entries of one section, keyed from inside it: `address.street` becomes
|
|
911
|
+
* `street`. An absent map stays absent, which a nested form reads as
|
|
912
|
+
* "uncontrolled".
|
|
913
|
+
*/
|
|
914
|
+
function sectionPathMap(source, sectionName) {
|
|
915
|
+
if (source === undefined) {
|
|
916
|
+
return undefined;
|
|
917
|
+
}
|
|
918
|
+
const scoped = {};
|
|
919
|
+
const prefix = `${sectionName}.`;
|
|
920
|
+
for (const [path, value] of Object.entries(source)) {
|
|
921
|
+
if (path.startsWith(prefix) && path.length > prefix.length) {
|
|
922
|
+
scoped[path.slice(prefix.length)] = value;
|
|
923
|
+
}
|
|
924
|
+
}
|
|
925
|
+
return scoped;
|
|
926
|
+
}
|
|
927
|
+
/**
|
|
928
|
+
* Whether a section is disabled, by its flag or by its condition: the test
|
|
929
|
+
* validation applies before it skips the section.
|
|
930
|
+
*/
|
|
931
|
+
function isSectionDisabled(field, data, rootData) {
|
|
932
|
+
return (field.disabled === true ||
|
|
933
|
+
field.disabledCondition?.(data, rootData) === true);
|
|
934
|
+
}
|
|
935
|
+
|
|
824
936
|
const DEFAULT_BREAKPOINT = 768;
|
|
825
937
|
// Per-instance id counter, so two forms rendering the same field name emit
|
|
826
938
|
// different DOM ids.
|
|
@@ -886,6 +998,10 @@ class MultiFieldInput {
|
|
|
886
998
|
indexedErrors = new Map();
|
|
887
999
|
indexedTouchedSource;
|
|
888
1000
|
indexedTouched = new Map();
|
|
1001
|
+
sectionErrorsSource;
|
|
1002
|
+
sectionErrors = new Map();
|
|
1003
|
+
sectionTouchedSource;
|
|
1004
|
+
sectionTouched = new Map();
|
|
889
1005
|
get effectiveIdPrefix() {
|
|
890
1006
|
return this.idPrefix ?? `dfk-${this.instanceId}`;
|
|
891
1007
|
}
|
|
@@ -944,6 +1060,41 @@ class MultiFieldInput {
|
|
|
944
1060
|
handleGroupBlur(fieldName, index, key) {
|
|
945
1061
|
this.handleBlurField(`${fieldName}[${index}].${key}`);
|
|
946
1062
|
}
|
|
1063
|
+
/** The fields of a titled section, or undefined for any other field. */
|
|
1064
|
+
sectionOf(field) {
|
|
1065
|
+
return sectionFieldsOf(field);
|
|
1066
|
+
}
|
|
1067
|
+
sectionValues(fieldName) {
|
|
1068
|
+
return sectionValuesOf(this.data, fieldName);
|
|
1069
|
+
}
|
|
1070
|
+
isSectionDisabled(field) {
|
|
1071
|
+
return isSectionDisabled(field, this.data, this.rootData ?? this.data);
|
|
1072
|
+
}
|
|
1073
|
+
// Cached per source map for the reason given at `touchedForItem`: a fresh
|
|
1074
|
+
// object in a template binding is a new identity on every pass.
|
|
1075
|
+
errorsForSection(fieldName) {
|
|
1076
|
+
if (this.sectionErrorsSource !== this.errors) {
|
|
1077
|
+
this.sectionErrorsSource = this.errors;
|
|
1078
|
+
this.sectionErrors.clear();
|
|
1079
|
+
}
|
|
1080
|
+
if (!this.sectionErrors.has(fieldName)) {
|
|
1081
|
+
this.sectionErrors.set(fieldName, sectionPathMap(this.errors, fieldName));
|
|
1082
|
+
}
|
|
1083
|
+
return this.sectionErrors.get(fieldName);
|
|
1084
|
+
}
|
|
1085
|
+
touchedForSection(fieldName) {
|
|
1086
|
+
const current = this.effectiveTouched;
|
|
1087
|
+
if (this.sectionTouchedSource !== current) {
|
|
1088
|
+
this.sectionTouchedSource = current;
|
|
1089
|
+
this.sectionTouched.clear();
|
|
1090
|
+
}
|
|
1091
|
+
let scoped = this.sectionTouched.get(fieldName);
|
|
1092
|
+
if (!scoped) {
|
|
1093
|
+
scoped = sectionPathMap(current, fieldName) ?? EMPTY_TOUCHED;
|
|
1094
|
+
this.sectionTouched.set(fieldName, scoped);
|
|
1095
|
+
}
|
|
1096
|
+
return scoped;
|
|
1097
|
+
}
|
|
947
1098
|
/**
|
|
948
1099
|
* Clears the internally tracked touched state. Only meaningful in
|
|
949
1100
|
* uncontrolled mode - when `touched` is bound, resetting the form store
|
|
@@ -1125,7 +1276,7 @@ class MultiFieldInput {
|
|
|
1125
1276
|
>
|
|
1126
1277
|
<ng-container *ngFor="let field of visibleFields; trackBy: trackByFn">
|
|
1127
1278
|
<dfk-field-input
|
|
1128
|
-
*ngIf="!field.fields"
|
|
1279
|
+
*ngIf="!field.fields && !sectionOf(field)"
|
|
1129
1280
|
[fieldDescription]="field"
|
|
1130
1281
|
[data]="data"
|
|
1131
1282
|
[rootData]="rootData"
|
|
@@ -1138,6 +1289,29 @@ class MultiFieldInput {
|
|
|
1138
1289
|
(onBlurField)="handleBlurField($event)"
|
|
1139
1290
|
></dfk-field-input>
|
|
1140
1291
|
|
|
1292
|
+
<!--
|
|
1293
|
+
A titled section: one nested form over the object data[name], with
|
|
1294
|
+
the errors, touched flags and blur of its fields keyed
|
|
1295
|
+
name.childName. A fieldset names the group through its legend and
|
|
1296
|
+
disables every control inside it natively.
|
|
1297
|
+
-->
|
|
1298
|
+
<fieldset
|
|
1299
|
+
*ngIf="!field.fields && sectionOf(field) as section"
|
|
1300
|
+
[class]="field.className"
|
|
1301
|
+
[disabled]="isSectionDisabled(field)"
|
|
1302
|
+
>
|
|
1303
|
+
<legend *ngIf="field.label">{{ field.label }}</legend>
|
|
1304
|
+
<dfk-multi-field-input
|
|
1305
|
+
[fieldDescriptions]="section"
|
|
1306
|
+
[properties]="sectionValues(field.name)"
|
|
1307
|
+
[rootData]="rootData ?? data"
|
|
1308
|
+
[errors]="errorsForSection(field.name)"
|
|
1309
|
+
[touched]="touchedForSection(field.name)"
|
|
1310
|
+
(onBlurField)="handleBlurField(field.name + '.' + $event)"
|
|
1311
|
+
(onChange)="onFieldChange({ value: $event, key: field.name })"
|
|
1312
|
+
></dfk-multi-field-input>
|
|
1313
|
+
</fieldset>
|
|
1314
|
+
|
|
1141
1315
|
<div *ngIf="field.fields" [class]="field.className">
|
|
1142
1316
|
<div *ngIf="field.label">{{ field.label }}</div>
|
|
1143
1317
|
<div
|
|
@@ -1217,7 +1391,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.22", ngImpo
|
|
|
1217
1391
|
>
|
|
1218
1392
|
<ng-container *ngFor="let field of visibleFields; trackBy: trackByFn">
|
|
1219
1393
|
<dfk-field-input
|
|
1220
|
-
*ngIf="!field.fields"
|
|
1394
|
+
*ngIf="!field.fields && !sectionOf(field)"
|
|
1221
1395
|
[fieldDescription]="field"
|
|
1222
1396
|
[data]="data"
|
|
1223
1397
|
[rootData]="rootData"
|
|
@@ -1230,6 +1404,29 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.22", ngImpo
|
|
|
1230
1404
|
(onBlurField)="handleBlurField($event)"
|
|
1231
1405
|
></dfk-field-input>
|
|
1232
1406
|
|
|
1407
|
+
<!--
|
|
1408
|
+
A titled section: one nested form over the object data[name], with
|
|
1409
|
+
the errors, touched flags and blur of its fields keyed
|
|
1410
|
+
name.childName. A fieldset names the group through its legend and
|
|
1411
|
+
disables every control inside it natively.
|
|
1412
|
+
-->
|
|
1413
|
+
<fieldset
|
|
1414
|
+
*ngIf="!field.fields && sectionOf(field) as section"
|
|
1415
|
+
[class]="field.className"
|
|
1416
|
+
[disabled]="isSectionDisabled(field)"
|
|
1417
|
+
>
|
|
1418
|
+
<legend *ngIf="field.label">{{ field.label }}</legend>
|
|
1419
|
+
<dfk-multi-field-input
|
|
1420
|
+
[fieldDescriptions]="section"
|
|
1421
|
+
[properties]="sectionValues(field.name)"
|
|
1422
|
+
[rootData]="rootData ?? data"
|
|
1423
|
+
[errors]="errorsForSection(field.name)"
|
|
1424
|
+
[touched]="touchedForSection(field.name)"
|
|
1425
|
+
(onBlurField)="handleBlurField(field.name + '.' + $event)"
|
|
1426
|
+
(onChange)="onFieldChange({ value: $event, key: field.name })"
|
|
1427
|
+
></dfk-multi-field-input>
|
|
1428
|
+
</fieldset>
|
|
1429
|
+
|
|
1233
1430
|
<div *ngIf="field.fields" [class]="field.className">
|
|
1234
1431
|
<div *ngIf="field.label">{{ field.label }}</div>
|
|
1235
1432
|
<div
|
|
@@ -1616,6 +1813,58 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.22", ngImpo
|
|
|
1616
1813
|
type: Input
|
|
1617
1814
|
}] } });
|
|
1618
1815
|
|
|
1816
|
+
function toFieldErrors(input) {
|
|
1817
|
+
const errors = {};
|
|
1818
|
+
for (const [path, value] of Object.entries(input)) {
|
|
1819
|
+
const messages = (typeof value === 'string' ? [value] : [...(value ?? [])])
|
|
1820
|
+
.filter((message) => typeof message === 'string')
|
|
1821
|
+
.map((message) => message.trim())
|
|
1822
|
+
.filter((message) => message !== '');
|
|
1823
|
+
if (messages.length > 0) {
|
|
1824
|
+
errors[path] = messages;
|
|
1825
|
+
}
|
|
1826
|
+
}
|
|
1827
|
+
return errors;
|
|
1828
|
+
}
|
|
1829
|
+
/** The form's own errors with the server's after them, field by field. */
|
|
1830
|
+
function mergeFieldErrors(own, server) {
|
|
1831
|
+
const paths = Object.keys(server);
|
|
1832
|
+
if (paths.length === 0) {
|
|
1833
|
+
return own;
|
|
1834
|
+
}
|
|
1835
|
+
const merged = { ...own };
|
|
1836
|
+
for (const path of paths) {
|
|
1837
|
+
merged[path] = [...(own[path] ?? []), ...server[path]];
|
|
1838
|
+
}
|
|
1839
|
+
return merged;
|
|
1840
|
+
}
|
|
1841
|
+
function valueAt(data, path) {
|
|
1842
|
+
let value = data;
|
|
1843
|
+
for (const segment of path.match(/[^.[\]]+/g) ?? []) {
|
|
1844
|
+
if (value === null || typeof value !== 'object') {
|
|
1845
|
+
return undefined;
|
|
1846
|
+
}
|
|
1847
|
+
value = value[segment];
|
|
1848
|
+
}
|
|
1849
|
+
return value;
|
|
1850
|
+
}
|
|
1851
|
+
/**
|
|
1852
|
+
* The server's errors without those of the fields whose value changed: the
|
|
1853
|
+
* server judged a value that is no longer the one in the field.
|
|
1854
|
+
*/
|
|
1855
|
+
function withoutChangedFields(server, previous, next) {
|
|
1856
|
+
const paths = Object.keys(server);
|
|
1857
|
+
const kept = paths.filter((path) => Object.is(valueAt(previous, path), valueAt(next, path)));
|
|
1858
|
+
if (kept.length === paths.length) {
|
|
1859
|
+
return server;
|
|
1860
|
+
}
|
|
1861
|
+
return Object.fromEntries(kept.map((path) => [path, server[path]]));
|
|
1862
|
+
}
|
|
1863
|
+
/** The errors without those of one field. */
|
|
1864
|
+
function withoutField(errors, name) {
|
|
1865
|
+
return Object.fromEntries(Object.entries(errors).filter(([path]) => path !== name));
|
|
1866
|
+
}
|
|
1867
|
+
|
|
1619
1868
|
function createDynamicFormStore(options) {
|
|
1620
1869
|
const fields = options.fields;
|
|
1621
1870
|
const initialValues = options.initialValues || {};
|
|
@@ -1630,6 +1879,22 @@ function createDynamicFormStore(options) {
|
|
|
1630
1879
|
// changes. See the React adapter for the full rationale.
|
|
1631
1880
|
const baselineValues = signal({ ...data() }, ...(ngDevMode ? [{ debugName: "baselineValues" }] : /* istanbul ignore next */ []));
|
|
1632
1881
|
const errors = signal({}, ...(ngDevMode ? [{ debugName: "errors" }] : /* istanbul ignore next */ []));
|
|
1882
|
+
// `errors` stays the writable signal it was, holding what is shown: the
|
|
1883
|
+
// form's own errors with the server's after them. The two parts are kept
|
|
1884
|
+
// apart here so that either can change without losing the other.
|
|
1885
|
+
let ownErrors = {};
|
|
1886
|
+
const serverErrors = signal({}, ...(ngDevMode ? [{ debugName: "serverErrors" }] : /* istanbul ignore next */ []));
|
|
1887
|
+
function showErrors(own) {
|
|
1888
|
+
ownErrors = own;
|
|
1889
|
+
errors.set(mergeFieldErrors(own, serverErrors()));
|
|
1890
|
+
}
|
|
1891
|
+
function showServerErrors(server) {
|
|
1892
|
+
if (server === serverErrors()) {
|
|
1893
|
+
return;
|
|
1894
|
+
}
|
|
1895
|
+
serverErrors.set(server);
|
|
1896
|
+
errors.set(mergeFieldErrors(ownErrors, server));
|
|
1897
|
+
}
|
|
1633
1898
|
const isDirty = signal(false, ...(ngDevMode ? [{ debugName: "isDirty" }] : /* istanbul ignore next */ []));
|
|
1634
1899
|
const touched = signal({}, ...(ngDevMode ? [{ debugName: "touched" }] : /* istanbul ignore next */ []));
|
|
1635
1900
|
const isSubmitting = signal(false, ...(ngDevMode ? [{ debugName: "isSubmitting" }] : /* istanbul ignore next */ []));
|
|
@@ -1651,7 +1916,7 @@ function createDynamicFormStore(options) {
|
|
|
1651
1916
|
}
|
|
1652
1917
|
function validate() {
|
|
1653
1918
|
const res = validateFields(fields, data(), undefined, validationContext);
|
|
1654
|
-
|
|
1919
|
+
showErrors(res.errors);
|
|
1655
1920
|
return commitSyncResult(res);
|
|
1656
1921
|
}
|
|
1657
1922
|
async function validateAsync() {
|
|
@@ -1669,7 +1934,7 @@ function createDynamicFormStore(options) {
|
|
|
1669
1934
|
if (run !== validationRun || data() !== snapshot) {
|
|
1670
1935
|
return res.valid;
|
|
1671
1936
|
}
|
|
1672
|
-
|
|
1937
|
+
showErrors(res.errors);
|
|
1673
1938
|
validationResult.set(res);
|
|
1674
1939
|
return res.valid;
|
|
1675
1940
|
}
|
|
@@ -1681,6 +1946,8 @@ function createDynamicFormStore(options) {
|
|
|
1681
1946
|
}
|
|
1682
1947
|
function handleChange(newData) {
|
|
1683
1948
|
const next = applyComputedValues(fields, newData);
|
|
1949
|
+
// The server judged the old value: its verdict goes with it.
|
|
1950
|
+
showServerErrors(withoutChangedFields(serverErrors(), data(), next));
|
|
1684
1951
|
data.set(next);
|
|
1685
1952
|
isDirty.set(true);
|
|
1686
1953
|
validationController?.abort();
|
|
@@ -1689,7 +1956,7 @@ function createDynamicFormStore(options) {
|
|
|
1689
1956
|
const res = validateFields(fields, next, undefined, validationContext);
|
|
1690
1957
|
commitSyncResult(res);
|
|
1691
1958
|
if (validateOnChange) {
|
|
1692
|
-
|
|
1959
|
+
showErrors(res.errors);
|
|
1693
1960
|
}
|
|
1694
1961
|
}
|
|
1695
1962
|
function setFieldValue(name, value) {
|
|
@@ -1721,11 +1988,36 @@ function createDynamicFormStore(options) {
|
|
|
1721
1988
|
function resetTouched() {
|
|
1722
1989
|
touched.set({});
|
|
1723
1990
|
}
|
|
1991
|
+
/**
|
|
1992
|
+
* Shows the errors a backend answered with, and marks their fields touched
|
|
1993
|
+
* so they are displayed. An error stays until the value it was about
|
|
1994
|
+
* changes, until `clearServerErrors` or `reset`, or until the next submit.
|
|
1995
|
+
* `isValid` is not affected: it speaks for the form's own rules. Core's
|
|
1996
|
+
* `serverErrorsFrom(body)` turns the usual response shapes into this map.
|
|
1997
|
+
*/
|
|
1998
|
+
function setServerErrors(input) {
|
|
1999
|
+
const next = toFieldErrors(input);
|
|
2000
|
+
showServerErrors(next);
|
|
2001
|
+
touched.set({
|
|
2002
|
+
...touched(),
|
|
2003
|
+
...Object.fromEntries(Object.keys(next).map((path) => [path, true])),
|
|
2004
|
+
});
|
|
2005
|
+
}
|
|
2006
|
+
/** Removes the server errors of one field, or all of them. */
|
|
2007
|
+
function clearServerErrors(name) {
|
|
2008
|
+
if (name === undefined) {
|
|
2009
|
+
showServerErrors({});
|
|
2010
|
+
return;
|
|
2011
|
+
}
|
|
2012
|
+
if (name in serverErrors()) {
|
|
2013
|
+
showServerErrors(withoutField(serverErrors(), name));
|
|
2014
|
+
}
|
|
2015
|
+
}
|
|
1724
2016
|
function handleBlur(fieldName) {
|
|
1725
2017
|
setFieldTouched(fieldName, true);
|
|
1726
2018
|
if (validateOnBlur) {
|
|
1727
2019
|
const res = validateFields(fields, data(), undefined, validationContext);
|
|
1728
|
-
|
|
2020
|
+
showErrors(res.errors);
|
|
1729
2021
|
commitSyncResult(res);
|
|
1730
2022
|
}
|
|
1731
2023
|
}
|
|
@@ -1734,7 +2026,8 @@ function createDynamicFormStore(options) {
|
|
|
1734
2026
|
const next = applyComputedValues(fields, seed);
|
|
1735
2027
|
data.set(next);
|
|
1736
2028
|
baselineValues.set({ ...next });
|
|
1737
|
-
|
|
2029
|
+
serverErrors.set({});
|
|
2030
|
+
showErrors({});
|
|
1738
2031
|
isDirty.set(false);
|
|
1739
2032
|
touched.set({});
|
|
1740
2033
|
isSubmitting.set(false);
|
|
@@ -1755,6 +2048,8 @@ function createDynamicFormStore(options) {
|
|
|
1755
2048
|
e.preventDefault();
|
|
1756
2049
|
}
|
|
1757
2050
|
isSubmitting.set(true);
|
|
2051
|
+
// A new attempt: what the server said about the last one is history.
|
|
2052
|
+
showServerErrors({});
|
|
1758
2053
|
const thisSubmit = ++submitRun;
|
|
1759
2054
|
try {
|
|
1760
2055
|
// Touch everything before validating: a submit is the user asserting
|
|
@@ -1783,12 +2078,12 @@ function createDynamicFormStore(options) {
|
|
|
1783
2078
|
// still has to describe the data on screen, so when it moved on, the
|
|
1784
2079
|
// displayed state is re-derived instead of showing the old pass.
|
|
1785
2080
|
if (data() === snapshot && run === validationRun) {
|
|
1786
|
-
|
|
2081
|
+
showErrors(res.errors);
|
|
1787
2082
|
validationResult.set(res);
|
|
1788
2083
|
}
|
|
1789
2084
|
else {
|
|
1790
2085
|
const live = validateFields(fields, data(), undefined, validationContext);
|
|
1791
|
-
|
|
2086
|
+
showErrors(live.errors);
|
|
1792
2087
|
validationResult.set(live);
|
|
1793
2088
|
}
|
|
1794
2089
|
isSubmitted.set(true);
|
|
@@ -1829,6 +2124,10 @@ function createDynamicFormStore(options) {
|
|
|
1829
2124
|
reset,
|
|
1830
2125
|
validate,
|
|
1831
2126
|
validateAsync,
|
|
2127
|
+
/** What the server refused, by field path; already part of `errors`. */
|
|
2128
|
+
serverErrors: serverErrors.asReadonly(),
|
|
2129
|
+
setServerErrors,
|
|
2130
|
+
clearServerErrors,
|
|
1832
2131
|
handleSubmit,
|
|
1833
2132
|
};
|
|
1834
2133
|
}
|
|
@@ -81,6 +81,9 @@ declare class DynamicInput extends BaseInputComponent implements OnChanges, Afte
|
|
|
81
81
|
host: ViewContainerRef;
|
|
82
82
|
private compRef?;
|
|
83
83
|
private inputInstance?;
|
|
84
|
+
private builtIn?;
|
|
85
|
+
private builtInType?;
|
|
86
|
+
private builtInOptions?;
|
|
84
87
|
private subscriptions;
|
|
85
88
|
private supplied;
|
|
86
89
|
ngOnChanges(changes: SimpleChanges): void;
|
|
@@ -90,6 +93,13 @@ declare class DynamicInput extends BaseInputComponent implements OnChanges, Afte
|
|
|
90
93
|
private cleanup;
|
|
91
94
|
private cleanupSubscriptions;
|
|
92
95
|
private cleanupRenderedComponent;
|
|
96
|
+
/**
|
|
97
|
+
* Removes what this component put into the host element itself: a built-in
|
|
98
|
+
* control, the HTML of a function renderer, an error message. `host.clear()`
|
|
99
|
+
* does not: it only knows the components it created, which sit beside the
|
|
100
|
+
* host element, not inside it.
|
|
101
|
+
*/
|
|
102
|
+
private clearOwnNodes;
|
|
93
103
|
/** The id `ariaDescribedBy` points at. See core's `makeErrorId`. */
|
|
94
104
|
errorNodeId(): string;
|
|
95
105
|
/** `error` may arrive as a bare string, so index 0 would be a character. */
|
|
@@ -132,10 +142,21 @@ declare class DynamicInput extends BaseInputComponent implements OnChanges, Afte
|
|
|
132
142
|
*
|
|
133
143
|
* `render()` re-runs on every ngOnChanges while the fallback is in use (there
|
|
134
144
|
* is no component instance to sync props into), so this stays current as the
|
|
135
|
-
* field's error comes and goes.
|
|
145
|
+
* field's error comes and goes. The control is the same one from change to
|
|
146
|
+
* change, so a flag that is no longer given is taken off again.
|
|
136
147
|
*/
|
|
137
148
|
private applyFallbackAria;
|
|
149
|
+
/**
|
|
150
|
+
* Builds the built-in control for `type` and puts it in the host element.
|
|
151
|
+
* What the control shows is set by `syncBuiltIn`, here and on every later
|
|
152
|
+
* change.
|
|
153
|
+
*/
|
|
138
154
|
private renderDefaultFallbackHTML5;
|
|
155
|
+
private createBuiltIn;
|
|
156
|
+
/** Brings a built-in control in line with the current inputs. */
|
|
157
|
+
private syncBuiltIn;
|
|
158
|
+
/** The options of a built-in select, rebuilt only when the list changed, and the one that is chosen. */
|
|
159
|
+
private syncBuiltInOptions;
|
|
139
160
|
private renderError;
|
|
140
161
|
static ɵfac: i0.ɵɵFactoryDeclaration<DynamicInput, never>;
|
|
141
162
|
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>;
|
|
@@ -188,6 +209,11 @@ declare class FieldInput implements OnChanges, OnDestroy {
|
|
|
188
209
|
private optionsState?;
|
|
189
210
|
/** Bound into the template so a renderer can drive a search-remote refetch. */
|
|
190
211
|
onOptionsQuery: (query: string) => void;
|
|
212
|
+
/**
|
|
213
|
+
* A masked field shows formatted text; `parseInput` turns what its renderer
|
|
214
|
+
* reports back into the value to store.
|
|
215
|
+
*/
|
|
216
|
+
handleValueChange(input: unknown): void;
|
|
191
217
|
ngOnChanges(_changes: SimpleChanges): void;
|
|
192
218
|
ngOnDestroy(): void;
|
|
193
219
|
private syncOptionsLoader;
|
|
@@ -246,6 +272,10 @@ declare class MultiFieldInput implements OnInit, OnChanges {
|
|
|
246
272
|
private indexedErrors;
|
|
247
273
|
private indexedTouchedSource?;
|
|
248
274
|
private indexedTouched;
|
|
275
|
+
private sectionErrorsSource?;
|
|
276
|
+
private sectionErrors;
|
|
277
|
+
private sectionTouchedSource?;
|
|
278
|
+
private sectionTouched;
|
|
249
279
|
get effectiveIdPrefix(): string;
|
|
250
280
|
/** The touched map in effect: the controlled input, else the internal one. */
|
|
251
281
|
private get effectiveTouched();
|
|
@@ -258,6 +288,12 @@ declare class MultiFieldInput implements OnInit, OnChanges {
|
|
|
258
288
|
errorsForItem(fieldName: string, index: number): Record<string, string[]> | undefined;
|
|
259
289
|
touchedForItem(fieldName: string, index: number): Record<string, boolean>;
|
|
260
290
|
handleGroupBlur(fieldName: string, index: number, key: string): void;
|
|
291
|
+
/** The fields of a titled section, or undefined for any other field. */
|
|
292
|
+
sectionOf(field: FieldDescription): FieldDescription[] | undefined;
|
|
293
|
+
sectionValues(fieldName: string): Properties;
|
|
294
|
+
isSectionDisabled(field: FieldDescription): boolean;
|
|
295
|
+
errorsForSection(fieldName: string): Record<string, string[]> | undefined;
|
|
296
|
+
touchedForSection(fieldName: string): Record<string, boolean>;
|
|
261
297
|
/**
|
|
262
298
|
* Clears the internally tracked touched state. Only meaningful in
|
|
263
299
|
* uncontrolled mode - when `touched` is bound, resetting the form store
|
|
@@ -316,6 +352,10 @@ declare class DynamicFormDevToolsComponent {
|
|
|
316
352
|
static ɵcmp: i0.ɵɵComponentDeclaration<DynamicFormDevToolsComponent, "dfk-dev-tools", never, { "data": { "alias": "data"; "required": false; }; "errors": { "alias": "errors"; "required": false; }; "touched": { "alias": "touched"; "required": false; }; "isDirty": { "alias": "isDirty"; "required": false; }; "fields": { "alias": "fields"; "required": false; }; }, {}, never, never, true, never>;
|
|
317
353
|
}
|
|
318
354
|
|
|
355
|
+
/** What `setServerErrors` takes: a message or messages by field path. */
|
|
356
|
+
type ServerErrorsInput = Record<string, string | readonly string[] | null | undefined>;
|
|
357
|
+
type FieldErrors = Record<string, string[]>;
|
|
358
|
+
|
|
319
359
|
interface DynamicFormOptions {
|
|
320
360
|
fields: FieldDescription[];
|
|
321
361
|
initialValues?: Properties;
|
|
@@ -351,6 +391,10 @@ declare function createDynamicFormStore(options: DynamicFormOptions): {
|
|
|
351
391
|
reset: (newValues?: Properties) => void;
|
|
352
392
|
validate: () => boolean;
|
|
353
393
|
validateAsync: () => Promise<boolean>;
|
|
394
|
+
/** What the server refused, by field path; already part of `errors`. */
|
|
395
|
+
serverErrors: i0.Signal<FieldErrors>;
|
|
396
|
+
setServerErrors: (input: ServerErrorsInput) => void;
|
|
397
|
+
clearServerErrors: (name?: string) => void;
|
|
354
398
|
handleSubmit: (onValid: (data: Properties) => void | Promise<void>, onInvalid?: (errors: Record<string, string[]>) => void) => (e?: Event) => Promise<void>;
|
|
355
399
|
};
|
|
356
400
|
|
|
@@ -401,4 +445,4 @@ declare class DynamicFieldKitModule {
|
|
|
401
445
|
declare const FIELD_REGISTRY: InjectionToken<FieldRegistry>;
|
|
402
446
|
|
|
403
447
|
export { BaseInputComponent, ColumnLayout, DynamicFieldKitModule, DynamicFormDevToolsComponent, DynamicInput, FIELD_REGISTRY, FieldInput, GridLayout, LayoutRegistry, MultiFieldInput, RowLayout, createDynamicFormStore, layoutRegistry };
|
|
404
|
-
export type { BaseLayoutConfig, DynamicFormOptions, FieldInputProps, ResponsiveLayoutConfig };
|
|
448
|
+
export type { BaseLayoutConfig, DynamicFormOptions, FieldInputProps, ResponsiveLayoutConfig, ServerErrorsInput };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dynamic-field-kit/angular",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.11.1",
|
|
4
4
|
"description": "Angular renderer for dynamic-field-kit",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"private": false,
|
|
@@ -27,8 +27,8 @@
|
|
|
27
27
|
"CHANGELOG.md"
|
|
28
28
|
],
|
|
29
29
|
"peerDependencies": {
|
|
30
|
-
"@angular/common": ">=16 <
|
|
31
|
-
"@angular/core": ">=16 <
|
|
30
|
+
"@angular/common": ">=16 <23",
|
|
31
|
+
"@angular/core": ">=16 <23",
|
|
32
32
|
"@dynamic-field-kit/core": "^1.3.0"
|
|
33
33
|
},
|
|
34
34
|
"publishConfig": {
|
|
@@ -44,8 +44,8 @@
|
|
|
44
44
|
"lint": "eslint ."
|
|
45
45
|
},
|
|
46
46
|
"devDependencies": {
|
|
47
|
-
"@analogjs/vite-plugin-angular": "^2.
|
|
48
|
-
"@analogjs/vitest-angular": "^2.
|
|
47
|
+
"@analogjs/vite-plugin-angular": "^2.8.0",
|
|
48
|
+
"@analogjs/vitest-angular": "^2.8.0",
|
|
49
49
|
"@angular-devkit/build-angular": "^21.2.0",
|
|
50
50
|
"@angular/cdk": "^21.2.14",
|
|
51
51
|
"@angular/common": "^21.2.0",
|
|
@@ -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.11.0",
|
|
58
58
|
"@vitest/coverage-istanbul": "^4.1.11",
|
|
59
59
|
"jsdom": "^29.1.1",
|
|
60
60
|
"ng-packagr": "^21.2.0",
|