ngx-dynamic-entity 1.1.0 → 1.2.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.
Files changed (51) hide show
  1. package/esm2022/lib/field-types/array-field.component.mjs +13 -5
  2. package/esm2022/lib/field-types/boolean-field.component.mjs +4 -3
  3. package/esm2022/lib/field-types/checkbox-field.component.mjs +4 -3
  4. package/esm2022/lib/field-types/currency-field.component.mjs +4 -3
  5. package/esm2022/lib/field-types/date-field.component.mjs +4 -3
  6. package/esm2022/lib/field-types/dropdown-field.component.mjs +9 -7
  7. package/esm2022/lib/field-types/email-field.component.mjs +4 -3
  8. package/esm2022/lib/field-types/entity-ref-field.component.mjs +21 -7
  9. package/esm2022/lib/field-types/field-component.contract.mjs +2 -0
  10. package/esm2022/lib/field-types/file-field.component.mjs +4 -3
  11. package/esm2022/lib/field-types/group-field.component.mjs +5 -5
  12. package/esm2022/lib/field-types/image-field.component.mjs +4 -3
  13. package/esm2022/lib/field-types/month-year-field.component.mjs +4 -3
  14. package/esm2022/lib/field-types/multi-select-field.component.mjs +4 -3
  15. package/esm2022/lib/field-types/number-field.component.mjs +7 -11
  16. package/esm2022/lib/field-types/password-field.component.mjs +4 -3
  17. package/esm2022/lib/field-types/radio-field.component.mjs +4 -3
  18. package/esm2022/lib/field-types/text-field.component.mjs +25 -18
  19. package/esm2022/lib/field-types/textarea-field.component.mjs +4 -3
  20. package/esm2022/lib/form/dynamic-field/dynamic-field.component.mjs +53 -6
  21. package/esm2022/lib/form/dynamic-form.component.mjs +131 -15
  22. package/esm2022/lib/form/dynamic-record-form.component.mjs +2 -2
  23. package/esm2022/lib/providers/provide-ngx-dynamic-entity.mjs +7 -2
  24. package/esm2022/lib/services/cascade-data.service.mjs +2 -2
  25. package/esm2022/lib/services/entity-ref-registry.service.mjs +2 -2
  26. package/esm2022/lib/services/lookup-registry.service.mjs +2 -2
  27. package/esm2022/lib/services/validation-messages.service.mjs +56 -0
  28. package/esm2022/lib/services/validator-registry.service.mjs +24 -2
  29. package/esm2022/lib/tokens/injection-tokens.mjs +19 -6
  30. package/esm2022/public-api.mjs +6 -1
  31. package/fesm2022/ngx-dynamic-entity.mjs +373 -81
  32. package/fesm2022/ngx-dynamic-entity.mjs.map +1 -1
  33. package/lib/field-types/array-field.component.d.ts +7 -1
  34. package/lib/field-types/dropdown-field.component.d.ts +1 -0
  35. package/lib/field-types/entity-ref-field.component.d.ts +11 -1
  36. package/lib/field-types/field-component.contract.d.ts +49 -0
  37. package/lib/field-types/group-field.component.d.ts +1 -1
  38. package/lib/field-types/number-field.component.d.ts +1 -0
  39. package/lib/field-types/text-field.component.d.ts +10 -2
  40. package/lib/form/dynamic-field/dynamic-field.component.d.ts +39 -4
  41. package/lib/form/dynamic-form.component.d.ts +62 -2
  42. package/lib/providers/provide-ngx-dynamic-entity.d.ts +17 -1
  43. package/lib/services/cascade-data.service.d.ts +1 -1
  44. package/lib/services/entity-ref-registry.service.d.ts +1 -1
  45. package/lib/services/lookup-registry.service.d.ts +1 -1
  46. package/lib/services/validation-messages.service.d.ts +43 -0
  47. package/lib/services/validator-registry.service.d.ts +13 -1
  48. package/lib/tokens/injection-tokens.d.ts +18 -5
  49. package/package.json +2 -2
  50. package/public-api.d.ts +2 -0
  51. package/styles.css +579 -0
@@ -1,8 +1,14 @@
1
1
  import { AbstractControl, FormArray } from '@angular/forms';
2
2
  import type { NestedFieldConfig, EntityFormConfig } from '@dynamic-entity/core';
3
3
  import * as i0 from "@angular/core";
