@dynamic-field-kit/angular 1.9.0 → 1.11.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
CHANGED
|
@@ -1,5 +1,24 @@
|
|
|
1
1
|
# @dynamic-field-kit/angular
|
|
2
2
|
|
|
3
|
+
## 1.11.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- 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.
|
|
8
|
+
- 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.
|
|
9
|
+
- 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.
|
|
10
|
+
- 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.
|
|
11
|
+
|
|
12
|
+
### Patch Changes
|
|
13
|
+
|
|
14
|
+
- 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.
|
|
15
|
+
|
|
16
|
+
## 1.10.0
|
|
17
|
+
|
|
18
|
+
### Minor Changes
|
|
19
|
+
|
|
20
|
+
- Add the Svelte 5 adapter, @dynamic-field-kit/svelte, with its demo app
|
|
21
|
+
|
|
3
22
|
## 1.9.0
|
|
4
23
|
|
|
5
24
|
### Minor Changes
|
|
@@ -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.
|
|
@@ -219,6 +224,21 @@ class DynamicInput extends BaseInputComponent {
|
|
|
219
224
|
this.compRef = undefined;
|
|
220
225
|
this.inputInstance = undefined;
|
|
221
226
|
}
|
|
227
|
+
/**
|
|
228
|
+
* Removes what this component put into the host element itself: a built-in
|
|
229
|
+
* control, the HTML of a function renderer, an error message. `host.clear()`
|
|
230
|
+
* does not: it only knows the components it created, which sit beside the
|
|
231
|
+
* host element, not inside it.
|
|
232
|
+
*/
|
|
233
|
+
clearOwnNodes() {
|
|
234
|
+
const element = this.host.element.nativeElement;
|
|
235
|
+
while (element.firstChild) {
|
|
236
|
+
element.removeChild(element.firstChild);
|
|
237
|
+
}
|
|
238
|
+
this.builtIn = undefined;
|
|
239
|
+
this.builtInType = undefined;
|
|
240
|
+
this.builtInOptions = undefined;
|
|
241
|
+
}
|
|
222
242
|
/** The id `ariaDescribedBy` points at. See core's `makeErrorId`. */
|
|
223
243
|
errorNodeId() {
|
|
224
244
|
return makeErrorId(this.id ?? '');
|
|
@@ -241,8 +261,17 @@ class DynamicInput extends BaseInputComponent {
|
|
|
241
261
|
}
|
|
242
262
|
render() {
|
|
243
263
|
const Renderer = this.registry.get(this.type);
|
|
264
|
+
// A built-in control of the same type is updated where it stands. Built
|
|
265
|
+
// again on every change, as it used to be, it would lose the focus after
|
|
266
|
+
// each character typed - and the ones built before stayed in the page.
|
|
267
|
+
if (!Renderer && this.builtIn && this.builtInType === this.type) {
|
|
268
|
+
this.syncBuiltIn(this.builtIn);
|
|
269
|
+
this.applyFallbackAria();
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
244
272
|
this.cleanup();
|
|
245
273
|
this.host.clear();
|
|
274
|
+
this.clearOwnNodes();
|
|
246
275
|
if (!Renderer) {
|
|
247
276
|
if (this.renderDefaultFallbackHTML5(this.type)) {
|
|
248
277
|
this.applyFallbackAria();
|
|
@@ -411,146 +440,155 @@ class DynamicInput extends BaseInputComponent {
|
|
|
411
440
|
*
|
|
412
441
|
* `render()` re-runs on every ngOnChanges while the fallback is in use (there
|
|
413
442
|
* is no component instance to sync props into), so this stays current as the
|
|
414
|
-
* field's error comes and goes.
|
|
443
|
+
* field's error comes and goes. The control is the same one from change to
|
|
444
|
+
* change, so a flag that is no longer given is taken off again.
|
|
415
445
|
*/
|
|
416
446
|
applyFallbackAria() {
|
|
417
447
|
const control = this.host.element.nativeElement.querySelector('input, select, textarea');
|
|
418
448
|
if (!control) {
|
|
419
449
|
return;
|
|
420
450
|
}
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
451
|
+
const flag = (name, value) => {
|
|
452
|
+
if (value === undefined) {
|
|
453
|
+
control.removeAttribute(name);
|
|
454
|
+
}
|
|
455
|
+
else {
|
|
456
|
+
control.setAttribute(name, value);
|
|
457
|
+
}
|
|
458
|
+
};
|
|
459
|
+
flag('aria-invalid', this.ariaInvalid === undefined ? undefined : String(this.ariaInvalid));
|
|
460
|
+
flag('aria-required', this.ariaRequired === undefined ? undefined : String(this.ariaRequired));
|
|
461
|
+
flag('aria-describedby', this.ariaDescribedBy || undefined);
|
|
430
462
|
}
|
|
463
|
+
/**
|
|
464
|
+
* Builds the built-in control for `type` and puts it in the host element.
|
|
465
|
+
* What the control shows is set by `syncBuiltIn`, here and on every later
|
|
466
|
+
* change.
|
|
467
|
+
*/
|
|
431
468
|
renderDefaultFallbackHTML5(type) {
|
|
432
|
-
const
|
|
469
|
+
const control = this.createBuiltIn(type);
|
|
470
|
+
if (!control) {
|
|
471
|
+
return false;
|
|
472
|
+
}
|
|
473
|
+
this.builtIn = control;
|
|
474
|
+
this.builtInType = type;
|
|
475
|
+
this.syncBuiltIn(control);
|
|
476
|
+
this.host.element.nativeElement.appendChild(control);
|
|
477
|
+
return true;
|
|
478
|
+
}
|
|
479
|
+
createBuiltIn(type) {
|
|
433
480
|
if (type === 'text' ||
|
|
434
481
|
type === 'password' ||
|
|
435
482
|
type === 'email' ||
|
|
436
483
|
type === 'number') {
|
|
437
484
|
const input = document.createElement('input');
|
|
438
485
|
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;
|
|
486
|
+
input.addEventListener('input', () => {
|
|
487
|
+
const raw = input.value;
|
|
460
488
|
const val = type === 'number' ? (raw === '' ? undefined : Number(raw)) : raw;
|
|
461
489
|
this.emitValue(val);
|
|
462
490
|
});
|
|
463
|
-
|
|
464
|
-
return true;
|
|
491
|
+
return input;
|
|
465
492
|
}
|
|
466
493
|
if (type === 'textarea') {
|
|
467
494
|
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);
|
|
495
|
+
textarea.addEventListener('input', () => {
|
|
496
|
+
this.emitValue(textarea.value);
|
|
489
497
|
});
|
|
490
|
-
|
|
491
|
-
return true;
|
|
498
|
+
return textarea;
|
|
492
499
|
}
|
|
493
500
|
if (type === 'checkbox') {
|
|
494
501
|
const checkbox = document.createElement('input');
|
|
495
502
|
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);
|
|
503
|
+
checkbox.addEventListener('change', () => {
|
|
504
|
+
this.emitValue(checkbox.checked);
|
|
511
505
|
});
|
|
512
|
-
|
|
513
|
-
return true;
|
|
506
|
+
return checkbox;
|
|
514
507
|
}
|
|
515
508
|
if (type === 'select') {
|
|
516
509
|
const select = document.createElement('select');
|
|
517
|
-
|
|
518
|
-
select.
|
|
519
|
-
}
|
|
520
|
-
|
|
521
|
-
|
|
510
|
+
select.addEventListener('change', () => {
|
|
511
|
+
this.emitValue(select.value);
|
|
512
|
+
});
|
|
513
|
+
return select;
|
|
514
|
+
}
|
|
515
|
+
return null;
|
|
516
|
+
}
|
|
517
|
+
/** Brings a built-in control in line with the current inputs. */
|
|
518
|
+
syncBuiltIn(control) {
|
|
519
|
+
const attribute = (name, value) => {
|
|
520
|
+
if (value) {
|
|
521
|
+
control.setAttribute(name, value);
|
|
522
522
|
}
|
|
523
|
-
|
|
524
|
-
|
|
523
|
+
else {
|
|
524
|
+
control.removeAttribute(name);
|
|
525
525
|
}
|
|
526
|
-
|
|
527
|
-
|
|
526
|
+
};
|
|
527
|
+
attribute('id', this.id);
|
|
528
|
+
attribute('class', this.className);
|
|
529
|
+
control.required = Boolean(this.required);
|
|
530
|
+
if (control instanceof HTMLSelectElement) {
|
|
531
|
+
// a select has no read-only state of its own
|
|
532
|
+
control.disabled = Boolean(this.disabled || this.readOnly);
|
|
533
|
+
this.syncBuiltInOptions(control);
|
|
534
|
+
return;
|
|
535
|
+
}
|
|
536
|
+
if (control instanceof HTMLInputElement && control.type === 'checkbox') {
|
|
537
|
+
// nor has a checkbox
|
|
538
|
+
control.disabled = Boolean(this.disabled || this.readOnly);
|
|
539
|
+
control.checked = Boolean(this.value);
|
|
540
|
+
return;
|
|
541
|
+
}
|
|
542
|
+
attribute('placeholder', this.placeholder);
|
|
543
|
+
control.disabled = Boolean(this.disabled);
|
|
544
|
+
control.readOnly = Boolean(this.readOnly);
|
|
545
|
+
const next = (this.value ?? '').toString();
|
|
546
|
+
// Written only when it says something else than the control already
|
|
547
|
+
// does: writing the text a user is in the middle of typing moves the
|
|
548
|
+
// caret to its end. For a number, "the same" is the same number: "1.50"
|
|
549
|
+
// is 1.5, and must not be rewritten to "1.5" under the caret.
|
|
550
|
+
const same = control.type === 'number' && control.value !== '' && next !== ''
|
|
551
|
+
? Number(control.value) === Number(next)
|
|
552
|
+
: control.value === next;
|
|
553
|
+
if (!same) {
|
|
554
|
+
control.value = next;
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
/** The options of a built-in select, rebuilt only when the list changed, and the one that is chosen. */
|
|
558
|
+
syncBuiltInOptions(select) {
|
|
559
|
+
const options = (this.options || []).map((opt) => {
|
|
560
|
+
const optObj = opt;
|
|
561
|
+
const optVal = optObj['value'] ?? optObj['id'] ?? opt;
|
|
562
|
+
const optLabel = optObj['label'] ?? optObj['name'] ?? String(optVal);
|
|
563
|
+
return { value: String(optVal), label: String(optLabel) };
|
|
564
|
+
});
|
|
565
|
+
const listed = JSON.stringify(options);
|
|
566
|
+
if (listed !== this.builtInOptions) {
|
|
567
|
+
this.builtInOptions = listed;
|
|
568
|
+
while (select.firstChild) {
|
|
569
|
+
select.removeChild(select.firstChild);
|
|
528
570
|
}
|
|
529
571
|
const defaultOpt = document.createElement('option');
|
|
530
572
|
defaultOpt.value = '';
|
|
531
573
|
defaultOpt.disabled = true;
|
|
532
574
|
defaultOpt.textContent = '-- Select --';
|
|
533
575
|
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);
|
|
576
|
+
for (const option of options) {
|
|
539
577
|
const optionEl = document.createElement('option');
|
|
540
|
-
optionEl.value =
|
|
541
|
-
optionEl.textContent =
|
|
542
|
-
if (String(optVal) === String(this.value)) {
|
|
543
|
-
optionEl.selected = true;
|
|
544
|
-
}
|
|
578
|
+
optionEl.value = option.value;
|
|
579
|
+
optionEl.textContent = option.label;
|
|
545
580
|
select.appendChild(optionEl);
|
|
546
|
-
}
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
const wanted = String(this.value);
|
|
584
|
+
const all = Array.from(select.options);
|
|
585
|
+
// Without a match the first option that can be chosen is shown, which is
|
|
586
|
+
// what a select built from scratch shows.
|
|
587
|
+
const chosen = all.find((option) => !option.disabled && option.value === wanted) ??
|
|
588
|
+
all.find((option) => !option.disabled);
|
|
589
|
+
if (chosen && !chosen.selected) {
|
|
590
|
+
chosen.selected = true;
|
|
552
591
|
}
|
|
553
|
-
return false;
|
|
554
592
|
}
|
|
555
593
|
renderError(message) {
|
|
556
594
|
const el = document.createElement('div');
|
|
@@ -650,6 +688,33 @@ class FieldInput {
|
|
|
650
688
|
onOptionsQuery = (query) => {
|
|
651
689
|
this.loader?.setQuery(query);
|
|
652
690
|
};
|
|
691
|
+
/**
|
|
692
|
+
* A masked field shows formatted text; `parseInput` turns what its renderer
|
|
693
|
+
* reports back into the value to store.
|
|
694
|
+
*/
|
|
695
|
+
handleValueChange(input) {
|
|
696
|
+
const props = this.rendererProps;
|
|
697
|
+
const parseInput = props?.parseInput;
|
|
698
|
+
if (props &&
|
|
699
|
+
parseInput &&
|
|
700
|
+
typeof input === 'string' &&
|
|
701
|
+
input !== props.value) {
|
|
702
|
+
// The text on screen is no longer the text this field last handed down,
|
|
703
|
+
// and the text to show next may well be that same one: the mask takes
|
|
704
|
+
// nothing from a letter where a digit belongs, or from a character past
|
|
705
|
+
// its last place. A binding that does not change writes nothing, so the
|
|
706
|
+
// refused character would stay on screen. Tell the renderer what it
|
|
707
|
+
// holds now; whatever is shown next is then a change.
|
|
708
|
+
this.rendererProps = { ...props, value: input };
|
|
709
|
+
this.cdr.detectChanges();
|
|
710
|
+
this.rendererProps = props;
|
|
711
|
+
this.cdr.markForCheck();
|
|
712
|
+
}
|
|
713
|
+
this.onValueChangeField.emit({
|
|
714
|
+
value: parseInput ? parseInput(input) : input,
|
|
715
|
+
key: this.fieldDescription.name,
|
|
716
|
+
});
|
|
717
|
+
}
|
|
653
718
|
ngOnChanges(_changes) {
|
|
654
719
|
this.syncOptionsLoader();
|
|
655
720
|
this.rendererProps = this.buildProps();
|
|
@@ -696,6 +761,7 @@ class FieldInput {
|
|
|
696
761
|
touched: this.touched,
|
|
697
762
|
dirty: this.dirty,
|
|
698
763
|
optionsState: this.optionsState,
|
|
764
|
+
applyMask: true,
|
|
699
765
|
});
|
|
700
766
|
// Explicitly bound inputs override what the field description resolves to,
|
|
701
767
|
// so a host can still drive options/disabled/error itself.
|
|
@@ -739,9 +805,7 @@ class FieldInput {
|
|
|
739
805
|
[step]="p.step"
|
|
740
806
|
[accept]="p.accept"
|
|
741
807
|
[multiple]="p.multiple"
|
|
742
|
-
(valueChange)="
|
|
743
|
-
onValueChangeField.emit({ value: $event, key: fieldDescription!.name })
|
|
744
|
-
"
|
|
808
|
+
(valueChange)="handleValueChange($event)"
|
|
745
809
|
(focusout)="onBlurField.emit(fieldDescription!.name)"
|
|
746
810
|
[extraProps]="p.extraProps"
|
|
747
811
|
[onOptionsQuery]="onOptionsQuery"
|
|
@@ -782,9 +846,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.22", ngImpo
|
|
|
782
846
|
[step]="p.step"
|
|
783
847
|
[accept]="p.accept"
|
|
784
848
|
[multiple]="p.multiple"
|
|
785
|
-
(valueChange)="
|
|
786
|
-
onValueChangeField.emit({ value: $event, key: fieldDescription!.name })
|
|
787
|
-
"
|
|
849
|
+
(valueChange)="handleValueChange($event)"
|
|
788
850
|
(focusout)="onBlurField.emit(fieldDescription!.name)"
|
|
789
851
|
[extraProps]="p.extraProps"
|
|
790
852
|
[onOptionsQuery]="onOptionsQuery"
|
|
@@ -821,6 +883,50 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.22", ngImpo
|
|
|
821
883
|
type: Output
|
|
822
884
|
}] } });
|
|
823
885
|
|
|
886
|
+
const NO_VALUES = Object.freeze({});
|
|
887
|
+
/**
|
|
888
|
+
* The fields of a titled section, or undefined for any other field. A field
|
|
889
|
+
* that also has `fields` is a repeatable group.
|
|
890
|
+
*/
|
|
891
|
+
function sectionFieldsOf(field) {
|
|
892
|
+
return Array.isArray(field.section) && !Array.isArray(field.fields)
|
|
893
|
+
? field.section
|
|
894
|
+
: undefined;
|
|
895
|
+
}
|
|
896
|
+
/** The object a section stores, or an empty one that keeps its identity. */
|
|
897
|
+
function sectionValuesOf(data, name) {
|
|
898
|
+
const value = data[name];
|
|
899
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
900
|
+
? value
|
|
901
|
+
: NO_VALUES;
|
|
902
|
+
}
|
|
903
|
+
/**
|
|
904
|
+
* The entries of one section, keyed from inside it: `address.street` becomes
|
|
905
|
+
* `street`. An absent map stays absent, which a nested form reads as
|
|
906
|
+
* "uncontrolled".
|
|
907
|
+
*/
|
|
908
|
+
function sectionPathMap(source, sectionName) {
|
|
909
|
+
if (source === undefined) {
|
|
910
|
+
return undefined;
|
|
911
|
+
}
|
|
912
|
+
const scoped = {};
|
|
913
|
+
const prefix = `${sectionName}.`;
|
|
914
|
+
for (const [path, value] of Object.entries(source)) {
|
|
915
|
+
if (path.startsWith(prefix) && path.length > prefix.length) {
|
|
916
|
+
scoped[path.slice(prefix.length)] = value;
|
|
917
|
+
}
|
|
918
|
+
}
|
|
919
|
+
return scoped;
|
|
920
|
+
}
|
|
921
|
+
/**
|
|
922
|
+
* Whether a section is disabled, by its flag or by its condition: the test
|
|
923
|
+
* validation applies before it skips the section.
|
|
924
|
+
*/
|
|
925
|
+
function isSectionDisabled(field, data, rootData) {
|
|
926
|
+
return (field.disabled === true ||
|
|
927
|
+
field.disabledCondition?.(data, rootData) === true);
|
|
928
|
+
}
|
|
929
|
+
|
|
824
930
|
const DEFAULT_BREAKPOINT = 768;
|
|
825
931
|
// Per-instance id counter, so two forms rendering the same field name emit
|
|
826
932
|
// different DOM ids.
|
|
@@ -886,6 +992,10 @@ class MultiFieldInput {
|
|
|
886
992
|
indexedErrors = new Map();
|
|
887
993
|
indexedTouchedSource;
|
|
888
994
|
indexedTouched = new Map();
|
|
995
|
+
sectionErrorsSource;
|
|
996
|
+
sectionErrors = new Map();
|
|
997
|
+
sectionTouchedSource;
|
|
998
|
+
sectionTouched = new Map();
|
|
889
999
|
get effectiveIdPrefix() {
|
|
890
1000
|
return this.idPrefix ?? `dfk-${this.instanceId}`;
|
|
891
1001
|
}
|
|
@@ -944,6 +1054,41 @@ class MultiFieldInput {
|
|
|
944
1054
|
handleGroupBlur(fieldName, index, key) {
|
|
945
1055
|
this.handleBlurField(`${fieldName}[${index}].${key}`);
|
|
946
1056
|
}
|
|
1057
|
+
/** The fields of a titled section, or undefined for any other field. */
|
|
1058
|
+
sectionOf(field) {
|
|
1059
|
+
return sectionFieldsOf(field);
|
|
1060
|
+
}
|
|
1061
|
+
sectionValues(fieldName) {
|
|
1062
|
+
return sectionValuesOf(this.data, fieldName);
|
|
1063
|
+
}
|
|
1064
|
+
isSectionDisabled(field) {
|
|
1065
|
+
return isSectionDisabled(field, this.data, this.rootData ?? this.data);
|
|
1066
|
+
}
|
|
1067
|
+
// Cached per source map for the reason given at `touchedForItem`: a fresh
|
|
1068
|
+
// object in a template binding is a new identity on every pass.
|
|
1069
|
+
errorsForSection(fieldName) {
|
|
1070
|
+
if (this.sectionErrorsSource !== this.errors) {
|
|
1071
|
+
this.sectionErrorsSource = this.errors;
|
|
1072
|
+
this.sectionErrors.clear();
|
|
1073
|
+
}
|
|
1074
|
+
if (!this.sectionErrors.has(fieldName)) {
|
|
1075
|
+
this.sectionErrors.set(fieldName, sectionPathMap(this.errors, fieldName));
|
|
1076
|
+
}
|
|
1077
|
+
return this.sectionErrors.get(fieldName);
|
|
1078
|
+
}
|
|
1079
|
+
touchedForSection(fieldName) {
|
|
1080
|
+
const current = this.effectiveTouched;
|
|
1081
|
+
if (this.sectionTouchedSource !== current) {
|
|
1082
|
+
this.sectionTouchedSource = current;
|
|
1083
|
+
this.sectionTouched.clear();
|
|
1084
|
+
}
|
|
1085
|
+
let scoped = this.sectionTouched.get(fieldName);
|
|
1086
|
+
if (!scoped) {
|
|
1087
|
+
scoped = sectionPathMap(current, fieldName) ?? EMPTY_TOUCHED;
|
|
1088
|
+
this.sectionTouched.set(fieldName, scoped);
|
|
1089
|
+
}
|
|
1090
|
+
return scoped;
|
|
1091
|
+
}
|
|
947
1092
|
/**
|
|
948
1093
|
* Clears the internally tracked touched state. Only meaningful in
|
|
949
1094
|
* uncontrolled mode - when `touched` is bound, resetting the form store
|
|
@@ -1125,7 +1270,7 @@ class MultiFieldInput {
|
|
|
1125
1270
|
>
|
|
1126
1271
|
<ng-container *ngFor="let field of visibleFields; trackBy: trackByFn">
|
|
1127
1272
|
<dfk-field-input
|
|
1128
|
-
*ngIf="!field.fields"
|
|
1273
|
+
*ngIf="!field.fields && !sectionOf(field)"
|
|
1129
1274
|
[fieldDescription]="field"
|
|
1130
1275
|
[data]="data"
|
|
1131
1276
|
[rootData]="rootData"
|
|
@@ -1138,6 +1283,29 @@ class MultiFieldInput {
|
|
|
1138
1283
|
(onBlurField)="handleBlurField($event)"
|
|
1139
1284
|
></dfk-field-input>
|
|
1140
1285
|
|
|
1286
|
+
<!--
|
|
1287
|
+
A titled section: one nested form over the object data[name], with
|
|
1288
|
+
the errors, touched flags and blur of its fields keyed
|
|
1289
|
+
name.childName. A fieldset names the group through its legend and
|
|
1290
|
+
disables every control inside it natively.
|
|
1291
|
+
-->
|
|
1292
|
+
<fieldset
|
|
1293
|
+
*ngIf="!field.fields && sectionOf(field) as section"
|
|
1294
|
+
[class]="field.className"
|
|
1295
|
+
[disabled]="isSectionDisabled(field)"
|
|
1296
|
+
>
|
|
1297
|
+
<legend *ngIf="field.label">{{ field.label }}</legend>
|
|
1298
|
+
<dfk-multi-field-input
|
|
1299
|
+
[fieldDescriptions]="section"
|
|
1300
|
+
[properties]="sectionValues(field.name)"
|
|
1301
|
+
[rootData]="rootData ?? data"
|
|
1302
|
+
[errors]="errorsForSection(field.name)"
|
|
1303
|
+
[touched]="touchedForSection(field.name)"
|
|
1304
|
+
(onBlurField)="handleBlurField(field.name + '.' + $event)"
|
|
1305
|
+
(onChange)="onFieldChange({ value: $event, key: field.name })"
|
|
1306
|
+
></dfk-multi-field-input>
|
|
1307
|
+
</fieldset>
|
|
1308
|
+
|
|
1141
1309
|
<div *ngIf="field.fields" [class]="field.className">
|
|
1142
1310
|
<div *ngIf="field.label">{{ field.label }}</div>
|
|
1143
1311
|
<div
|
|
@@ -1217,7 +1385,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.22", ngImpo
|
|
|
1217
1385
|
>
|
|
1218
1386
|
<ng-container *ngFor="let field of visibleFields; trackBy: trackByFn">
|
|
1219
1387
|
<dfk-field-input
|
|
1220
|
-
*ngIf="!field.fields"
|
|
1388
|
+
*ngIf="!field.fields && !sectionOf(field)"
|
|
1221
1389
|
[fieldDescription]="field"
|
|
1222
1390
|
[data]="data"
|
|
1223
1391
|
[rootData]="rootData"
|
|
@@ -1230,6 +1398,29 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.22", ngImpo
|
|
|
1230
1398
|
(onBlurField)="handleBlurField($event)"
|
|
1231
1399
|
></dfk-field-input>
|
|
1232
1400
|
|
|
1401
|
+
<!--
|
|
1402
|
+
A titled section: one nested form over the object data[name], with
|
|
1403
|
+
the errors, touched flags and blur of its fields keyed
|
|
1404
|
+
name.childName. A fieldset names the group through its legend and
|
|
1405
|
+
disables every control inside it natively.
|
|
1406
|
+
-->
|
|
1407
|
+
<fieldset
|
|
1408
|
+
*ngIf="!field.fields && sectionOf(field) as section"
|
|
1409
|
+
[class]="field.className"
|
|
1410
|
+
[disabled]="isSectionDisabled(field)"
|
|
1411
|
+
>
|
|
1412
|
+
<legend *ngIf="field.label">{{ field.label }}</legend>
|
|
1413
|
+
<dfk-multi-field-input
|
|
1414
|
+
[fieldDescriptions]="section"
|
|
1415
|
+
[properties]="sectionValues(field.name)"
|
|
1416
|
+
[rootData]="rootData ?? data"
|
|
1417
|
+
[errors]="errorsForSection(field.name)"
|
|
1418
|
+
[touched]="touchedForSection(field.name)"
|
|
1419
|
+
(onBlurField)="handleBlurField(field.name + '.' + $event)"
|
|
1420
|
+
(onChange)="onFieldChange({ value: $event, key: field.name })"
|
|
1421
|
+
></dfk-multi-field-input>
|
|
1422
|
+
</fieldset>
|
|
1423
|
+
|
|
1233
1424
|
<div *ngIf="field.fields" [class]="field.className">
|
|
1234
1425
|
<div *ngIf="field.label">{{ field.label }}</div>
|
|
1235
1426
|
<div
|
|
@@ -1616,6 +1807,58 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.22", ngImpo
|
|
|
1616
1807
|
type: Input
|
|
1617
1808
|
}] } });
|
|
1618
1809
|
|
|
1810
|
+
function toFieldErrors(input) {
|
|
1811
|
+
const errors = {};
|
|
1812
|
+
for (const [path, value] of Object.entries(input)) {
|
|
1813
|
+
const messages = (typeof value === 'string' ? [value] : [...(value ?? [])])
|
|
1814
|
+
.filter((message) => typeof message === 'string')
|
|
1815
|
+
.map((message) => message.trim())
|
|
1816
|
+
.filter((message) => message !== '');
|
|
1817
|
+
if (messages.length > 0) {
|
|
1818
|
+
errors[path] = messages;
|
|
1819
|
+
}
|
|
1820
|
+
}
|
|
1821
|
+
return errors;
|
|
1822
|
+
}
|
|
1823
|
+
/** The form's own errors with the server's after them, field by field. */
|
|
1824
|
+
function mergeFieldErrors(own, server) {
|
|
1825
|
+
const paths = Object.keys(server);
|
|
1826
|
+
if (paths.length === 0) {
|
|
1827
|
+
return own;
|
|
1828
|
+
}
|
|
1829
|
+
const merged = { ...own };
|
|
1830
|
+
for (const path of paths) {
|
|
1831
|
+
merged[path] = [...(own[path] ?? []), ...server[path]];
|
|
1832
|
+
}
|
|
1833
|
+
return merged;
|
|
1834
|
+
}
|
|
1835
|
+
function valueAt(data, path) {
|
|
1836
|
+
let value = data;
|
|
1837
|
+
for (const segment of path.match(/[^.[\]]+/g) ?? []) {
|
|
1838
|
+
if (value === null || typeof value !== 'object') {
|
|
1839
|
+
return undefined;
|
|
1840
|
+
}
|
|
1841
|
+
value = value[segment];
|
|
1842
|
+
}
|
|
1843
|
+
return value;
|
|
1844
|
+
}
|
|
1845
|
+
/**
|
|
1846
|
+
* The server's errors without those of the fields whose value changed: the
|
|
1847
|
+
* server judged a value that is no longer the one in the field.
|
|
1848
|
+
*/
|
|
1849
|
+
function withoutChangedFields(server, previous, next) {
|
|
1850
|
+
const paths = Object.keys(server);
|
|
1851
|
+
const kept = paths.filter((path) => Object.is(valueAt(previous, path), valueAt(next, path)));
|
|
1852
|
+
if (kept.length === paths.length) {
|
|
1853
|
+
return server;
|
|
1854
|
+
}
|
|
1855
|
+
return Object.fromEntries(kept.map((path) => [path, server[path]]));
|
|
1856
|
+
}
|
|
1857
|
+
/** The errors without those of one field. */
|
|
1858
|
+
function withoutField(errors, name) {
|
|
1859
|
+
return Object.fromEntries(Object.entries(errors).filter(([path]) => path !== name));
|
|
1860
|
+
}
|
|
1861
|
+
|
|
1619
1862
|
function createDynamicFormStore(options) {
|
|
1620
1863
|
const fields = options.fields;
|
|
1621
1864
|
const initialValues = options.initialValues || {};
|
|
@@ -1630,6 +1873,22 @@ function createDynamicFormStore(options) {
|
|
|
1630
1873
|
// changes. See the React adapter for the full rationale.
|
|
1631
1874
|
const baselineValues = signal({ ...data() }, ...(ngDevMode ? [{ debugName: "baselineValues" }] : /* istanbul ignore next */ []));
|
|
1632
1875
|
const errors = signal({}, ...(ngDevMode ? [{ debugName: "errors" }] : /* istanbul ignore next */ []));
|
|
1876
|
+
// `errors` stays the writable signal it was, holding what is shown: the
|
|
1877
|
+
// form's own errors with the server's after them. The two parts are kept
|
|
1878
|
+
// apart here so that either can change without losing the other.
|
|
1879
|
+
let ownErrors = {};
|
|
1880
|
+
const serverErrors = signal({}, ...(ngDevMode ? [{ debugName: "serverErrors" }] : /* istanbul ignore next */ []));
|
|
1881
|
+
function showErrors(own) {
|
|
1882
|
+
ownErrors = own;
|
|
1883
|
+
errors.set(mergeFieldErrors(own, serverErrors()));
|
|
1884
|
+
}
|
|
1885
|
+
function showServerErrors(server) {
|
|
1886
|
+
if (server === serverErrors()) {
|
|
1887
|
+
return;
|
|
1888
|
+
}
|
|
1889
|
+
serverErrors.set(server);
|
|
1890
|
+
errors.set(mergeFieldErrors(ownErrors, server));
|
|
1891
|
+
}
|
|
1633
1892
|
const isDirty = signal(false, ...(ngDevMode ? [{ debugName: "isDirty" }] : /* istanbul ignore next */ []));
|
|
1634
1893
|
const touched = signal({}, ...(ngDevMode ? [{ debugName: "touched" }] : /* istanbul ignore next */ []));
|
|
1635
1894
|
const isSubmitting = signal(false, ...(ngDevMode ? [{ debugName: "isSubmitting" }] : /* istanbul ignore next */ []));
|
|
@@ -1651,7 +1910,7 @@ function createDynamicFormStore(options) {
|
|
|
1651
1910
|
}
|
|
1652
1911
|
function validate() {
|
|
1653
1912
|
const res = validateFields(fields, data(), undefined, validationContext);
|
|
1654
|
-
|
|
1913
|
+
showErrors(res.errors);
|
|
1655
1914
|
return commitSyncResult(res);
|
|
1656
1915
|
}
|
|
1657
1916
|
async function validateAsync() {
|
|
@@ -1669,7 +1928,7 @@ function createDynamicFormStore(options) {
|
|
|
1669
1928
|
if (run !== validationRun || data() !== snapshot) {
|
|
1670
1929
|
return res.valid;
|
|
1671
1930
|
}
|
|
1672
|
-
|
|
1931
|
+
showErrors(res.errors);
|
|
1673
1932
|
validationResult.set(res);
|
|
1674
1933
|
return res.valid;
|
|
1675
1934
|
}
|
|
@@ -1681,6 +1940,8 @@ function createDynamicFormStore(options) {
|
|
|
1681
1940
|
}
|
|
1682
1941
|
function handleChange(newData) {
|
|
1683
1942
|
const next = applyComputedValues(fields, newData);
|
|
1943
|
+
// The server judged the old value: its verdict goes with it.
|
|
1944
|
+
showServerErrors(withoutChangedFields(serverErrors(), data(), next));
|
|
1684
1945
|
data.set(next);
|
|
1685
1946
|
isDirty.set(true);
|
|
1686
1947
|
validationController?.abort();
|
|
@@ -1689,7 +1950,7 @@ function createDynamicFormStore(options) {
|
|
|
1689
1950
|
const res = validateFields(fields, next, undefined, validationContext);
|
|
1690
1951
|
commitSyncResult(res);
|
|
1691
1952
|
if (validateOnChange) {
|
|
1692
|
-
|
|
1953
|
+
showErrors(res.errors);
|
|
1693
1954
|
}
|
|
1694
1955
|
}
|
|
1695
1956
|
function setFieldValue(name, value) {
|
|
@@ -1721,11 +1982,36 @@ function createDynamicFormStore(options) {
|
|
|
1721
1982
|
function resetTouched() {
|
|
1722
1983
|
touched.set({});
|
|
1723
1984
|
}
|
|
1985
|
+
/**
|
|
1986
|
+
* Shows the errors a backend answered with, and marks their fields touched
|
|
1987
|
+
* so they are displayed. An error stays until the value it was about
|
|
1988
|
+
* changes, until `clearServerErrors` or `reset`, or until the next submit.
|
|
1989
|
+
* `isValid` is not affected: it speaks for the form's own rules. Core's
|
|
1990
|
+
* `serverErrorsFrom(body)` turns the usual response shapes into this map.
|
|
1991
|
+
*/
|
|
1992
|
+
function setServerErrors(input) {
|
|
1993
|
+
const next = toFieldErrors(input);
|
|
1994
|
+
showServerErrors(next);
|
|
1995
|
+
touched.set({
|
|
1996
|
+
...touched(),
|
|
1997
|
+
...Object.fromEntries(Object.keys(next).map((path) => [path, true])),
|
|
1998
|
+
});
|
|
1999
|
+
}
|
|
2000
|
+
/** Removes the server errors of one field, or all of them. */
|
|
2001
|
+
function clearServerErrors(name) {
|
|
2002
|
+
if (name === undefined) {
|
|
2003
|
+
showServerErrors({});
|
|
2004
|
+
return;
|
|
2005
|
+
}
|
|
2006
|
+
if (name in serverErrors()) {
|
|
2007
|
+
showServerErrors(withoutField(serverErrors(), name));
|
|
2008
|
+
}
|
|
2009
|
+
}
|
|
1724
2010
|
function handleBlur(fieldName) {
|
|
1725
2011
|
setFieldTouched(fieldName, true);
|
|
1726
2012
|
if (validateOnBlur) {
|
|
1727
2013
|
const res = validateFields(fields, data(), undefined, validationContext);
|
|
1728
|
-
|
|
2014
|
+
showErrors(res.errors);
|
|
1729
2015
|
commitSyncResult(res);
|
|
1730
2016
|
}
|
|
1731
2017
|
}
|
|
@@ -1734,7 +2020,8 @@ function createDynamicFormStore(options) {
|
|
|
1734
2020
|
const next = applyComputedValues(fields, seed);
|
|
1735
2021
|
data.set(next);
|
|
1736
2022
|
baselineValues.set({ ...next });
|
|
1737
|
-
|
|
2023
|
+
serverErrors.set({});
|
|
2024
|
+
showErrors({});
|
|
1738
2025
|
isDirty.set(false);
|
|
1739
2026
|
touched.set({});
|
|
1740
2027
|
isSubmitting.set(false);
|
|
@@ -1755,6 +2042,8 @@ function createDynamicFormStore(options) {
|
|
|
1755
2042
|
e.preventDefault();
|
|
1756
2043
|
}
|
|
1757
2044
|
isSubmitting.set(true);
|
|
2045
|
+
// A new attempt: what the server said about the last one is history.
|
|
2046
|
+
showServerErrors({});
|
|
1758
2047
|
const thisSubmit = ++submitRun;
|
|
1759
2048
|
try {
|
|
1760
2049
|
// Touch everything before validating: a submit is the user asserting
|
|
@@ -1783,12 +2072,12 @@ function createDynamicFormStore(options) {
|
|
|
1783
2072
|
// still has to describe the data on screen, so when it moved on, the
|
|
1784
2073
|
// displayed state is re-derived instead of showing the old pass.
|
|
1785
2074
|
if (data() === snapshot && run === validationRun) {
|
|
1786
|
-
|
|
2075
|
+
showErrors(res.errors);
|
|
1787
2076
|
validationResult.set(res);
|
|
1788
2077
|
}
|
|
1789
2078
|
else {
|
|
1790
2079
|
const live = validateFields(fields, data(), undefined, validationContext);
|
|
1791
|
-
|
|
2080
|
+
showErrors(live.errors);
|
|
1792
2081
|
validationResult.set(live);
|
|
1793
2082
|
}
|
|
1794
2083
|
isSubmitted.set(true);
|
|
@@ -1829,6 +2118,10 @@ function createDynamicFormStore(options) {
|
|
|
1829
2118
|
reset,
|
|
1830
2119
|
validate,
|
|
1831
2120
|
validateAsync,
|
|
2121
|
+
/** What the server refused, by field path; already part of `errors`. */
|
|
2122
|
+
serverErrors: serverErrors.asReadonly(),
|
|
2123
|
+
setServerErrors,
|
|
2124
|
+
clearServerErrors,
|
|
1832
2125
|
handleSubmit,
|
|
1833
2126
|
};
|
|
1834
2127
|
}
|
|
@@ -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.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.11.0",
|
|
58
58
|
"@vitest/coverage-istanbul": "^4.1.11",
|
|
59
59
|
"jsdom": "^29.1.1",
|
|
60
60
|
"ng-packagr": "^21.2.0",
|