4
- /** ADR-008: ArrayFieldComponent — renders repeating rows for FormArray with + Add Item and Remove */
4
+ /** Renders repeating rows for a FormArray, with Add Item and Remove. */
5
5
  export declare class ArrayFieldComponent {
6
+ /**
7
+ * addItem/removeItem are public and may be called from outside this component's template,
8
+ * which under OnPush would not re-render. The host also watches the control, but this
9
+ * keeps the component correct on its own.
10
+ */
11
+ private readonly cdr;
6
12
  field: NestedFieldConfig;
7
13
  control: AbstractControl;
8
14
  config: EntityFormConfig;
@@ -3,6 +3,7 @@ import type { NestedFieldConfig } from '@dynamic-entity/core';
3
3
  import * as i0 from "@angular/core";
4
4
  export declare class DropdownFieldComponent {
5
5
  private readonly lookups;
6
+ private readonly messages;
6
7
  private _field;
7
8
  private _language;
8
9
  /** Setter-based so options resolve however the input is set — see `refreshChoiceOptions`. */
@@ -29,7 +29,17 @@ export declare class EntityRefFieldComponent implements OnInit {
29
29
  ngOnInit(): void;
30
30
  /** Reload options against the parent's current value. */
31
31
  reload(): Promise<void>;
32
- onSelectionChange(): void;
32
+ /**
33
+ * Publish the selected option so autoPatch can copy from its record.
34
+ *
35
+ * Driven from `valueChanges` rather than the select's `change` event: that event is the
36
+ * same one the `formControl` directive listens to, so reading the control inside a
37
+ * template `(change)` handler can see the previous value. `valueChanges` fires after the
38
+ * model has updated — including Playwright `selectOption`, which updates the control
39
+ * through the value accessor.
40
+ */
41
+ onSelectionChange(value?: string): void;
42
+ private publishSelection;
33
43
  getLabel(value: any): string;
34
44
  private parentValue;
35
45
  /** Reload (and drop a stale selection) whenever the parent control changes. */
@@ -0,0 +1,49 @@
1
+ import type { AbstractControl } from '@angular/forms';
2
+ import type { NestedFieldConfig } from '@dynamic-entity/core';
3
+ /**
4
+ * What a field component must accept to be registered with `provideFieldTypes`.
5
+ *
6
+ * The renderer instantiates a field component dynamically and assigns exactly these five
7
+ * inputs — no more, no less — so any component satisfying this interface can stand in for a
8
+ * built-in type or add a new one.
9
+ *
10
+ * Implement it explicitly and TypeScript will tell you when you have missed one:
11
+ *
12
+ * ```typescript
13
+ * @Component({
14
+ * selector: 'app-signature-field',
15
+ * standalone: true,
16
+ * template: `<canvas [attr.aria-label]="field.id"></canvas>`,
17
+ * })
18
+ * export class SignatureFieldComponent implements DynamicFieldComponentContract {
19
+ * @Input() field!: NestedFieldConfig;
20
+ * @Input() control!: AbstractControl;
21
+ * @Input() language = 'en';
22
+ * @Input() readonly = false;
23
+ * @Input() masked = false;
24
+ * }
25
+ *
26
+ * provideFieldTypes({ signature: SignatureFieldComponent });
27
+ * ```
28
+ *
29
+ * Two implementation notes that are easy to get wrong:
30
+ *
31
+ * - Declare the inputs with definite assignment (`field!: …`) or an initialiser. The
32
+ * renderer sets them through `ComponentRef.setInput()` after construction, so they are
33
+ * genuinely undefined until the first assignment.
34
+ * - `masked` means the value must not be displayed. Render a placeholder rather than the
35
+ * control's value. This is presentational: the real value stays in the control and is
36
+ * included in the submitted record, so it is not an access-control boundary.
37
+ */
38
+ export interface DynamicFieldComponentContract {
39
+ /** The field's schema entry: id, type, label, validators, options, and so on. */
40
+ field: NestedFieldConfig;
41
+ /** The reactive control backing this field. A `FormGroup` for `group`, `FormArray` for `array`. */
42
+ control: AbstractControl;
43
+ /** Active language, for resolving `LocalizedText` labels, placeholders and options. */
44
+ language: string;
45
+ /** Render the value as text rather than an editable control. */
46
+ readonly: boolean;
47
+ /** Hide the value from display. See the note above — presentational only. */
48
+ masked: boolean;
49
+ }
@@ -1,7 +1,7 @@
1
1
  import { AbstractControl } from '@angular/forms';
2
2
  import type { NestedFieldConfig, EntityFormConfig } from '@dynamic-entity/core';
3
3
  import * as i0 from "@angular/core";
4
- /** ADR-008: GroupFieldComponent — renders nested fieldset container for group.children */
4
+ /** Renders a nested fieldset container for a group field's children. */
5
5
  export declare class GroupFieldComponent {
6
6
  field: NestedFieldConfig;
7
7
  control: AbstractControl;
@@ -2,6 +2,7 @@ import { AbstractControl } from '@angular/forms';
2
2
  import type { NestedFieldConfig } from '@dynamic-entity/core';
3
3
  import * as i0 from "@angular/core";
4
4
  export declare class NumberFieldComponent {
5
+ private readonly messages;
5
6
  field: NestedFieldConfig;
6
7
  control: AbstractControl;
7
8
  language: string;
@@ -1,12 +1,20 @@
1
+ import { OnDestroy } from '@angular/core';
1
2
  import { AbstractControl } from '@angular/forms';
2
3
  import type { NestedFieldConfig } from '@dynamic-entity/core';
3
4
  import * as i0 from "@angular/core";
4
- export declare class TextFieldComponent {
5
+ export declare class TextFieldComponent implements OnDestroy {
6
+ private readonly messages;
7
+ private displaySub?;
5
8
  field: NestedFieldConfig;
6
- control: AbstractControl;
9
+ set control(value: AbstractControl);
10
+ get control(): AbstractControl;
11
+ private _control;
7
12
  language: string;
8
13
  readonly: boolean;
9
14
  masked: boolean;
15
+ /** Readonly markup reads this signal so an external patch (autoPatch) is visible under OnPush. */
16
+ readonly displayValue: import("@angular/core").WritableSignal<unknown>;
17
+ ngOnDestroy(): void;
10
18
  get label(): string;
11
19
  get placeholder(): string;
12
20
  get errorMessage(): string;
@@ -1,16 +1,17 @@
1
- import { OnChanges, SimpleChanges } from '@angular/core';
1
+ import { OnChanges, OnDestroy, SimpleChanges } from '@angular/core';
2
2
  import { AbstractControl } from '@angular/forms';
3
3
  import type { NestedFieldConfig, EntityFormConfig } from '@dynamic-entity/core';
4
4
  import * as i0 from "@angular/core";
5
5
  /**
6
6
  * DynamicFieldComponent — mounts the correct field component for a given NestedFieldConfig.
7
- * Uses ViewContainerRef.createComponent() and passes inputs via setInput() (ADR-008).
7
+ * Uses ViewContainerRef.createComponent() and passes inputs via setInput(); see
8
+ * DynamicFieldComponentContract for the five inputs every field component receives.
8
9
  * Never add field-type-specific logic here — this component must remain generic.
9
10
  *
10
11
  * The 5 inputs (field, control, language, readonly, masked) are passed to every mounted
11
12
  * component uniformly — no special-casing per type.
12
13
  */
13
- export declare class DynamicFieldComponent implements OnChanges {
14
+ export declare class DynamicFieldComponent implements OnChanges, OnDestroy {
14
15
  field: NestedFieldConfig;
15
16
  control: AbstractControl;
16
17
  config: EntityFormConfig;
@@ -21,6 +22,12 @@ export declare class DynamicFieldComponent implements OnChanges {
21
22
  private readonly fieldHost;
22
23
  private readonly fieldRegistry;
23
24
  private readonly rbacService;
25
+ /**
26
+ * Passed into `createComponent` so a hosted field sees this host's injector — including
27
+ * `DynamicFormComponent`'s scoped `EntityRefSelectionService`. Without it, a standalone
28
+ * field resolves the root bus and `autoPatch` never runs.
29
+ */
30
+ private readonly injector;
24
31
  private componentRef;
25
32
  ngOnChanges(_changes: SimpleChanges): void;
26
33
  private mountField;
@@ -32,7 +39,35 @@ export declare class DynamicFieldComponent implements OnChanges {
32
39
  private warnUnresolved;
33
40
  private static readonly warnedTypes;
34
41
  /**
35
- * Pass all 5 contract inputs via ComponentRef.setInput() — uniform for all types (ADR-008).
42
+ * Re-check the hosted field component.
43
+ *
44
+ * The field components are OnPush, so they re-render on an input change, an event from
45
+ * their own template, or a signal they read. `form.markAllAsTouched()` is none of those —
46
+ * it flips `touched` from outside, and every field's error message depends on it. Without
47
+ * this, a blocked submit would mark the form touched and no error would appear.
48
+ */
49
+ refresh(): void;
50
+ /**
51
+ * Re-check the hosted component whenever its control changes from outside.
52
+ *
53
+ * The field components are OnPush, so they re-render on an input change or an event from
54
+ * their own template. A control mutated externally is neither — and that is not an edge
55
+ * case: patchForm, reset, autoPatch and patchOnTrue all do exactly that. Without this, a
56
+ * field patched from a record would keep rendering its previous value.
57
+ *
58
+ * `detectChanges()` rather than `markForCheck()`: a selection that arrives from a native
59
+ * `change` event (or Playwright) may not be followed by another Angular tick, so a dirty
60
+ * flag would never be flushed. Running CD on the hosted component now is the copy showing up.
61
+ *
62
+ * Done here rather than in each of the eighteen field components: the host owns the
63
+ * component reference, so one subscription covers every type including custom ones.
64
+ */
65
+ private watchControl;
66
+ private controlSub?;
67
+ ngOnDestroy(): void;
68
+ /**
69
+ * Pass all 5 contract inputs via ComponentRef.setInput() — uniform for all types.
70
+ * The public shape is DynamicFieldComponentContract.
36
71
  * Must use setInput(), not property assignment: the field components declare inputs with
37
72
  * definite assignment and no initializer, and the lib compiles with
38
73
  * useDefineForClassFields:false, so `'field' in instance` is false until first set.
@@ -10,7 +10,21 @@ import * as i0 from "@angular/core";
10
10
  * keyboard shortcuts (Ctrl+S, Esc), and RBAC-gated submission.
11
11
  */
12
12
  export declare class DynamicFormComponent implements OnInit, OnChanges, OnDestroy {
13
- config: EntityFormConfig;
13
+ /**
14
+ * The schema to render.
15
+ *
16
+ * An accessor pair rather than a plain field: `ngOnChanges` normalises the option shape,
17
+ * and that result is kept here instead of being written back over the input. Reassigning
18
+ * an `@Input` works, but it mutates what the parent handed us — and a parent using OnPush
19
+ * or signals would never observe the replacement anyway.
20
+ *
21
+ * Reads of `this.config` get the normalised copy once it exists, so every internal caller
22
+ * is unaffected.
23
+ */
24
+ set config(value: EntityFormConfig);
25
+ get config(): EntityFormConfig;
26
+ private rawConfig;
27
+ private normalizedConfig?;
14
28
  rules?: FormRule[];
15
29
  initialData?: Record<string, any>;
16
30
  /** Session-original values for `VALUE_CHANGED` rules. Captured from the first build when omitted. */
@@ -43,6 +57,14 @@ export declare class DynamicFormComponent implements OnInit, OnChanges, OnDestro
43
57
  formReset: EventEmitter<void>;
44
58
  /** The visible tab changed. Lets a host track it without reaching into this component. */
45
59
  activeTabChange: EventEmitter<string>;
60
+ /**
61
+ * Emitted when a `beforeSave` hook aborted the save — by returning false or throwing.
62
+ * `formSubmit` does not fire in that case.
63
+ */
64
+ saveRejected: EventEmitter<{
65
+ reason: string;
66
+ error?: unknown;
67
+ }>;
46
68
  private readonly fb;
47
69
  private readonly validatorRegistry;
48
70
  private readonly hookRegistry;
@@ -60,6 +82,10 @@ export declare class DynamicFormComponent implements OnInit, OnChanges, OnDestro
60
82
  readonly sessionBaseline: import("@angular/core").WritableSignal<Record<string, any>>;
61
83
  /** Critical fields the user has explicitly unlocked for editing. */
62
84
  readonly unlockedFields: import("@angular/core").WritableSignal<ReadonlySet<string>>;
85
+ /** The active tab's field panel, focused after a tab switch. */
86
+ private formPanel?;
87
+ /** The hosted field components, so an external control-state change can reach them. */
88
+ private fieldHosts?;
63
89
  form: FormGroup;
64
90
  private valueSub?;
65
91
  private selectionSub?;
@@ -121,6 +147,14 @@ export declare class DynamicFormComponent implements OnInit, OnChanges, OnDestro
121
147
  get ruleValidationErrors(): Record<string, string>;
122
148
  /** True when a `validation` rule is currently blocking submission. */
123
149
  get hasRuleErrors(): boolean;
150
+ /**
151
+ * True while an async validator is still running.
152
+ *
153
+ * Angular reports a control with an outstanding async check as `pending`, and `invalid` is
154
+ * false until it settles — so without this a form could be submitted in the gap before a
155
+ * uniqueness check came back.
156
+ */
157
+ get isValidating(): boolean;
124
158
  /** Single source of truth for both the submit guard and the button's disabled state. */
125
159
  get submitBlocked(): boolean;
126
160
  ngOnInit(): void;
@@ -152,6 +186,13 @@ export declare class DynamicFormComponent implements OnInit, OnChanges, OnDestro
152
186
  */
153
187
  createArrayRow(field: NestedFieldConfig, value?: unknown): AbstractControl;
154
188
  private buildArrayRow;
189
+ /**
190
+ * Mark every control touched, then re-check the field components.
191
+ *
192
+ * They are OnPush and `touched` is flipped from outside their own template, so the second
193
+ * half is what makes the error messages actually appear.
194
+ */
195
+ private markAllTouched;
155
196
  /**
156
197
  * Bring a record up to the config's `version` before it reaches the form.
157
198
  *
@@ -223,9 +264,28 @@ export declare class DynamicFormComponent implements OnInit, OnChanges, OnDestro
223
264
  private baseline;
224
265
  private sameValue;
225
266
  getControl(fieldId: string, currentTabId?: string): AbstractControl | null;
267
+ /**
268
+ * Move focus into the newly shown panel.
269
+ *
270
+ * Activating a tab replaces everything below it while focus stays on the tab button, so a
271
+ * screen-reader or keyboard user is given no indication that the content changed and must
272
+ * tab back through the strip to reach it. Deferred a frame because the panel's contents
273
+ * are rendered by the change detection this call is part of.
274
+ */
275
+ private focusActivePanel;
276
+ /** Accessible name for the field panel — it is the tabpanel for the active tab. */
277
+ get activeTabLabel(): string;
278
+ /**
279
+ * Why a referenced field is flagged.
280
+ *
281
+ * `hasDrift` was written by the builder and read by nothing at runtime, so a field whose
282
+ * source definition had changed since it was linked looked entirely normal to the person
283
+ * filling the form in.
284
+ */
285
+ driftHint(field: NestedFieldConfig): string;
226
286
  resolveTabLabel(tab: NestedTabConfig): string;
227
287
  submit(): Promise<void>;
228
288
  reset(): void;
229
289
  static ɵfac: i0.ɵɵFactoryDeclaration<DynamicFormComponent, never>;
230
- static ɵcmp: i0.ɵɵComponentDeclaration<DynamicFormComponent, "ngx-dynamic-form", never, { "config": { "alias": "config"; "required": false; }; "rules": { "alias": "rules"; "required": false; }; "initialData": { "alias": "initialData"; "required": false; }; "originalData": { "alias": "originalData"; "required": false; }; "userRoles": { "alias": "userRoles"; "required": false; }; "language": { "alias": "language"; "required": false; }; "readonly": { "alias": "readonly"; "required": false; }; "readOnlyFields": { "alias": "readOnlyFields"; "required": false; }; "showInfoBanners": { "alias": "showInfoBanners"; "required": false; }; "loading": { "alias": "loading"; "required": false; }; "error": { "alias": "error"; "required": false; }; "changeDebounceMs": { "alias": "changeDebounceMs"; "required": false; }; "preview": { "alias": "preview"; "required": false; }; }, { "formSubmit": "formSubmit"; "formChange": "formChange"; "formReset": "formReset"; "activeTabChange": "activeTabChange"; }, never, never, true, never>;
290
+ static ɵcmp: i0.ɵɵComponentDeclaration<DynamicFormComponent, "ngx-dynamic-form", never, { "config": { "alias": "config"; "required": false; }; "rules": { "alias": "rules"; "required": false; }; "initialData": { "alias": "initialData"; "required": false; }; "originalData": { "alias": "originalData"; "required": false; }; "userRoles": { "alias": "userRoles"; "required": false; }; "language": { "alias": "language"; "required": false; }; "readonly": { "alias": "readonly"; "required": false; }; "readOnlyFields": { "alias": "readOnlyFields"; "required": false; }; "showInfoBanners": { "alias": "showInfoBanners"; "required": false; }; "loading": { "alias": "loading"; "required": false; }; "error": { "alias": "error"; "required": false; }; "changeDebounceMs": { "alias": "changeDebounceMs"; "required": false; }; "preview": { "alias": "preview"; "required": false; }; }, { "formSubmit": "formSubmit"; "formChange": "formChange"; "formReset": "formReset"; "activeTabChange": "activeTabChange"; "saveRejected": "saveRejected"; }, never, never, true, never>;
231
291
  }
@@ -7,7 +7,7 @@ export interface NgxDynamicEntityConfig {
7
7
  /** Custom field type components keyed by type string */
8
8
  fieldTypes?: Record<string, Type<any>>;
9
9
  /**
10
- * Entity-ref option loaders keyed by entity key (ADR-006).
10
+ * Entity-ref option loaders keyed by entity key.
11
11
  * Each receives `{ parentValue, filters, lang }` and may return an array, Promise, or Observable.
12
12
  */
13
13
  entityRefs?: Record<string, EntityReferenceLoader>;
@@ -17,6 +17,22 @@ export interface NgxDynamicEntityConfig {
17
17
  * a loader for anything fetched, so an unused list is never loaded.
18
18
  */
19
19
  lookups?: Record<string, LookupListSource>;
20
+ /**
21
+ * Override the messages shown under invalid fields, keyed by Angular error key. A value is
22
+ * a string, or a function receiving `(language, error)` when it needs the error's detail.
23
+ * Unlisted keys keep their English default.
24
+ */
25
+ validationMessages?: Record<string, string | ((language: string, error: any) => string)>;
26
+ /**
27
+ * Async validator functions keyed by name, for checks that need a server — uniqueness,
28
+ * availability, a remote rule. Name them from a schema with
29
+ * `validators: { customAsync: ['uniqueEmail'] }`.
30
+ *
31
+ * Each is an Angular `AsyncValidatorFn`: it receives the control and returns a Promise or
32
+ * Observable of `ValidationErrors | null`. The control is `pending` while it runs, and the
33
+ * form cannot be submitted until every pending check settles.
34
+ */
35
+ asyncValidators?: Record<string, any>;
20
36
  /** Custom validator functions keyed by validator name */
21
37
  validators?: Record<string, any>;
22
38
  /** Hook functions keyed by hook name */
@@ -3,7 +3,7 @@ import * as i0 from "@angular/core";
3
3
  /**
4
4
  * CascadeDataService — loads entity-reference options and applies parent→child cascades.
5
5
  *
6
- * Loaders come from the registry only (ADR-006). This service owns the three things a
6
+ * Loaders come from the registry only. This service owns the three things a
7
7
  * field component should not: resolving the loader, coercing whatever it returned
8
8
  * (array | Promise | Observable) into a Promise, and filtering by the parent's value.
9
9
  */
@@ -2,7 +2,7 @@ import type { EntityReferenceLoader } from '@dynamic-entity/core';
2
2
  import * as i0 from "@angular/core";
3
3
  /**
4
4
  * EntityRefRegistryService — resolves entity-ref option loader functions.
5
- * ADR-006: Entity-ref options come ONLY from this registry.
5
+ * Entity-ref options come ONLY from this registry.
6
6
  * Never pass loaders via @Input() on field components.
7
7
  *
8
8
  * Consumers register loaders via provideNgxDynamicEntity({ entityRefs: { clients: () => [...] } }).
@@ -81,7 +81,7 @@ export interface ChoiceOptionHost {
81
81
  * Point a choice component's `options` signal at the right source (parity plan §6.3).
82
82
  *
83
83
  * Called from the `field` / `language` input setters of `dropdown`, `radio` and `multiSelect` —
84
- * the wiring is theirs, the decision is the service's, which is what keeps the ADR-008
84
+ * the wiring is theirs, the decision is the service's, which is what keeps the field
85
85
  * five-input contract intact: options are never pushed in as a sixth input.
86
86
  *
87
87
  * Setters rather than `ngOnChanges` on purpose. `ngOnChanges` only fires for inputs Angular
@@ -0,0 +1,43 @@
1
+ import type { ValidationErrors } from '@angular/forms';
2
+ import * as i0 from "@angular/core";
3
+ /**
4
+ * Resolves an Angular `ValidationErrors` object to the message shown under a field.
5
+ *
6
+ * These strings used to be hardcoded English literals inside each field component, so a
7
+ * German form meant re-implementing every field type — the labels honoured `language` while
8
+ * the errors underneath them did not.
9
+ *
10
+ * A message is a plain string, or a function when it needs the error's own detail
11
+ * (`minlength.requiredLength`, `min.min`, and so on). The active `language` is passed
12
+ * through, so one registration can serve every locale:
13
+ *
14
+ * ```typescript
15
+ * provideNgxDynamicEntity({
16
+ * validationMessages: {
17
+ * required: lang => (lang === 'de' ? 'Pflichtfeld.' : 'This field is required.'),
18
+ * minlength: (lang, err) => `Mindestens ${err['requiredLength']} Zeichen.`,
19
+ * },
20
+ * });
21
+ * ```
22
+ *
23
+ * Unregistered keys fall back to the defaults below, so overriding one message does not
24
+ * mean supplying them all.
25
+ */
26
+ export type ValidationMessage = string | ((language: string, error: any) => string);
27
+ /** The built-in English messages. Every key here can be overridden individually. */
28
+ export declare const DEFAULT_VALIDATION_MESSAGES: Record<string, ValidationMessage>;
29
+ export declare class ValidationMessagesService {
30
+ private readonly overrides;
31
+ /**
32
+ * The message for the first error present, in the order the caller lists.
33
+ *
34
+ * Order is the caller's because it is field-specific: a dropdown wants
35
+ * `requiredSelection` where a text input wants `required`, and the first match wins so the
36
+ * most specific message is the one shown.
37
+ */
38
+ resolve(errors: ValidationErrors | null | undefined, language: string, order: readonly (string | readonly [errorKey: string, messageKey: string])[], fallbackKey?: string): string;
39
+ /** One message by key, honouring an override before the default. */
40
+ messageFor(key: string, language: string, error: unknown): string;
41
+ static ɵfac: i0.ɵɵFactoryDeclaration<ValidationMessagesService, never>;
42
+ static ɵprov: i0.ɵɵInjectableDeclaration<ValidationMessagesService>;
43
+ }
@@ -1,4 +1,4 @@
1
- import { ValidatorFn } from '@angular/forms';
1
+ import { AsyncValidatorFn, ValidatorFn } from '@angular/forms';
2
2
  import type { FieldValidators } from '@dynamic-entity/core';
3
3
  import * as i0 from "@angular/core";
4
4
  /**
@@ -6,6 +6,7 @@ import * as i0 from "@angular/core";
6
6
  */
7
7
  export declare class ValidatorRegistryService {
8
8
  private readonly consumerRegistry;
9
+ private readonly asyncRegistry;
9
10
  /**
10
11
  * Resolve a validator key string to a ValidatorFn.
11
12
  * Supports parameterized built-ins like 'min:0', 'maxLength:255'.
@@ -15,6 +16,17 @@ export declare class ValidatorRegistryService {
15
16
  resolveAll(validatorKeys?: string[]): ValidatorFn[];
16
17
  /** Resolve a FieldValidators object or string[] into ValidatorFn[] */
17
18
  resolveFromConfig(config?: FieldValidators | string[]): ValidatorFn[];
19
+ /**
20
+ * Resolve the async validators a field names, in `validators.customAsync`.
21
+ *
22
+ * Async validators are deliberately a separate registry and a separate config key: Angular
23
+ * attaches them through `setAsyncValidators`, runs them only once the synchronous ones
24
+ * pass, and holds the control in `pending` until they settle. Mixing them into the
25
+ * synchronous list would silently never run them.
26
+ *
27
+ * An unknown name is skipped rather than throwing, matching the synchronous behaviour.
28
+ */
29
+ resolveAsyncFromConfig(config?: FieldValidators | string[]): AsyncValidatorFn[];
18
30
  static ɵfac: i0.ɵɵFactoryDeclaration<ValidatorRegistryService, never>;
19
31
  static ɵprov: i0.ɵɵInjectableDeclaration<ValidatorRegistryService>;
20
32
  }
@@ -1,7 +1,7 @@
1
1
  import { InjectionToken } from '@angular/core';
2
2
  import type { Type } from '@angular/core';
3
3
  import { type EntityRefCacheStore } from '../services/entity-ref-cache';
4
- /** Roles that see XXXXXXXXX for masked fields (ADR-003) */
4
+ /** Roles that see XXXXXXXXX for masked fields. Presentational only — enforce authz server-side. */
5
5
  export declare const MASKED_ROLES: InjectionToken<string[]>;
6
6
  /**
7
7
  * Consumer **overrides**: fieldType string → Angular component class.
@@ -58,8 +58,25 @@ export declare const LOOKUP_REGISTRY: InjectionToken<Map<string, LookupListSourc
58
58
  * })
59
59
  */
60
60
  export declare const RECORD_MIGRATIONS: InjectionToken<RecordMigration[]>;
61
+ /**
62
+ * Overrides for the messages shown under an invalid field, keyed by Angular error key
63
+ * (`required`, `minlength`, `pattern`, …) plus a few field-specific keys.
64
+ *
65
+ * The built-in messages are English literals. Registering here is what makes a non-English
66
+ * form possible without re-implementing every field component; unlisted keys keep their
67
+ * default, so overriding one does not mean supplying them all.
68
+ */
69
+ export declare const VALIDATION_MESSAGES: InjectionToken<Record<string, any>>;
61
70
  /** Registry: validator key → ValidatorFn */
62
71
  export declare const VALIDATOR_REGISTRY: InjectionToken<Map<string, any>>;
72
+ /**
73
+ * Registry: validator key → AsyncValidatorFn.
74
+ *
75
+ * Kept separate from VALIDATOR_REGISTRY because Angular applies the two differently: an async
76
+ * validator runs only once the synchronous ones pass, and puts the control into `pending`
77
+ * while it does. Name them from a schema with `validators: { customAsync: ['uniqueEmail'] }`.
78
+ */
79
+ export declare const ASYNC_VALIDATOR_REGISTRY: InjectionToken<Map<string, any>>;
63
80
  /**
64
81
  * A registered hook: receives the payload, returns it (or a replacement), sync or async.
65
82
  *
@@ -104,10 +121,6 @@ export declare const UPLOAD_HANDLER: InjectionToken<FileUploadHandler>;
104
121
  * { provide: ENTITY_REF_CACHE_STORE, useClass: SessionStorageRefCache }
105
122
  */
106
123
  export declare const ENTITY_REF_CACHE_STORE: InjectionToken<EntityRefCacheStore>;
107
- /**
108
- * Consumer-provided predicate function to check whether user roles can edit system default tabs or fields.
109
- * Defaults to allowing edits if not provided.
110
- */
111
124
  /**
112
125
  * Consumer-provided predicate function to check whether user roles can edit system default tabs or fields.
113
126
  * Defaults to allowing edits if not provided.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ngx-dynamic-entity",
3
- "version": "1.1.0",
3
+ "version": "1.2.0",
4
4
  "description": "Angular standalone UI library for rendering dynamic entity forms.",
5
5
  "//typings": "Local workspace resolution only: ng-packagr overwrites this with the correct in-tarball path ('index.d.ts') when generating dist/package.json. Do NOT add a 'main' field here — ng-packagr does not generate one, so it would be copied verbatim and resolve to dist/dist/... in the published package.",
6
6
  "typings": "index.d.ts",
@@ -8,7 +8,7 @@
8
8
  "@angular/common": "^17.0.0 || ^18.0.0 || ^19.0.0 || ^20.0.0 || ^21.0.0 || ^22.0.0",
9
9
  "@angular/core": "^17.0.0 || ^18.0.0 || ^19.0.0 || ^20.0.0 || ^21.0.0 || ^22.0.0",
10
10
  "@angular/forms": "^17.0.0 || ^18.0.0 || ^19.0.0 || ^20.0.0 || ^21.0.0 || ^22.0.0",
11
- "@dynamic-entity/core": "^1.1.0",
11
+ "@dynamic-entity/core": "^1.2.0",
12
12
  "rxjs": "^7.0.0"
13
13
  },
14
14
  "keywords": [
package/public-api.d.ts CHANGED
@@ -13,9 +13,11 @@ export * from './lib/services/lookup-registry.service';
13
13
  export * from './lib/services/rbac.service';
14
14
  export * from './lib/services/rules-evaluation.service';
15
15
  export * from './lib/services/config-source.service';
16
+ export * from './lib/services/validation-messages.service';
16
17
  export * from './lib/form/dynamic-form.component';
17
18
  export * from './lib/form/dynamic-record-form.component';
18
19
  export * from './lib/form/dynamic-field/dynamic-field.component';
20
+ export * from './lib/field-types/field-component.contract';
19
21
  export * from './lib/field-types/text-field.component';
20
22
  export * from './lib/field-types/textarea-field.component';
21
23
  export * from './lib/field-types/number-field.component';