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,5 +1,5 @@
1
1
  import * as i0 from '@angular/core';
2
- import { InjectionToken, makeEnvironmentProviders, Input, Component, signal, inject, Injectable, DestroyRef, isDevMode, ViewContainerRef, ViewChild, forwardRef, Optional, Inject, EventEmitter, computed, HostListener, Output } from '@angular/core';
2
+ import { InjectionToken, makeEnvironmentProviders, inject, Injectable, signal, Input, ChangeDetectionStrategy, Component, DestroyRef, Injector, isDevMode, ViewContainerRef, ViewChild, forwardRef, ChangeDetectorRef, Optional, Inject, EventEmitter, computed, HostListener, ViewChildren, Output } from '@angular/core';
3
3
  import * as i1 from '@angular/forms';
4
4
  import { ReactiveFormsModule, Validators, FormBuilder, FormArray, FormGroup } from '@angular/forms';
5
5
  import { resolveLabel, normalizeLookupValues, lookupValuesToOptions, valuesMatch, getOptionStoredValue, resolveOptionLabel, resolveOptionValue, buildReferenceCacheKey, normalizeReferenceOptions, entityKeyFromCacheKey, applyCascadeFilter, resolveEffectiveMask, shouldMaskField, findTab, fileRefName, evaluateFormRules, filterRulesForTab, evaluateFieldVisibility, normalizeConfigOptions, migrateRecord, getTabData, getValueByPath, setTabData, setValueByPath, normalizeArrayStructures, getTabPath, applyAutoPatch, applyPatchOnTrue, formatDisplayValue } from '@dynamic-entity/core';
@@ -29,7 +29,7 @@ class InMemoryEntityRefCacheStore {
29
29
  }
30
30
  }
31
31
 
32
- /** Roles that see XXXXXXXXX for masked fields (ADR-003) */
32
+ /** Roles that see XXXXXXXXX for masked fields. Presentational only — enforce authz server-side. */
33
33
  const MASKED_ROLES = new InjectionToken('MASKED_ROLES');
34
34
  /**
35
35
  * Consumer **overrides**: fieldType string → Angular component class.
@@ -86,8 +86,25 @@ const LOOKUP_REGISTRY = new InjectionToken('LOOKUP_REGISTRY');
86
86
  * })
87
87
  */
88
88
  const RECORD_MIGRATIONS = new InjectionToken('RECORD_MIGRATIONS');
89
+ /**
90
+ * Overrides for the messages shown under an invalid field, keyed by Angular error key
91
+ * (`required`, `minlength`, `pattern`, …) plus a few field-specific keys.
92
+ *
93
+ * The built-in messages are English literals. Registering here is what makes a non-English
94
+ * form possible without re-implementing every field component; unlisted keys keep their
95
+ * default, so overriding one does not mean supplying them all.
96
+ */
97
+ const VALIDATION_MESSAGES = new InjectionToken('VALIDATION_MESSAGES');
89
98
  /** Registry: validator key → ValidatorFn */
90
99
  const VALIDATOR_REGISTRY = new InjectionToken('VALIDATOR_REGISTRY');
100
+ /**
101
+ * Registry: validator key → AsyncValidatorFn.
102
+ *
103
+ * Kept separate from VALIDATOR_REGISTRY because Angular applies the two differently: an async
104
+ * validator runs only once the synchronous ones pass, and puts the control into `pending`
105
+ * while it does. Name them from a schema with `validators: { customAsync: ['uniqueEmail'] }`.
106
+ */
107
+ const ASYNC_VALIDATOR_REGISTRY = new InjectionToken('ASYNC_VALIDATOR_REGISTRY');
91
108
  /** Registry: hook key → async (data, context) => data */
92
109
  const HOOK_REGISTRY = new InjectionToken('HOOK_REGISTRY');
93
110
  /**
@@ -125,10 +142,6 @@ const UPLOAD_HANDLER = new InjectionToken('UPLOAD_HANDLER');
125
142
  * { provide: ENTITY_REF_CACHE_STORE, useClass: SessionStorageRefCache }
126
143
  */
127
144
  const ENTITY_REF_CACHE_STORE = new InjectionToken('ENTITY_REF_CACHE_STORE', { providedIn: 'root', factory: () => new InMemoryEntityRefCacheStore() });
128
- /**
129
- * Consumer-provided predicate function to check whether user roles can edit system default tabs or fields.
130
- * Defaults to allowing edits if not provided.
131
- */
132
145
  /**
133
146
  * Consumer-provided predicate function to check whether user roles can edit system default tabs or fields.
134
147
  * Defaults to allowing edits if not provided.
@@ -156,16 +169,90 @@ const provideNgxDynamicEntity = (config = {}) => makeEnvironmentProviders([
156
169
  { provide: ENTITY_REF_REGISTRY, useValue: new Map(Object.entries(config.entityRefs ?? {})) },
157
170
  { provide: LOOKUP_REGISTRY, useValue: new Map(Object.entries(config.lookups ?? {})) },
158
171
  { provide: VALIDATOR_REGISTRY, useValue: new Map(Object.entries(config.validators ?? {})) },
172
+ {
173
+ provide: ASYNC_VALIDATOR_REGISTRY,
174
+ useValue: new Map(Object.entries(config.asyncValidators ?? {})),
175
+ },
159
176
  { provide: HOOK_REGISTRY, useValue: new Map(Object.entries(config.hooks ?? {})) },
160
177
  { provide: RECORD_MIGRATIONS, useValue: config.migrations ?? [] },
178
+ { provide: VALIDATION_MESSAGES, useValue: config.validationMessages ?? {} },
161
179
  ]);
162
180
 
181
+ /** The built-in English messages. Every key here can be overridden individually. */
182
+ const DEFAULT_VALIDATION_MESSAGES = {
183
+ required: 'This field is required.',
184
+ requiredSelection: 'Please select an option.',
185
+ email: 'Please enter a valid email address.',
186
+ pattern: 'Invalid format.',
187
+ minlength: (_lang, err) => `Minimum ${err?.requiredLength} characters required.`,
188
+ maxlength: (_lang, err) => `Maximum ${err?.requiredLength} characters allowed.`,
189
+ min: (_lang, err) => `Value must be at least ${err?.min}.`,
190
+ max: (_lang, err) => `Value must not exceed ${err?.max}.`,
191
+ invalid: 'Invalid value.',
192
+ invalidNumber: 'Invalid number.',
193
+ invalidSelection: 'Invalid selection.',
194
+ };
195
+ class ValidationMessagesService {
196
+ overrides = inject(VALIDATION_MESSAGES, { optional: true }) ?? {};
197
+ /**
198
+ * The message for the first error present, in the order the caller lists.
199
+ *
200
+ * Order is the caller's because it is field-specific: a dropdown wants
201
+ * `requiredSelection` where a text input wants `required`, and the first match wins so the
202
+ * most specific message is the one shown.
203
+ */
204
+ resolve(errors, language, order, fallbackKey = 'invalid') {
205
+ if (!errors)
206
+ return '';
207
+ for (const entry of order) {
208
+ // A tuple maps an Angular error key to a different message key: a dropdown raises the
209
+ // standard `required` error but reads better as "Please select an option", and both
210
+ // messages stay independently overridable.
211
+ const [errorKey, messageKey] = typeof entry === 'string' ? [entry, entry] : entry;
212
+ // `errors[errorKey]` is usually an object of detail, but `required` is simply `true`.
213
+ if (errors[errorKey] === undefined)
214
+ continue;
215
+ return this.messageFor(messageKey, language, errors[errorKey]);
216
+ }
217
+ return this.messageFor(fallbackKey, language, null);
218
+ }
219
+ /** One message by key, honouring an override before the default. */
220
+ messageFor(key, language, error) {
221
+ const message = this.overrides[key] ?? DEFAULT_VALIDATION_MESSAGES[key];
222
+ if (message === undefined)
223
+ return '';
224
+ return typeof message === 'function' ? message(language, error) : message;
225
+ }
226
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: ValidationMessagesService, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
227
+ static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: ValidationMessagesService, providedIn: 'root' });
228
+ }
229
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: ValidationMessagesService, decorators: [{
230
+ type: Injectable,
231
+ args: [{ providedIn: 'root' }]
232
+ }] });
233
+
163
234
  class TextFieldComponent {
235
+ messages = inject(ValidationMessagesService);
236
+ displaySub;
164
237
  field;
165
- control;
238
+ set control(value) {
239
+ this._control = value;
240
+ this.displaySub?.unsubscribe();
241
+ this.displayValue.set(value?.value ?? null);
242
+ this.displaySub = value?.valueChanges.subscribe(v => this.displayValue.set(v));
243
+ }
244
+ get control() {
245
+ return this._control;
246
+ }
247
+ _control;
166
248
  language = 'en';
167
249
  readonly = false;
168
250
  masked = false;
251
+ /** Readonly markup reads this signal so an external patch (autoPatch) is visible under OnPush. */
252
+ displayValue = signal(null);
253
+ ngOnDestroy() {
254
+ this.displaySub?.unsubscribe();
255
+ }
169
256
  get label() {
170
257
  return resolveLabel(this.field?.label, this.language);
171
258
  }
@@ -175,18 +262,7 @@ class TextFieldComponent {
175
262
  get errorMessage() {
176
263
  if (!this.control || !this.control.errors || !this.control.touched)
177
264
  return '';
178
- const errs = this.control.errors;
179
- if (errs['required'])
180
- return 'This field is required.';
181
- if (errs['email'])
182
- return 'Please enter a valid email address.';
183
- if (errs['minlength'])
184
- return `Minimum ${errs['minlength'].requiredLength} characters required.`;
185
- if (errs['maxlength'])
186
- return `Maximum ${errs['maxlength'].requiredLength} characters allowed.`;
187
- if (errs['pattern'])
188
- return 'Invalid format.';
189
- return 'Invalid value.';
265
+ return this.messages.resolve(this.control.errors, this.language, ['required', 'email', 'minlength', 'maxlength', 'pattern']);
190
266
  }
191
267
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: TextFieldComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
192
268
  static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "17.3.12", type: TextFieldComponent, isStandalone: true, selector: "ngx-text-field", inputs: { field: "field", control: "control", language: "language", readonly: "readonly", masked: "masked" }, ngImport: i0, template: `
@@ -201,7 +277,7 @@ class TextFieldComponent {
201
277
  @if (masked) {
202
278
  <span class="ngx-field__value ngx-field__value--masked" [attr.data-testid]="'field-' + field.id + '-masked'">XXXXXXXXX</span>
203
279
  } @else if (readonly) {
204
- <span class="ngx-field__value" [attr.data-testid]="'field-' + field.id + '-value'">{{ control.value ?? '—' }}</span>
280
+ <span class="ngx-field__value" [attr.data-testid]="'field-' + field.id + '-value'">{{ displayValue() ?? '—' }}</span>
205
281
  } @else {
206
282
  <input
207
283
  [id]="field.id"
@@ -218,11 +294,12 @@ class TextFieldComponent {
218
294
  }
219
295
  }
220
296
  </div>
221
- `, isInline: true, dependencies: [{ kind: "ngmodule", type: ReactiveFormsModule }, { kind: "directive", type: i1.DefaultValueAccessor, selector: "input:not([type=checkbox])[formControlName],textarea[formControlName],input:not([type=checkbox])[formControl],textarea[formControl],input:not([type=checkbox])[ngModel],textarea[ngModel],[ngDefaultControl]" }, { kind: "directive", type: i1.NgControlStatus, selector: "[formControlName],[ngModel],[formControl]" }, { kind: "directive", type: i1.FormControlDirective, selector: "[formControl]", inputs: ["formControl", "disabled", "ngModel"], outputs: ["ngModelChange"], exportAs: ["ngForm"] }] });
297
+ `, isInline: true, dependencies: [{ kind: "ngmodule", type: ReactiveFormsModule }, { kind: "directive", type: i1.DefaultValueAccessor, selector: "input:not([type=checkbox])[formControlName],textarea[formControlName],input:not([type=checkbox])[formControl],textarea[formControl],input:not([type=checkbox])[ngModel],textarea[ngModel],[ngDefaultControl]" }, { kind: "directive", type: i1.NgControlStatus, selector: "[formControlName],[ngModel],[formControl]" }, { kind: "directive", type: i1.FormControlDirective, selector: "[formControl]", inputs: ["formControl", "disabled", "ngModel"], outputs: ["ngModelChange"], exportAs: ["ngForm"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
222
298
  }
223
299
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: TextFieldComponent, decorators: [{
224
300
  type: Component,
225
301
  args: [{
302
+ changeDetection: ChangeDetectionStrategy.OnPush,
226
303
  selector: 'ngx-text-field',
227
304
  standalone: true,
228
305
  imports: [ReactiveFormsModule],
@@ -238,7 +315,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImpo
238
315
  @if (masked) {
239
316
  <span class="ngx-field__value ngx-field__value--masked" [attr.data-testid]="'field-' + field.id + '-masked'">XXXXXXXXX</span>
240
317
  } @else if (readonly) {
241
- <span class="ngx-field__value" [attr.data-testid]="'field-' + field.id + '-value'">{{ control.value ?? '—' }}</span>
318
+ <span class="ngx-field__value" [attr.data-testid]="'field-' + field.id + '-value'">{{ displayValue() ?? '—' }}</span>
242
319
  } @else {
243
320
  <input
244
321
  [id]="field.id"
@@ -304,11 +381,12 @@ class TextareaFieldComponent {
304
381
  }
305
382
  }
306
383
  </div>
307
- `, isInline: true, dependencies: [{ kind: "ngmodule", type: ReactiveFormsModule }, { kind: "directive", type: i1.DefaultValueAccessor, selector: "input:not([type=checkbox])[formControlName],textarea[formControlName],input:not([type=checkbox])[formControl],textarea[formControl],input:not([type=checkbox])[ngModel],textarea[ngModel],[ngDefaultControl]" }, { kind: "directive", type: i1.NgControlStatus, selector: "[formControlName],[ngModel],[formControl]" }, { kind: "directive", type: i1.FormControlDirective, selector: "[formControl]", inputs: ["formControl", "disabled", "ngModel"], outputs: ["ngModelChange"], exportAs: ["ngForm"] }] });
384
+ `, isInline: true, dependencies: [{ kind: "ngmodule", type: ReactiveFormsModule }, { kind: "directive", type: i1.DefaultValueAccessor, selector: "input:not([type=checkbox])[formControlName],textarea[formControlName],input:not([type=checkbox])[formControl],textarea[formControl],input:not([type=checkbox])[ngModel],textarea[ngModel],[ngDefaultControl]" }, { kind: "directive", type: i1.NgControlStatus, selector: "[formControlName],[ngModel],[formControl]" }, { kind: "directive", type: i1.FormControlDirective, selector: "[formControl]", inputs: ["formControl", "disabled", "ngModel"], outputs: ["ngModelChange"], exportAs: ["ngForm"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
308
385
  }
309
386
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: TextareaFieldComponent, decorators: [{
310
387
  type: Component,
311
388
  args: [{
389
+ changeDetection: ChangeDetectionStrategy.OnPush,
312
390
  selector: 'ngx-textarea-field',
313
391
  standalone: true,
314
392
  imports: [ReactiveFormsModule],
@@ -349,6 +427,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImpo
349
427
  }] } });
350
428
 
351
429
  class NumberFieldComponent {
430
+ messages = inject(ValidationMessagesService);
352
431
  field;
353
432
  control;
354
433
  language = 'en';
@@ -363,14 +442,7 @@ class NumberFieldComponent {
363
442
  get errorMessage() {
364
443
  if (!this.control || !this.control.errors || !this.control.touched)
365
444
  return '';
366
- const errs = this.control.errors;
367
- if (errs['required'])
368
- return 'This field is required.';
369
- if (errs['min'])
370
- return `Value must be at least ${errs['min'].min}.`;
371
- if (errs['max'])
372
- return `Value must not exceed ${errs['max'].max}.`;
373
- return 'Invalid number.';
445
+ return this.messages.resolve(this.control.errors, this.language, ['required', 'min', 'max'], 'invalidNumber');
374
446
  }
375
447
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: NumberFieldComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
376
448
  static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "17.3.12", type: NumberFieldComponent, isStandalone: true, selector: "ngx-number-field", inputs: { field: "field", control: "control", language: "language", readonly: "readonly", masked: "masked" }, ngImport: i0, template: `
@@ -405,11 +477,12 @@ class NumberFieldComponent {
405
477
  }
406
478
  }
407
479
  </div>
408
- `, isInline: true, dependencies: [{ kind: "ngmodule", type: ReactiveFormsModule }, { kind: "directive", type: i1.DefaultValueAccessor, selector: "input:not([type=checkbox])[formControlName],textarea[formControlName],input:not([type=checkbox])[formControl],textarea[formControl],input:not([type=checkbox])[ngModel],textarea[ngModel],[ngDefaultControl]" }, { kind: "directive", type: i1.NumberValueAccessor, selector: "input[type=number][formControlName],input[type=number][formControl],input[type=number][ngModel]" }, { kind: "directive", type: i1.NgControlStatus, selector: "[formControlName],[ngModel],[formControl]" }, { kind: "directive", type: i1.FormControlDirective, selector: "[formControl]", inputs: ["formControl", "disabled", "ngModel"], outputs: ["ngModelChange"], exportAs: ["ngForm"] }] });
480
+ `, isInline: true, dependencies: [{ kind: "ngmodule", type: ReactiveFormsModule }, { kind: "directive", type: i1.DefaultValueAccessor, selector: "input:not([type=checkbox])[formControlName],textarea[formControlName],input:not([type=checkbox])[formControl],textarea[formControl],input:not([type=checkbox])[ngModel],textarea[ngModel],[ngDefaultControl]" }, { kind: "directive", type: i1.NumberValueAccessor, selector: "input[type=number][formControlName],input[type=number][formControl],input[type=number][ngModel]" }, { kind: "directive", type: i1.NgControlStatus, selector: "[formControlName],[ngModel],[formControl]" }, { kind: "directive", type: i1.FormControlDirective, selector: "[formControl]", inputs: ["formControl", "disabled", "ngModel"], outputs: ["ngModelChange"], exportAs: ["ngForm"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
409
481
  }
410
482
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: NumberFieldComponent, decorators: [{
411
483
  type: Component,
412
484
  args: [{
485
+ changeDetection: ChangeDetectionStrategy.OnPush,
413
486
  selector: 'ngx-number-field',
414
487
  standalone: true,
415
488
  imports: [ReactiveFormsModule],
@@ -508,11 +581,12 @@ class CurrencyFieldComponent {
508
581
  }
509
582
  }
510
583
  </div>
511
- `, isInline: true, dependencies: [{ kind: "ngmodule", type: ReactiveFormsModule }, { kind: "directive", type: i1.DefaultValueAccessor, selector: "input:not([type=checkbox])[formControlName],textarea[formControlName],input:not([type=checkbox])[formControl],textarea[formControl],input:not([type=checkbox])[ngModel],textarea[ngModel],[ngDefaultControl]" }, { kind: "directive", type: i1.NumberValueAccessor, selector: "input[type=number][formControlName],input[type=number][formControl],input[type=number][ngModel]" }, { kind: "directive", type: i1.NgControlStatus, selector: "[formControlName],[ngModel],[formControl]" }, { kind: "directive", type: i1.FormControlDirective, selector: "[formControl]", inputs: ["formControl", "disabled", "ngModel"], outputs: ["ngModelChange"], exportAs: ["ngForm"] }] });
584
+ `, isInline: true, dependencies: [{ kind: "ngmodule", type: ReactiveFormsModule }, { kind: "directive", type: i1.DefaultValueAccessor, selector: "input:not([type=checkbox])[formControlName],textarea[formControlName],input:not([type=checkbox])[formControl],textarea[formControl],input:not([type=checkbox])[ngModel],textarea[ngModel],[ngDefaultControl]" }, { kind: "directive", type: i1.NumberValueAccessor, selector: "input[type=number][formControlName],input[type=number][formControl],input[type=number][ngModel]" }, { kind: "directive", type: i1.NgControlStatus, selector: "[formControlName],[ngModel],[formControl]" }, { kind: "directive", type: i1.FormControlDirective, selector: "[formControl]", inputs: ["formControl", "disabled", "ngModel"], outputs: ["ngModelChange"], exportAs: ["ngForm"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
512
585
  }
513
586
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: CurrencyFieldComponent, decorators: [{
514
587
  type: Component,
515
588
  args: [{
589
+ changeDetection: ChangeDetectionStrategy.OnPush,
516
590
  selector: 'ngx-currency-field',
517
591
  standalone: true,
518
592
  imports: [ReactiveFormsModule],
@@ -599,11 +673,12 @@ class EmailFieldComponent {
599
673
  }
600
674
  }
601
675
  </div>
602
- `, isInline: true, dependencies: [{ kind: "ngmodule", type: ReactiveFormsModule }, { kind: "directive", type: i1.DefaultValueAccessor, selector: "input:not([type=checkbox])[formControlName],textarea[formControlName],input:not([type=checkbox])[formControl],textarea[formControl],input:not([type=checkbox])[ngModel],textarea[ngModel],[ngDefaultControl]" }, { kind: "directive", type: i1.NgControlStatus, selector: "[formControlName],[ngModel],[formControl]" }, { kind: "directive", type: i1.FormControlDirective, selector: "[formControl]", inputs: ["formControl", "disabled", "ngModel"], outputs: ["ngModelChange"], exportAs: ["ngForm"] }] });
676
+ `, isInline: true, dependencies: [{ kind: "ngmodule", type: ReactiveFormsModule }, { kind: "directive", type: i1.DefaultValueAccessor, selector: "input:not([type=checkbox])[formControlName],textarea[formControlName],input:not([type=checkbox])[formControl],textarea[formControl],input:not([type=checkbox])[ngModel],textarea[ngModel],[ngDefaultControl]" }, { kind: "directive", type: i1.NgControlStatus, selector: "[formControlName],[ngModel],[formControl]" }, { kind: "directive", type: i1.FormControlDirective, selector: "[formControl]", inputs: ["formControl", "disabled", "ngModel"], outputs: ["ngModelChange"], exportAs: ["ngForm"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
603
677
  }
604
678
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: EmailFieldComponent, decorators: [{
605
679
  type: Component,
606
680
  args: [{
681
+ changeDetection: ChangeDetectionStrategy.OnPush,
607
682
  selector: 'ngx-email-field',
608
683
  standalone: true,
609
684
  imports: [ReactiveFormsModule],
@@ -701,11 +776,12 @@ class PasswordFieldComponent {
701
776
  }
702
777
  }
703
778
  </div>
704
- `, isInline: true, dependencies: [{ kind: "ngmodule", type: ReactiveFormsModule }, { kind: "directive", type: i1.DefaultValueAccessor, selector: "input:not([type=checkbox])[formControlName],textarea[formControlName],input:not([type=checkbox])[formControl],textarea[formControl],input:not([type=checkbox])[ngModel],textarea[ngModel],[ngDefaultControl]" }, { kind: "directive", type: i1.NgControlStatus, selector: "[formControlName],[ngModel],[formControl]" }, { kind: "directive", type: i1.FormControlDirective, selector: "[formControl]", inputs: ["formControl", "disabled", "ngModel"], outputs: ["ngModelChange"], exportAs: ["ngForm"] }] });
779
+ `, isInline: true, dependencies: [{ kind: "ngmodule", type: ReactiveFormsModule }, { kind: "directive", type: i1.DefaultValueAccessor, selector: "input:not([type=checkbox])[formControlName],textarea[formControlName],input:not([type=checkbox])[formControl],textarea[formControl],input:not([type=checkbox])[ngModel],textarea[ngModel],[ngDefaultControl]" }, { kind: "directive", type: i1.NgControlStatus, selector: "[formControlName],[ngModel],[formControl]" }, { kind: "directive", type: i1.FormControlDirective, selector: "[formControl]", inputs: ["formControl", "disabled", "ngModel"], outputs: ["ngModelChange"], exportAs: ["ngForm"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
705
780
  }
706
781
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: PasswordFieldComponent, decorators: [{
707
782
  type: Component,
708
783
  args: [{
784
+ changeDetection: ChangeDetectionStrategy.OnPush,
709
785
  selector: 'ngx-password-field',
710
786
  standalone: true,
711
787
  imports: [ReactiveFormsModule],
@@ -787,11 +863,12 @@ class CheckboxFieldComponent {
787
863
  </label>
788
864
  }
789
865
  </div>
790
- `, isInline: true, dependencies: [{ kind: "ngmodule", type: ReactiveFormsModule }, { kind: "directive", type: i1.CheckboxControlValueAccessor, selector: "input[type=checkbox][formControlName],input[type=checkbox][formControl],input[type=checkbox][ngModel]" }, { kind: "directive", type: i1.NgControlStatus, selector: "[formControlName],[ngModel],[formControl]" }, { kind: "directive", type: i1.FormControlDirective, selector: "[formControl]", inputs: ["formControl", "disabled", "ngModel"], outputs: ["ngModelChange"], exportAs: ["ngForm"] }] });
866
+ `, isInline: true, dependencies: [{ kind: "ngmodule", type: ReactiveFormsModule }, { kind: "directive", type: i1.CheckboxControlValueAccessor, selector: "input[type=checkbox][formControlName],input[type=checkbox][formControl],input[type=checkbox][ngModel]" }, { kind: "directive", type: i1.NgControlStatus, selector: "[formControlName],[ngModel],[formControl]" }, { kind: "directive", type: i1.FormControlDirective, selector: "[formControl]", inputs: ["formControl", "disabled", "ngModel"], outputs: ["ngModelChange"], exportAs: ["ngForm"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
791
867
  }
792
868
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: CheckboxFieldComponent, decorators: [{
793
869
  type: Component,
794
870
  args: [{
871
+ changeDetection: ChangeDetectionStrategy.OnPush,
795
872
  selector: 'ngx-checkbox-field',
796
873
  standalone: true,
797
874
  imports: [ReactiveFormsModule],
@@ -875,11 +952,12 @@ class BooleanFieldComponent {
875
952
  </div>
876
953
  }
877
954
  </div>
878
- `, isInline: true, dependencies: [{ kind: "ngmodule", type: ReactiveFormsModule }, { kind: "directive", type: i1.CheckboxControlValueAccessor, selector: "input[type=checkbox][formControlName],input[type=checkbox][formControl],input[type=checkbox][ngModel]" }, { kind: "directive", type: i1.NgControlStatus, selector: "[formControlName],[ngModel],[formControl]" }, { kind: "directive", type: i1.FormControlDirective, selector: "[formControl]", inputs: ["formControl", "disabled", "ngModel"], outputs: ["ngModelChange"], exportAs: ["ngForm"] }] });
955
+ `, isInline: true, dependencies: [{ kind: "ngmodule", type: ReactiveFormsModule }, { kind: "directive", type: i1.CheckboxControlValueAccessor, selector: "input[type=checkbox][formControlName],input[type=checkbox][formControl],input[type=checkbox][ngModel]" }, { kind: "directive", type: i1.NgControlStatus, selector: "[formControlName],[ngModel],[formControl]" }, { kind: "directive", type: i1.FormControlDirective, selector: "[formControl]", inputs: ["formControl", "disabled", "ngModel"], outputs: ["ngModelChange"], exportAs: ["ngForm"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
879
956
  }
880
957
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: BooleanFieldComponent, decorators: [{
881
958
  type: Component,
882
959
  args: [{
960
+ changeDetection: ChangeDetectionStrategy.OnPush,
883
961
  selector: 'ngx-boolean-field',
884
962
  standalone: true,
885
963
  imports: [ReactiveFormsModule],
@@ -968,11 +1046,12 @@ class DateFieldComponent {
968
1046
  }
969
1047
  }
970
1048
  </div>
971
- `, isInline: true, dependencies: [{ kind: "ngmodule", type: ReactiveFormsModule }, { kind: "directive", type: i1.DefaultValueAccessor, selector: "input:not([type=checkbox])[formControlName],textarea[formControlName],input:not([type=checkbox])[formControl],textarea[formControl],input:not([type=checkbox])[ngModel],textarea[ngModel],[ngDefaultControl]" }, { kind: "directive", type: i1.NgControlStatus, selector: "[formControlName],[ngModel],[formControl]" }, { kind: "directive", type: i1.FormControlDirective, selector: "[formControl]", inputs: ["formControl", "disabled", "ngModel"], outputs: ["ngModelChange"], exportAs: ["ngForm"] }] });
1049
+ `, isInline: true, dependencies: [{ kind: "ngmodule", type: ReactiveFormsModule }, { kind: "directive", type: i1.DefaultValueAccessor, selector: "input:not([type=checkbox])[formControlName],textarea[formControlName],input:not([type=checkbox])[formControl],textarea[formControl],input:not([type=checkbox])[ngModel],textarea[ngModel],[ngDefaultControl]" }, { kind: "directive", type: i1.NgControlStatus, selector: "[formControlName],[ngModel],[formControl]" }, { kind: "directive", type: i1.FormControlDirective, selector: "[formControl]", inputs: ["formControl", "disabled", "ngModel"], outputs: ["ngModelChange"], exportAs: ["ngForm"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
972
1050
  }
973
1051
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: DateFieldComponent, decorators: [{
974
1052
  type: Component,
975
1053
  args: [{
1054
+ changeDetection: ChangeDetectionStrategy.OnPush,
976
1055
  selector: 'ngx-date-field',
977
1056
  standalone: true,
978
1057
  imports: [ReactiveFormsModule],
@@ -1097,11 +1176,12 @@ class MonthYearFieldComponent {
1097
1176
  }
1098
1177
  }
1099
1178
  </div>
1100
- `, isInline: true, dependencies: [{ kind: "ngmodule", type: ReactiveFormsModule }, { kind: "directive", type: i1.NgSelectOption, selector: "option", inputs: ["ngValue", "value"] }, { kind: "directive", type: i1.ɵNgSelectMultipleOption, selector: "option", inputs: ["ngValue", "value"] }] });
1179
+ `, isInline: true, dependencies: [{ kind: "ngmodule", type: ReactiveFormsModule }, { kind: "directive", type: i1.NgSelectOption, selector: "option", inputs: ["ngValue", "value"] }, { kind: "directive", type: i1.ɵNgSelectMultipleOption, selector: "option", inputs: ["ngValue", "value"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
1101
1180
  }
1102
1181
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: MonthYearFieldComponent, decorators: [{
1103
1182
  type: Component,
1104
1183
  args: [{
1184
+ changeDetection: ChangeDetectionStrategy.OnPush,
1105
1185
  selector: 'ngx-month-year-field',
1106
1186
  standalone: true,
1107
1187
  imports: [ReactiveFormsModule],
@@ -1372,7 +1452,7 @@ const hostLastConfig = new WeakMap();
1372
1452
  * Point a choice component's `options` signal at the right source (parity plan §6.3).
1373
1453
  *
1374
1454
  * Called from the `field` / `language` input setters of `dropdown`, `radio` and `multiSelect` —
1375
- * the wiring is theirs, the decision is the service's, which is what keeps the ADR-008
1455
+ * the wiring is theirs, the decision is the service's, which is what keeps the field
1376
1456
  * five-input contract intact: options are never pushed in as a sixth input.
1377
1457
  *
1378
1458
  * Setters rather than `ngOnChanges` on purpose. `ngOnChanges` only fires for inputs Angular
@@ -1401,6 +1481,7 @@ function refreshChoiceOptions(host, lookups) {
1401
1481
 
1402
1482
  class DropdownFieldComponent {
1403
1483
  lookups = inject(LookupRegistryService);
1484
+ messages = inject(ValidationMessagesService);
1404
1485
  _field;
1405
1486
  _language = 'en';
1406
1487
  /** Setter-based so options resolve however the input is set — see `refreshChoiceOptions`. */
@@ -1459,10 +1540,9 @@ class DropdownFieldComponent {
1459
1540
  get errorMessage() {
1460
1541
  if (!this.control || !this.control.errors || !this.control.touched)
1461
1542
  return '';
1462
- const errs = this.control.errors;
1463
- if (errs['required'])
1464
- return 'Please select an option.';
1465
- return 'Invalid selection.';
1543
+ // `requiredSelection` rather than `required`: "Please select an option" reads better on a
1544
+ // dropdown than "This field is required", and both stay independently overridable.
1545
+ return this.messages.resolve(this.control.errors, this.language, [['required', 'requiredSelection']], 'invalidSelection');
1466
1546
  }
1467
1547
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: DropdownFieldComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
1468
1548
  static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "17.3.12", type: DropdownFieldComponent, isStandalone: true, selector: "ngx-dropdown-field", inputs: { field: "field", language: "language", control: "control", readonly: "readonly", masked: "masked" }, ngImport: i0, template: `
@@ -1505,11 +1585,12 @@ class DropdownFieldComponent {
1505
1585
  }
1506
1586
  }
1507
1587
  </div>
1508
- `, isInline: true, dependencies: [{ kind: "ngmodule", type: ReactiveFormsModule }, { kind: "directive", type: i1.NgSelectOption, selector: "option", inputs: ["ngValue", "value"] }, { kind: "directive", type: i1.ɵNgSelectMultipleOption, selector: "option", inputs: ["ngValue", "value"] }, { kind: "directive", type: i1.SelectControlValueAccessor, selector: "select:not([multiple])[formControlName],select:not([multiple])[formControl],select:not([multiple])[ngModel]", inputs: ["compareWith"] }, { kind: "directive", type: i1.NgControlStatus, selector: "[formControlName],[ngModel],[formControl]" }, { kind: "directive", type: i1.FormControlDirective, selector: "[formControl]", inputs: ["formControl", "disabled", "ngModel"], outputs: ["ngModelChange"], exportAs: ["ngForm"] }] });
1588
+ `, isInline: true, dependencies: [{ kind: "ngmodule", type: ReactiveFormsModule }, { kind: "directive", type: i1.NgSelectOption, selector: "option", inputs: ["ngValue", "value"] }, { kind: "directive", type: i1.ɵNgSelectMultipleOption, selector: "option", inputs: ["ngValue", "value"] }, { kind: "directive", type: i1.SelectControlValueAccessor, selector: "select:not([multiple])[formControlName],select:not([multiple])[formControl],select:not([multiple])[ngModel]", inputs: ["compareWith"] }, { kind: "directive", type: i1.NgControlStatus, selector: "[formControlName],[ngModel],[formControl]" }, { kind: "directive", type: i1.FormControlDirective, selector: "[formControl]", inputs: ["formControl", "disabled", "ngModel"], outputs: ["ngModelChange"], exportAs: ["ngForm"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
1509
1589
  }
1510
1590
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: DropdownFieldComponent, decorators: [{
1511
1591
  type: Component,
1512
1592
  args: [{
1593
+ changeDetection: ChangeDetectionStrategy.OnPush,
1513
1594
  selector: 'ngx-dropdown-field',
1514
1595
  standalone: true,
1515
1596
  imports: [ReactiveFormsModule],
@@ -1660,11 +1741,12 @@ class RadioFieldComponent {
1660
1741
  }
1661
1742
  </fieldset>
1662
1743
  </div>
1663
- `, isInline: true, dependencies: [{ kind: "ngmodule", type: ReactiveFormsModule }, { kind: "directive", type: i1.DefaultValueAccessor, selector: "input:not([type=checkbox])[formControlName],textarea[formControlName],input:not([type=checkbox])[formControl],textarea[formControl],input:not([type=checkbox])[ngModel],textarea[ngModel],[ngDefaultControl]" }, { kind: "directive", type: i1.RadioControlValueAccessor, selector: "input[type=radio][formControlName],input[type=radio][formControl],input[type=radio][ngModel]", inputs: ["name", "formControlName", "value"] }, { kind: "directive", type: i1.NgControlStatus, selector: "[formControlName],[ngModel],[formControl]" }, { kind: "directive", type: i1.FormControlDirective, selector: "[formControl]", inputs: ["formControl", "disabled", "ngModel"], outputs: ["ngModelChange"], exportAs: ["ngForm"] }] });
1744
+ `, isInline: true, dependencies: [{ kind: "ngmodule", type: ReactiveFormsModule }, { kind: "directive", type: i1.DefaultValueAccessor, selector: "input:not([type=checkbox])[formControlName],textarea[formControlName],input:not([type=checkbox])[formControl],textarea[formControl],input:not([type=checkbox])[ngModel],textarea[ngModel],[ngDefaultControl]" }, { kind: "directive", type: i1.RadioControlValueAccessor, selector: "input[type=radio][formControlName],input[type=radio][formControl],input[type=radio][ngModel]", inputs: ["name", "formControlName", "value"] }, { kind: "directive", type: i1.NgControlStatus, selector: "[formControlName],[ngModel],[formControl]" }, { kind: "directive", type: i1.FormControlDirective, selector: "[formControl]", inputs: ["formControl", "disabled", "ngModel"], outputs: ["ngModelChange"], exportAs: ["ngForm"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
1664
1745
  }
1665
1746
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: RadioFieldComponent, decorators: [{
1666
1747
  type: Component,
1667
1748
  args: [{
1749
+ changeDetection: ChangeDetectionStrategy.OnPush,
1668
1750
  selector: 'ngx-radio-field',
1669
1751
  standalone: true,
1670
1752
  imports: [ReactiveFormsModule],
@@ -1799,11 +1881,12 @@ class MultiSelectFieldComponent {
1799
1881
  }
1800
1882
  }
1801
1883
  </div>
1802
- `, isInline: true, dependencies: [{ kind: "ngmodule", type: ReactiveFormsModule }, { kind: "directive", type: i1.NgSelectOption, selector: "option", inputs: ["ngValue", "value"] }, { kind: "directive", type: i1.ɵNgSelectMultipleOption, selector: "option", inputs: ["ngValue", "value"] }, { kind: "directive", type: i1.SelectMultipleControlValueAccessor, selector: "select[multiple][formControlName],select[multiple][formControl],select[multiple][ngModel]", inputs: ["compareWith"] }, { kind: "directive", type: i1.NgControlStatus, selector: "[formControlName],[ngModel],[formControl]" }, { kind: "directive", type: i1.FormControlDirective, selector: "[formControl]", inputs: ["formControl", "disabled", "ngModel"], outputs: ["ngModelChange"], exportAs: ["ngForm"] }] });
1884
+ `, isInline: true, dependencies: [{ kind: "ngmodule", type: ReactiveFormsModule }, { kind: "directive", type: i1.NgSelectOption, selector: "option", inputs: ["ngValue", "value"] }, { kind: "directive", type: i1.ɵNgSelectMultipleOption, selector: "option", inputs: ["ngValue", "value"] }, { kind: "directive", type: i1.SelectMultipleControlValueAccessor, selector: "select[multiple][formControlName],select[multiple][formControl],select[multiple][ngModel]", inputs: ["compareWith"] }, { kind: "directive", type: i1.NgControlStatus, selector: "[formControlName],[ngModel],[formControl]" }, { kind: "directive", type: i1.FormControlDirective, selector: "[formControl]", inputs: ["formControl", "disabled", "ngModel"], outputs: ["ngModelChange"], exportAs: ["ngForm"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
1803
1885
  }
1804
1886
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: MultiSelectFieldComponent, decorators: [{
1805
1887
  type: Component,
1806
1888
  args: [{
1889
+ changeDetection: ChangeDetectionStrategy.OnPush,
1807
1890
  selector: 'ngx-multi-select-field',
1808
1891
  standalone: true,
1809
1892
  imports: [ReactiveFormsModule],
@@ -1854,7 +1937,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImpo
1854
1937
 
1855
1938
  /**
1856
1939
  * EntityRefRegistryService — resolves entity-ref option loader functions.
1857
- * ADR-006: Entity-ref options come ONLY from this registry.
1940
+ * Entity-ref options come ONLY from this registry.
1858
1941
  * Never pass loaders via @Input() on field components.
1859
1942
  *
1860
1943
  * Consumers register loaders via provideNgxDynamicEntity({ entityRefs: { clients: () => [...] } }).
@@ -2046,7 +2129,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImpo
2046
2129
  /**
2047
2130
  * CascadeDataService — loads entity-reference options and applies parent→child cascades.
2048
2131
  *
2049
- * Loaders come from the registry only (ADR-006). This service owns the three things a
2132
+ * Loaders come from the registry only. This service owns the three things a
2050
2133
  * field component should not: resolving the loader, coercing whatever it returned
2051
2134
  * (array | Promise | Observable) into a Promise, and filtering by the parent's value.
2052
2135
  */
@@ -2226,6 +2309,9 @@ class EntityRefFieldComponent {
2226
2309
  return;
2227
2310
  void this.reload();
2228
2311
  this.watchParent();
2312
+ this.control?.valueChanges
2313
+ .pipe(takeUntilDestroyed(this.destroyRef))
2314
+ .subscribe(value => this.publishSelection(String(value ?? '')));
2229
2315
  }
2230
2316
  /** Reload options against the parent's current value. */
2231
2317
  async reload() {
@@ -2243,8 +2329,20 @@ class EntityRefFieldComponent {
2243
2329
  this.loading.set(false);
2244
2330
  }
2245
2331
  }
2246
- onSelectionChange() {
2247
- const selected = this.options().find(o => String(o.value) === String(this.control.value));
2332
+ /**
2333
+ * Publish the selected option so autoPatch can copy from its record.
2334
+ *
2335
+ * Driven from `valueChanges` rather than the select's `change` event: that event is the
2336
+ * same one the `formControl` directive listens to, so reading the control inside a
2337
+ * template `(change)` handler can see the previous value. `valueChanges` fires after the
2338
+ * model has updated — including Playwright `selectOption`, which updates the control
2339
+ * through the value accessor.
2340
+ */
2341
+ onSelectionChange(value) {
2342
+ this.publishSelection(value ?? String(this.control?.value ?? ''));
2343
+ }
2344
+ publishSelection(current) {
2345
+ const selected = this.options().find(o => String(o.value) === current);
2248
2346
  this.selectionBus.emit(this.field.id, selected ?? null);
2249
2347
  }
2250
2348
  getLabel(value) {
@@ -2293,7 +2391,6 @@ class EntityRefFieldComponent {
2293
2391
  [id]="'field-' + field.id"
2294
2392
  [formControl]="$any(control)"
2295
2393
  [attr.disabled]="field.disabled ? true : null"
2296
- (change)="onSelectionChange()"
2297
2394
  >
2298
2395
  <option value="">{{ placeholder || 'Select...' }}</option>
2299
2396
  @for (option of options(); track option.value) {
@@ -2309,11 +2406,12 @@ class EntityRefFieldComponent {
2309
2406
  }
2310
2407
  }
2311
2408
  </div>
2312
- `, isInline: true, dependencies: [{ kind: "ngmodule", type: ReactiveFormsModule }, { kind: "directive", type: i1.NgSelectOption, selector: "option", inputs: ["ngValue", "value"] }, { kind: "directive", type: i1.ɵNgSelectMultipleOption, selector: "option", inputs: ["ngValue", "value"] }, { kind: "directive", type: i1.SelectControlValueAccessor, selector: "select:not([multiple])[formControlName],select:not([multiple])[formControl],select:not([multiple])[ngModel]", inputs: ["compareWith"] }, { kind: "directive", type: i1.NgControlStatus, selector: "[formControlName],[ngModel],[formControl]" }, { kind: "directive", type: i1.FormControlDirective, selector: "[formControl]", inputs: ["formControl", "disabled", "ngModel"], outputs: ["ngModelChange"], exportAs: ["ngForm"] }] });
2409
+ `, isInline: true, dependencies: [{ kind: "ngmodule", type: ReactiveFormsModule }, { kind: "directive", type: i1.NgSelectOption, selector: "option", inputs: ["ngValue", "value"] }, { kind: "directive", type: i1.ɵNgSelectMultipleOption, selector: "option", inputs: ["ngValue", "value"] }, { kind: "directive", type: i1.SelectControlValueAccessor, selector: "select:not([multiple])[formControlName],select:not([multiple])[formControl],select:not([multiple])[ngModel]", inputs: ["compareWith"] }, { kind: "directive", type: i1.NgControlStatus, selector: "[formControlName],[ngModel],[formControl]" }, { kind: "directive", type: i1.FormControlDirective, selector: "[formControl]", inputs: ["formControl", "disabled", "ngModel"], outputs: ["ngModelChange"], exportAs: ["ngForm"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
2313
2410
  }
2314
2411
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: EntityRefFieldComponent, decorators: [{
2315
2412
  type: Component,
2316
2413
  args: [{
2414
+ changeDetection: ChangeDetectionStrategy.OnPush,
2317
2415
  selector: 'ngx-entity-ref-field',
2318
2416
  standalone: true,
2319
2417
  imports: [ReactiveFormsModule],
@@ -2334,7 +2432,6 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImpo
2334
2432
  [id]="'field-' + field.id"
2335
2433
  [formControl]="$any(control)"
2336
2434
  [attr.disabled]="field.disabled ? true : null"
2337
- (change)="onSelectionChange()"
2338
2435
  >
2339
2436
  <option value="">{{ placeholder || 'Select...' }}</option>
2340
2437
  @for (option of options(); track option.value) {
@@ -2460,7 +2557,8 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImpo
2460
2557
 
2461
2558
  /**
2462
2559
  * DynamicFieldComponent — mounts the correct field component for a given NestedFieldConfig.
2463
- * Uses ViewContainerRef.createComponent() and passes inputs via setInput() (ADR-008).
2560
+ * Uses ViewContainerRef.createComponent() and passes inputs via setInput(); see
2561
+ * DynamicFieldComponentContract for the five inputs every field component receives.
2464
2562
  * Never add field-type-specific logic here — this component must remain generic.
2465
2563
  *
2466
2564
  * The 5 inputs (field, control, language, readonly, masked) are passed to every mounted
@@ -2477,6 +2575,12 @@ class DynamicFieldComponent {
2477
2575
  fieldHost;
2478
2576
  fieldRegistry = inject(FieldRegistryService);
2479
2577
  rbacService = inject(RbacService);
2578
+ /**
2579
+ * Passed into `createComponent` so a hosted field sees this host's injector — including
2580
+ * `DynamicFormComponent`'s scoped `EntityRefSelectionService`. Without it, a standalone
2581
+ * field resolves the root bus and `autoPatch` never runs.
2582
+ */
2583
+ injector = inject(Injector);
2480
2584
  componentRef = null;
2481
2585
  ngOnChanges(_changes) {
2482
2586
  this.mountField();
@@ -2498,9 +2602,12 @@ class DynamicFieldComponent {
2498
2602
  return;
2499
2603
  }
2500
2604
  this.fieldHost.clear();
2501
- this.componentRef = this.fieldHost.createComponent(ComponentClass);
2605
+ this.componentRef = this.fieldHost.createComponent(ComponentClass, {
2606
+ injector: this.injector,
2607
+ });
2502
2608
  this.setInputs(this.componentRef, masked);
2503
- this.componentRef.changeDetectorRef.markForCheck();
2609
+ this.watchControl();
2610
+ this.componentRef.changeDetectorRef.detectChanges();
2504
2611
  }
2505
2612
  /**
2506
2613
  * Field types are opt-in (see `provideFieldTypes` / `provideBuiltInFieldTypes`), so an
@@ -2516,7 +2623,44 @@ class DynamicFieldComponent {
2516
2623
  }
2517
2624
  static warnedTypes = new Set();
2518
2625
  /**
2519
- * Pass all 5 contract inputs via ComponentRef.setInput() — uniform for all types (ADR-008).
2626
+ * Re-check the hosted field component.
2627
+ *
2628
+ * The field components are OnPush, so they re-render on an input change, an event from
2629
+ * their own template, or a signal they read. `form.markAllAsTouched()` is none of those —
2630
+ * it flips `touched` from outside, and every field's error message depends on it. Without
2631
+ * this, a blocked submit would mark the form touched and no error would appear.
2632
+ */
2633
+ refresh() {
2634
+ this.componentRef?.changeDetectorRef.detectChanges();
2635
+ }
2636
+ /**
2637
+ * Re-check the hosted component whenever its control changes from outside.
2638
+ *
2639
+ * The field components are OnPush, so they re-render on an input change or an event from
2640
+ * their own template. A control mutated externally is neither — and that is not an edge
2641
+ * case: patchForm, reset, autoPatch and patchOnTrue all do exactly that. Without this, a
2642
+ * field patched from a record would keep rendering its previous value.
2643
+ *
2644
+ * `detectChanges()` rather than `markForCheck()`: a selection that arrives from a native
2645
+ * `change` event (or Playwright) may not be followed by another Angular tick, so a dirty
2646
+ * flag would never be flushed. Running CD on the hosted component now is the copy showing up.
2647
+ *
2648
+ * Done here rather than in each of the eighteen field components: the host owns the
2649
+ * component reference, so one subscription covers every type including custom ones.
2650
+ */
2651
+ watchControl() {
2652
+ this.controlSub?.unsubscribe();
2653
+ if (!this.control)
2654
+ return;
2655
+ this.controlSub = this.control.valueChanges.subscribe(() => this.refresh());
2656
+ }
2657
+ controlSub;
2658
+ ngOnDestroy() {
2659
+ this.controlSub?.unsubscribe();
2660
+ }
2661
+ /**
2662
+ * Pass all 5 contract inputs via ComponentRef.setInput() — uniform for all types.
2663
+ * The public shape is DynamicFieldComponentContract.
2520
2664
  * Must use setInput(), not property assignment: the field components declare inputs with
2521
2665
  * definite assignment and no initializer, and the lib compiles with
2522
2666
  * useDefineForClassFields:false, so `'field' in instance` is false until first set.
@@ -2557,7 +2701,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImpo
2557
2701
  args: ['fieldHost', { read: ViewContainerRef, static: true }]
2558
2702
  }] } });
2559
2703
 
2560
- /** ADR-008: GroupFieldComponent — renders nested fieldset container for group.children */
2704
+ /** Renders a nested fieldset container for a group field's children. */
2561
2705
  class GroupFieldComponent {
2562
2706
  field;
2563
2707
  control;
@@ -2597,11 +2741,11 @@ class GroupFieldComponent {
2597
2741
  </div>
2598
2742
  }
2599
2743
  </fieldset>
2600
- `, isInline: true, styles: [".ngx-field--group{border:1px solid #e2e8f0;border-radius:8px;padding:16px;margin-bottom:16px;background:#fafafa}.ngx-field__legend{font-weight:600;font-size:14px;padding:0 8px;color:#334155}.ngx-field__group-children{display:flex;flex-direction:column;gap:12px}\n"], dependencies: [{ kind: "ngmodule", type: i0.forwardRef(() => ReactiveFormsModule) }, { kind: "component", type: i0.forwardRef(() => DynamicFieldComponent), selector: "ngx-dynamic-field", inputs: ["field", "control", "config", "currentTabId", "language", "readonly", "userRoles"] }] });
2744
+ `, isInline: true, styles: [".ngx-field--group{border:1px solid #e2e8f0;border-radius:8px;padding:16px;margin-bottom:16px;background:#fafafa}.ngx-field__legend{font-weight:600;font-size:14px;padding:0 8px;color:#334155}.ngx-field__group-children{display:flex;flex-direction:column;gap:12px}\n"], dependencies: [{ kind: "ngmodule", type: i0.forwardRef(() => ReactiveFormsModule) }, { kind: "component", type: i0.forwardRef(() => DynamicFieldComponent), selector: "ngx-dynamic-field", inputs: ["field", "control", "config", "currentTabId", "language", "readonly", "userRoles"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
2601
2745
  }
2602
2746
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: GroupFieldComponent, decorators: [{
2603
2747
  type: Component,
2604
- args: [{ selector: 'ngx-group-field', standalone: true, imports: [ReactiveFormsModule, forwardRef(() => DynamicFieldComponent)], template: `
2748
+ args: [{ changeDetection: ChangeDetectionStrategy.OnPush, selector: 'ngx-group-field', standalone: true, imports: [ReactiveFormsModule, forwardRef(() => DynamicFieldComponent)], template: `
2605
2749
  <fieldset class="ngx-field ngx-field--group"
2606
2750
  [attr.data-testid]="'field-' + field.id" [attr.data-field-type]="field.type" [class.ngx-field--readonly]="readonly" [class.ngx-field--masked]="masked">
2607
2751
  <legend class="ngx-field__legend">{{ label }}</legend>
@@ -2646,6 +2790,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImpo
2646
2790
  */
2647
2791
  class ValidatorRegistryService {
2648
2792
  consumerRegistry = inject(VALIDATOR_REGISTRY, { optional: true }) ?? new Map();
2793
+ asyncRegistry = inject(ASYNC_VALIDATOR_REGISTRY, { optional: true }) ?? new Map();
2649
2794
  /**
2650
2795
  * Resolve a validator key string to a ValidatorFn.
2651
2796
  * Supports parameterized built-ins like 'min:0', 'maxLength:255'.
@@ -2706,6 +2851,27 @@ class ValidatorRegistryService {
2706
2851
  }
2707
2852
  return fnList;
2708
2853
  }
2854
+ /**
2855
+ * Resolve the async validators a field names, in `validators.customAsync`.
2856
+ *
2857
+ * Async validators are deliberately a separate registry and a separate config key: Angular
2858
+ * attaches them through `setAsyncValidators`, runs them only once the synchronous ones
2859
+ * pass, and holds the control in `pending` until they settle. Mixing them into the
2860
+ * synchronous list would silently never run them.
2861
+ *
2862
+ * An unknown name is skipped rather than throwing, matching the synchronous behaviour.
2863
+ */
2864
+ resolveAsyncFromConfig(config) {
2865
+ if (!config || Array.isArray(config))
2866
+ return [];
2867
+ const out = [];
2868
+ for (const key of config.customAsync ?? []) {
2869
+ const fn = this.asyncRegistry.get(key);
2870
+ if (fn)
2871
+ out.push(fn);
2872
+ }
2873
+ return out;
2874
+ }
2709
2875
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: ValidatorRegistryService, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
2710
2876
  static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: ValidatorRegistryService, providedIn: 'root' });
2711
2877
  }
@@ -2714,8 +2880,14 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImpo
2714
2880
  args: [{ providedIn: 'root' }]
2715
2881
  }] });
2716
2882
 
2717
- /** ADR-008: ArrayFieldComponent — renders repeating rows for FormArray with + Add Item and Remove */
2883
+ /** Renders repeating rows for a FormArray, with Add Item and Remove. */
2718
2884
  class ArrayFieldComponent {
2885
+ /**
2886
+ * addItem/removeItem are public and may be called from outside this component's template,
2887
+ * which under OnPush would not re-render. The host also watches the control, but this
2888
+ * keeps the component correct on its own.
2889
+ */
2890
+ cdr = inject(ChangeDetectorRef);
2719
2891
  field;
2720
2892
  control;
2721
2893
  config;
@@ -2742,9 +2914,11 @@ class ArrayFieldComponent {
2742
2914
  }
2743
2915
  const group = this.fb.group(controls);
2744
2916
  this.formArray.push(group);
2917
+ this.cdr.markForCheck();
2745
2918
  }
2746
2919
  removeItem(index) {
2747
2920
  this.formArray.removeAt(index);
2921
+ this.cdr.markForCheck();
2748
2922
  }
2749
2923
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: ArrayFieldComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
2750
2924
  static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "17.3.12", type: ArrayFieldComponent, isStandalone: true, selector: "ngx-array-field", inputs: { field: "field", control: "control", config: "config", language: "language", readonly: "readonly", masked: "masked", userRoles: "userRoles" }, ngImport: i0, template: `
@@ -2797,11 +2971,11 @@ class ArrayFieldComponent {
2797
2971
  </div>
2798
2972
  }
2799
2973
  </div>
2800
- `, isInline: true, styles: [".ngx-field--array{border:1px solid #e2e8f0;border-radius:8px;padding:16px;margin-bottom:16px;background:#f8fafc}.ngx-field__array-header{display:flex;justify-content:space-between;align-items:center;margin-bottom:12px}.ngx-field__array-add-btn{background:#2563eb;color:#fff;border:none;padding:6px 12px;border-radius:4px;font-weight:500;cursor:pointer}.ngx-field__array-add-btn:hover{background:#1d4ed8}.ngx-field__array-list{display:flex;flex-direction:column;gap:12px}.ngx-field__array-item{background:#fff;border:1px solid #cbd5e1;border-radius:6px;padding:12px}.ngx-field__array-item-header{display:flex;justify-content:space-between;align-items:center;margin-bottom:8px;border-bottom:1px solid #f1f5f9;padding-bottom:6px}.ngx-field__array-item-title{font-weight:600;font-size:13px;color:#475569}.ngx-field__array-remove-btn{background:#ef4444;color:#fff;border:none;padding:4px 8px;border-radius:4px;font-size:12px;cursor:pointer}.ngx-field__array-empty{color:#94a3b8;font-style:italic;font-size:13px}\n"], dependencies: [{ kind: "ngmodule", type: i0.forwardRef(() => ReactiveFormsModule) }, { kind: "component", type: i0.forwardRef(() => DynamicFieldComponent), selector: "ngx-dynamic-field", inputs: ["field", "control", "config", "currentTabId", "language", "readonly", "userRoles"] }] });
2974
+ `, isInline: true, styles: [".ngx-field--array{border:1px solid #e2e8f0;border-radius:8px;padding:16px;margin-bottom:16px;background:#f8fafc}.ngx-field__array-header{display:flex;justify-content:space-between;align-items:center;margin-bottom:12px}.ngx-field__array-add-btn{background:#2563eb;color:#fff;border:none;padding:6px 12px;border-radius:4px;font-weight:500;cursor:pointer}.ngx-field__array-add-btn:hover{background:#1d4ed8}.ngx-field__array-list{display:flex;flex-direction:column;gap:12px}.ngx-field__array-item{background:#fff;border:1px solid #cbd5e1;border-radius:6px;padding:12px}.ngx-field__array-item-header{display:flex;justify-content:space-between;align-items:center;margin-bottom:8px;border-bottom:1px solid #f1f5f9;padding-bottom:6px}.ngx-field__array-item-title{font-weight:600;font-size:13px;color:#475569}.ngx-field__array-remove-btn{background:#ef4444;color:#fff;border:none;padding:4px 8px;border-radius:4px;font-size:12px;cursor:pointer}.ngx-field__array-empty{color:#94a3b8;font-style:italic;font-size:13px}\n"], dependencies: [{ kind: "ngmodule", type: i0.forwardRef(() => ReactiveFormsModule) }, { kind: "component", type: i0.forwardRef(() => DynamicFieldComponent), selector: "ngx-dynamic-field", inputs: ["field", "control", "config", "currentTabId", "language", "readonly", "userRoles"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
2801
2975
  }
2802
2976
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: ArrayFieldComponent, decorators: [{
2803
2977
  type: Component,
2804
- args: [{ selector: 'ngx-array-field', standalone: true, imports: [ReactiveFormsModule, forwardRef(() => DynamicFieldComponent)], template: `
2978
+ args: [{ changeDetection: ChangeDetectionStrategy.OnPush, selector: 'ngx-array-field', standalone: true, imports: [ReactiveFormsModule, forwardRef(() => DynamicFieldComponent)], template: `
2805
2979
  <div class="ngx-field ngx-field--array"
2806
2980
  [attr.data-testid]="'field-' + field.id" [attr.data-field-type]="field.type" [class.ngx-field--readonly]="readonly" [class.ngx-field--masked]="masked">
2807
2981
  <div class="ngx-field__array-header">
@@ -3052,11 +3226,12 @@ class ImageFieldComponent {
3052
3226
  }
3053
3227
  }
3054
3228
  </div>
3055
- `, isInline: true });
3229
+ `, isInline: true, changeDetection: i0.ChangeDetectionStrategy.OnPush });
3056
3230
  }
3057
3231
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: ImageFieldComponent, decorators: [{
3058
3232
  type: Component,
3059
3233
  args: [{
3234
+ changeDetection: ChangeDetectionStrategy.OnPush,
3060
3235
  selector: 'ngx-image-field',
3061
3236
  standalone: true,
3062
3237
  imports: [],
@@ -3206,11 +3381,12 @@ class FileFieldComponent {
3206
3381
  }
3207
3382
  }
3208
3383
  </div>
3209
- `, isInline: true });
3384
+ `, isInline: true, changeDetection: i0.ChangeDetectionStrategy.OnPush });
3210
3385
  }
3211
3386
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: FileFieldComponent, decorators: [{
3212
3387
  type: Component,
3213
3388
  args: [{
3389
+ changeDetection: ChangeDetectionStrategy.OnPush,
3214
3390
  selector: 'ngx-file-field',
3215
3391
  standalone: true,
3216
3392
  imports: [],
@@ -3440,7 +3616,26 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImpo
3440
3616
  */
3441
3617
  class DynamicFormComponent {
3442
3618
  // ─── Inputs ───────────────────────────────────────────────────────────────
3443
- config;
3619
+ /**
3620
+ * The schema to render.
3621
+ *
3622
+ * An accessor pair rather than a plain field: `ngOnChanges` normalises the option shape,
3623
+ * and that result is kept here instead of being written back over the input. Reassigning
3624
+ * an `@Input` works, but it mutates what the parent handed us — and a parent using OnPush
3625
+ * or signals would never observe the replacement anyway.
3626
+ *
3627
+ * Reads of `this.config` get the normalised copy once it exists, so every internal caller
3628
+ * is unaffected.
3629
+ */
3630
+ set config(value) {
3631
+ this.rawConfig = value;
3632
+ this.normalizedConfig = undefined; // re-normalised in ngOnChanges
3633
+ }
3634
+ get config() {
3635
+ return this.normalizedConfig ?? this.rawConfig;
3636
+ }
3637
+ rawConfig;
3638
+ normalizedConfig;
3444
3639
  rules;
3445
3640
  initialData;
3446
3641
  /** Session-original values for `VALUE_CHANGED` rules. Captured from the first build when omitted. */
@@ -3474,6 +3669,11 @@ class DynamicFormComponent {
3474
3669
  formReset = new EventEmitter();
3475
3670
  /** The visible tab changed. Lets a host track it without reaching into this component. */
3476
3671
  activeTabChange = new EventEmitter();
3672
+ /**
3673
+ * Emitted when a `beforeSave` hook aborted the save — by returning false or throwing.
3674
+ * `formSubmit` does not fire in that case.
3675
+ */
3676
+ saveRejected = new EventEmitter();
3477
3677
  // ─── Services ─────────────────────────────────────────────────────────────
3478
3678
  fb = inject(FormBuilder);
3479
3679
  validatorRegistry = inject(ValidatorRegistryService);
@@ -3493,6 +3693,10 @@ class DynamicFormComponent {
3493
3693
  sessionBaseline = signal({});
3494
3694
  /** Critical fields the user has explicitly unlocked for editing. */
3495
3695
  unlockedFields = signal(new Set());
3696
+ /** The active tab's field panel, focused after a tab switch. */
3697
+ formPanel;
3698
+ /** The hosted field components, so an external control-state change can reach them. */
3699
+ fieldHosts;
3496
3700
  // ─── Form ─────────────────────────────────────────────────────────────────
3497
3701
  form;
3498
3702
  valueSub;
@@ -3618,9 +3822,19 @@ class DynamicFormComponent {
3618
3822
  get hasRuleErrors() {
3619
3823
  return Object.keys(this.ruleValidationErrors).length > 0;
3620
3824
  }
3825
+ /**
3826
+ * True while an async validator is still running.
3827
+ *
3828
+ * Angular reports a control with an outstanding async check as `pending`, and `invalid` is
3829
+ * false until it settles — so without this a form could be submitted in the gap before a
3830
+ * uniqueness check came back.
3831
+ */
3832
+ get isValidating() {
3833
+ return this.form.pending;
3834
+ }
3621
3835
  /** Single source of truth for both the submit guard and the button's disabled state. */
3622
3836
  get submitBlocked() {
3623
- return this.form.invalid || this.hasRuleErrors;
3837
+ return this.form.invalid || this.form.pending || this.hasRuleErrors;
3624
3838
  }
3625
3839
  ngOnInit() {
3626
3840
  this.selectionSub = this.entityRefSelection.selection$.subscribe(({ fieldId, option }) => {
@@ -3631,12 +3845,15 @@ class DynamicFormComponent {
3631
3845
  if (changes['config'] || changes['userRoles']) {
3632
3846
  this.permissionsCache = null;
3633
3847
  }
3634
- if (changes['config'] && this.config) {
3848
+ if (changes['config'] && this.rawConfig) {
3635
3849
  // Configs arrive as plain JSON from storage or an API, where TypeScript cannot enforce
3636
3850
  // the option shape. Normalise here, at the library boundary, so everything downstream
3637
3851
  // can rely on an option being a LocalizedText. Returns the same object when it already
3638
3852
  // is, so a well-formed config costs nothing.
3639
- this.config = normalizeConfigOptions(this.config);
3853
+ // Kept in a field of our own rather than written back to the @Input. Reassigning an
3854
+ // input works, but it surprises a parent that holds the same object — and a parent
3855
+ // using OnPush or signals would not see the replacement anyway.
3856
+ this.normalizedConfig = normalizeConfigOptions(this.config);
3640
3857
  }
3641
3858
  if (changes['config'] || changes['initialData']) {
3642
3859
  this.buildForm();
@@ -3650,9 +3867,12 @@ class DynamicFormComponent {
3650
3867
  this.selectionSub?.unsubscribe();
3651
3868
  }
3652
3869
  setActiveTab(tabId) {
3653
- if (this.activeTab() !== tabId)
3870
+ const changed = this.activeTab() !== tabId;
3871
+ if (changed)
3654
3872
  this.activeTabChange.emit(tabId);
3655
3873
  this.activeTab.set(tabId);
3874
+ if (changed)
3875
+ this.focusActivePanel();
3656
3876
  const parent = findTab(this.tabs, tabId);
3657
3877
  if (parent?.children?.length) {
3658
3878
  this.activeSubTab.set(parent.children[0].id);
@@ -3662,7 +3882,10 @@ class DynamicFormComponent {
3662
3882
  }
3663
3883
  }
3664
3884
  setActiveSubTab(subTabId) {
3885
+ const changed = this.activeSubTab() !== subTabId;
3665
3886
  this.activeSubTab.set(subTabId);
3887
+ if (changed)
3888
+ this.focusActivePanel();
3666
3889
  }
3667
3890
  getFieldSpan(field) {
3668
3891
  const span = field.colSpan ?? 12;
@@ -3770,7 +3993,8 @@ class DynamicFormComponent {
3770
3993
  }
3771
3994
  else {
3772
3995
  const validators = this.validatorRegistry.resolveFromConfig(field.validators);
3773
- group[field.id] = this.fb.control({ value: field.defaultValue ?? null, disabled: field.disabled ?? false }, validators);
3996
+ const asyncValidators = this.validatorRegistry.resolveAsyncFromConfig(field.validators);
3997
+ group[field.id] = this.fb.control({ value: field.defaultValue ?? null, disabled: field.disabled ?? false }, validators, asyncValidators);
3774
3998
  }
3775
3999
  }
3776
4000
  /** One row of an `array` field: a FormGroup when the field declares columns, else a bare control. */
@@ -3801,6 +4025,16 @@ class DynamicFormComponent {
3801
4025
  group.patchValue(item);
3802
4026
  return group;
3803
4027
  }
4028
+ /**
4029
+ * Mark every control touched, then re-check the field components.
4030
+ *
4031
+ * They are OnPush and `touched` is flipped from outside their own template, so the second
4032
+ * half is what makes the error messages actually appear.
4033
+ */
4034
+ markAllTouched() {
4035
+ this.form.markAllAsTouched();
4036
+ this.fieldHosts?.forEach(host => host.refresh());
4037
+ }
3804
4038
  /**
3805
4039
  * Bring a record up to the config's `version` before it reaches the form.
3806
4040
  *
@@ -3970,6 +4204,10 @@ class DynamicFormComponent {
3970
4204
  const control = this.resolveTargetControl(autoPatch, targetId);
3971
4205
  control?.patchValue(value);
3972
4206
  }
4207
+ // Readonly targets interpolate `control.value` under OnPush. `detectChanges` on the
4208
+ // hosted component flushes that write immediately — `markForCheck` waits for a later
4209
+ // tick that a Playwright selection never schedules.
4210
+ this.fieldHosts?.forEach(host => host.refresh());
3973
4211
  }
3974
4212
  /**
3975
4213
  * `patchOnTrue` — when a boolean/checkbox field transitions to `true`,
@@ -4115,24 +4353,70 @@ class DynamicFormComponent {
4115
4353
  };
4116
4354
  return findInGroup(this.form);
4117
4355
  }
4356
+ /**
4357
+ * Move focus into the newly shown panel.
4358
+ *
4359
+ * Activating a tab replaces everything below it while focus stays on the tab button, so a
4360
+ * screen-reader or keyboard user is given no indication that the content changed and must
4361
+ * tab back through the strip to reach it. Deferred a frame because the panel's contents
4362
+ * are rendered by the change detection this call is part of.
4363
+ */
4364
+ focusActivePanel() {
4365
+ if (typeof requestAnimationFrame !== 'function')
4366
+ return;
4367
+ requestAnimationFrame(() => this.formPanel?.nativeElement?.focus?.());
4368
+ }
4369
+ /** Accessible name for the field panel — it is the tabpanel for the active tab. */
4370
+ get activeTabLabel() {
4371
+ const tab = this.activeSubTabConfig ?? this.activeTabConfig;
4372
+ return tab ? this.resolveTabLabel(tab) : '';
4373
+ }
4374
+ /**
4375
+ * Why a referenced field is flagged.
4376
+ *
4377
+ * `hasDrift` was written by the builder and read by nothing at runtime, so a field whose
4378
+ * source definition had changed since it was linked looked entirely normal to the person
4379
+ * filling the form in.
4380
+ */
4381
+ driftHint(field) {
4382
+ const source = field.referencedEntityKey
4383
+ ? `"${field.referencedEntityKey}"`
4384
+ : 'its source entity';
4385
+ return `This field is linked to ${source} and its definition there has changed since it was linked.`;
4386
+ }
4118
4387
  resolveTabLabel(tab) {
4119
4388
  return resolveLabel(tab.label, this.language);
4120
4389
  }
4121
4390
  async submit() {
4122
4391
  if (!this.canSubmit || this.submitBlocked) {
4123
- this.form.markAllAsTouched();
4392
+ this.markAllTouched();
4124
4393
  return;
4125
4394
  }
4126
4395
  const rawData = this.extractRecord();
4127
4396
  let processedData = rawData;
4128
- const hookKey = `${this.config?.entity}:beforeSave`;
4129
- if (this.hookRegistry.has(hookKey)) {
4130
- processedData = await this.hookRegistry.run(hookKey, processedData);
4131
- }
4132
4397
  this.isSaving.set(true);
4133
4398
  try {
4399
+ // beforeSave can now abort the save: returning false, or throwing, stops it. Previously
4400
+ // its return value simply replaced the payload and the submit proceeded regardless, so
4401
+ // a hook that wanted to veto — a server-side check, a confirmation — had no way to.
4402
+ const hookKey = `${this.config?.entity}:beforeSave`;
4403
+ if (this.hookRegistry.has(hookKey)) {
4404
+ const result = await this.hookRegistry.run(hookKey, processedData);
4405
+ if (result === false) {
4406
+ this.saveRejected.emit({ reason: 'beforeSave returned false' });
4407
+ return;
4408
+ }
4409
+ // Anything else is the payload to submit; `undefined` means "unchanged".
4410
+ if (result !== undefined && result !== true)
4411
+ processedData = result;
4412
+ }
4134
4413
  this.formSubmit.emit(processedData);
4135
4414
  }
4415
+ catch (err) {
4416
+ // A hook that throws aborts the save and reports why, rather than the error escaping
4417
+ // into an unhandled rejection with the form left looking as though it saved.
4418
+ this.saveRejected.emit({ reason: err instanceof Error ? err.message : String(err), error: err });
4419
+ }
4136
4420
  finally {
4137
4421
  this.isSaving.set(false);
4138
4422
  }
@@ -4148,11 +4432,11 @@ class DynamicFormComponent {
4148
4432
  this.formReset.emit();
4149
4433
  }
4150
4434
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: DynamicFormComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
4151
- static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "17.3.12", type: DynamicFormComponent, isStandalone: true, selector: "ngx-dynamic-form", inputs: { config: "config", rules: "rules", initialData: "initialData", originalData: "originalData", userRoles: "userRoles", language: "language", readonly: "readonly", readOnlyFields: "readOnlyFields", showInfoBanners: "showInfoBanners", loading: "loading", error: "error", changeDebounceMs: "changeDebounceMs", preview: "preview" }, outputs: { formSubmit: "formSubmit", formChange: "formChange", formReset: "formReset", activeTabChange: "activeTabChange" }, host: { listeners: { "window:keydown": "handleKeyboardEvent($event)" } }, providers: [EntityRefSelectionService], usesOnChanges: true, ngImport: i0, template: "@if (canView) {\n <form [formGroup]=\"form\" (ngSubmit)=\"submit()\" class=\"ngx-form\" novalidate>\n\n <!-- Error message banner -->\n @if (error) {\n <div class=\"ngx-form__error-banner\" role=\"alert\" data-testid=\"form-error\">{{ error }}</div>\n }\n\n <!-- Rules Info Banners -->\n @for (infoKey of showInfoBanners ? Object.keys(ruleResult().infoBanners) : []; track infoKey) {\n <div class=\"ngx-form__info-banner\" role=\"status\" [attr.data-testid]=\"'info-banner-' + infoKey\">\n \u2139\uFE0F {{ ruleResult().infoBanners[infoKey] }}\n </div>\n }\n\n <!-- Rules Validation Errors & Warnings -->\n @for (errKey of Object.keys(ruleResult().validationErrors); track errKey) {\n <div class=\"ngx-form__error-banner\" role=\"alert\" [attr.data-testid]=\"'rule-error-' + errKey\">\n \u26A0\uFE0F {{ ruleResult().validationErrors[errKey] }}\n </div>\n }\n @for (warnKey of Object.keys(ruleResult().validationWarnings); track warnKey) {\n <div class=\"ngx-form__warning-banner\" role=\"status\" [attr.data-testid]=\"'rule-warning-' + warnKey\">\n \u26A1 {{ ruleResult().validationWarnings[warnKey] }}\n </div>\n }\n\n <!-- Deferred VALUE_CHANGED notice for criticalField edits (vs session baseline) -->\n @if (changedCriticalFields().length > 0) {\n <div class=\"ngx-form__warning-banner\" role=\"status\" data-testid=\"critical-change-banner\">\n \uD83D\uDD12 Critical field changed:\n @for (field of changedCriticalFields(); track field.id) {\n <span>{{ resolveFieldLabel(field) }}{{ $last ? '' : ', ' }}</span>\n }\n \u2014 this differs from the value at the start of this session.\n </div>\n }\n\n <!-- Primary Tab bar \u2014 only if visible tabs defined -->\n @if (visibleTabs.length > 0) {\n <div class=\"ngx-form__tabs\" role=\"tablist\" data-testid=\"tab-strip\">\n @for (tab of visibleTabs; track tab.id) {\n <button\n type=\"button\"\n class=\"ngx-form__tab\"\n [attr.data-testid]=\"'tab-' + tab.id\"\n [class.ngx-form__tab--active]=\"activeTab() === tab.id\"\n (click)=\"setActiveTab(tab.id)\"\n role=\"tab\"\n [attr.aria-selected]=\"activeTab() === tab.id\"\n >\n {{ resolveTabLabel(tab) }}\n </button>\n }\n </div>\n }\n\n <!-- Sub-tab bar \u2014 only if active tab has sub-tabs -->\n @if (visibleSubTabs.length > 0) {\n <div class=\"ngx-form__tabs ngx-form__tabs--sub\" role=\"tablist\" data-testid=\"subtab-strip\" style=\"margin-top: -12px; margin-bottom: 12px; font-size: 13px;\">\n @for (subTab of visibleSubTabs; track subTab.id) {\n <button\n type=\"button\"\n class=\"ngx-form__tab ngx-form__tab--sub\"\n [attr.data-testid]=\"'subtab-' + subTab.id\"\n [class.ngx-form__tab--active]=\"(activeSubTabConfig?.id) === subTab.id\"\n (click)=\"setActiveSubTab(subTab.id)\"\n role=\"tab\"\n [attr.aria-selected]=\"(activeSubTabConfig?.id) === subTab.id\"\n >\n {{ resolveTabLabel(subTab) }}\n </button>\n }\n </div>\n }\n\n <!-- Module tab component OR Fields for active tab -->\n @if (activeTabModuleComponent) {\n <div class=\"ngx-form__module-panel\" data-testid=\"module-panel\">\n <ng-container *ngComponentOutlet=\"activeTabModuleComponent; inputs: (activeSubTabConfig || activeTabConfig)?.moduleInputs\"></ng-container>\n </div>\n } @else {\n <!-- Fields for active tab (Responsive 12-column grid) -->\n <div class=\"ngx-form__panel\" data-testid=\"form-panel\">\n @for (field of fieldsForActiveTab; track field.id) {\n <div\n class=\"ngx-form__field-slot\"\n [style.gridColumn]=\"getFieldSpan(field)\"\n [attr.id]=\"'field-container-' + field.id\"\n >\n <ngx-dynamic-field\n [field]=\"field\"\n [control]=\"getControl(field.id, (activeSubTabConfig || activeTabConfig)?.id)!\"\n [config]=\"config\"\n [currentTabId]=\"(activeSubTabConfig || activeTabConfig)?.id\"\n [language]=\"language\"\n [readonly]=\"isFieldReadonly(field)\"\n [userRoles]=\"userRoles\"\n />\n @if (field.criticalField && !readonly) {\n <button\n type=\"button\"\n class=\"ngx-form__lock\"\n [attr.data-testid]=\"'lock-' + field.id\"\n [attr.aria-pressed]=\"!isFieldLocked(field)\"\n [attr.aria-label]=\"\n (isFieldLocked(field) ? 'Unlock ' : 'Lock ') + resolveFieldLabel(field)\n \"\n [title]=\"\n isFieldLocked(field)\n ? 'Critical field \u2014 click to unlock for editing'\n : 'Critical field \u2014 click to lock'\n \"\n (click)=\"toggleFieldLock(field)\"\n >\n {{ isFieldLocked(field) ? '\uD83D\uDD12' : '\uD83D\uDD13' }}\n </button>\n }\n </div>\n }\n </div>\n }\n\n <!-- Submit button \u2014 gated by RBAC and isSaving lock -->\n @if (!readonly && permissions.canEdit) {\n <div class=\"ngx-form__actions\" data-testid=\"form-actions\">\n <button\n type=\"button\"\n class=\"ngx-form__reset\"\n data-testid=\"form-reset\"\n (click)=\"reset()\"\n [disabled]=\"isSaving()\"\n >\n Reset\n </button>\n <button\n type=\"submit\"\n class=\"ngx-form__submit\"\n data-testid=\"form-submit\"\n [disabled]=\"submitBlocked\"\n title=\"Shortcut: Ctrl+S\"\n >\n @if (isSaving() || loading) {\n Saving\u2026\n } @else {\n Save\n }\n </button>\n </div>\n }\n\n </form>\n} @else {\n <!-- permissions.view denied: render nothing of the record.\n Presentational only \u2014 the server must not send data this user may not see. -->\n <div class=\"ngx-form__denied\" role=\"status\" data-testid=\"form-access-denied\">\n You do not have permission to view this record.\n </div>\n}\n", styles: [".ngx-form__panel{display:grid;grid-template-columns:repeat(12,minmax(0,1fr));gap:16px 20px;align-items:start}.ngx-form__field-slot{display:flex;align-items:flex-start;gap:6px;min-width:0}.ngx-form__field-slot ngx-dynamic-field{flex:1;min-width:0}.ngx-form__lock{flex:0 0 auto;margin-top:22px;background:none;border:1px solid transparent;border-radius:6px;cursor:pointer;font-size:14px;line-height:1;padding:4px 6px}.ngx-form__lock:hover{border-color:#d1d5db;background:#f9fafb}@media (max-width: 768px){.ngx-form__panel{grid-template-columns:1fr}.ngx-form__field-slot{grid-column:span 12!important}}\n"], dependencies: [{ kind: "ngmodule", type: ReactiveFormsModule }, { kind: "directive", type: i1.ɵNgNoValidate, selector: "form:not([ngNoForm]):not([ngNativeValidate])" }, { kind: "directive", type: i1.NgControlStatusGroup, selector: "[formGroupName],[formArrayName],[ngModelGroup],[formGroup],form:not([ngNoForm]),[ngForm]" }, { kind: "directive", type: i1.FormGroupDirective, selector: "[formGroup]", inputs: ["formGroup"], outputs: ["ngSubmit"], exportAs: ["ngForm"] }, { kind: "component", type: DynamicFieldComponent, selector: "ngx-dynamic-field", inputs: ["field", "control", "config", "currentTabId", "language", "readonly", "userRoles"] }, { kind: "directive", type: NgComponentOutlet, selector: "[ngComponentOutlet]", inputs: ["ngComponentOutlet", "ngComponentOutletInputs", "ngComponentOutletInjector", "ngComponentOutletContent", "ngComponentOutletNgModule", "ngComponentOutletNgModuleFactory"] }] });
4435
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "17.3.12", type: DynamicFormComponent, isStandalone: true, selector: "ngx-dynamic-form", inputs: { config: "config", rules: "rules", initialData: "initialData", originalData: "originalData", userRoles: "userRoles", language: "language", readonly: "readonly", readOnlyFields: "readOnlyFields", showInfoBanners: "showInfoBanners", loading: "loading", error: "error", changeDebounceMs: "changeDebounceMs", preview: "preview" }, outputs: { formSubmit: "formSubmit", formChange: "formChange", formReset: "formReset", activeTabChange: "activeTabChange", saveRejected: "saveRejected" }, host: { listeners: { "window:keydown": "handleKeyboardEvent($event)" } }, providers: [EntityRefSelectionService], viewQueries: [{ propertyName: "formPanel", first: true, predicate: ["formPanel"], descendants: true }, { propertyName: "fieldHosts", predicate: DynamicFieldComponent, descendants: true }], usesOnChanges: true, ngImport: i0, template: "@if (canView) {\n <form [formGroup]=\"form\" (ngSubmit)=\"submit()\" class=\"ngx-form\" novalidate>\n\n <!-- Error message banner -->\n @if (error) {\n <div class=\"ngx-form__error-banner\" role=\"alert\" data-testid=\"form-error\">{{ error }}</div>\n }\n\n <!-- Rules Info Banners -->\n @for (infoKey of showInfoBanners ? Object.keys(ruleResult().infoBanners) : []; track infoKey) {\n <div class=\"ngx-form__info-banner\" role=\"status\" [attr.data-testid]=\"'info-banner-' + infoKey\">\n \u2139\uFE0F {{ ruleResult().infoBanners[infoKey] }}\n </div>\n }\n\n <!-- Rules Validation Errors & Warnings -->\n @for (errKey of Object.keys(ruleResult().validationErrors); track errKey) {\n <div class=\"ngx-form__error-banner\" role=\"alert\" [attr.data-testid]=\"'rule-error-' + errKey\">\n \u26A0\uFE0F {{ ruleResult().validationErrors[errKey] }}\n </div>\n }\n @for (warnKey of Object.keys(ruleResult().validationWarnings); track warnKey) {\n <div class=\"ngx-form__warning-banner\" role=\"status\" [attr.data-testid]=\"'rule-warning-' + warnKey\">\n \u26A1 {{ ruleResult().validationWarnings[warnKey] }}\n </div>\n }\n\n <!-- Deferred VALUE_CHANGED notice for criticalField edits (vs session baseline) -->\n @if (changedCriticalFields().length > 0) {\n <div class=\"ngx-form__warning-banner\" role=\"status\" data-testid=\"critical-change-banner\">\n \uD83D\uDD12 Critical field changed:\n @for (field of changedCriticalFields(); track field.id) {\n <span>{{ resolveFieldLabel(field) }}{{ $last ? '' : ', ' }}</span>\n }\n \u2014 this differs from the value at the start of this session.\n </div>\n }\n\n <!-- Primary Tab bar \u2014 only if visible tabs defined -->\n @if (visibleTabs.length > 0) {\n <div class=\"ngx-form__tabs\" role=\"tablist\" data-testid=\"tab-strip\">\n @for (tab of visibleTabs; track tab.id) {\n <button\n type=\"button\"\n class=\"ngx-form__tab\"\n [attr.data-testid]=\"'tab-' + tab.id\"\n [class.ngx-form__tab--active]=\"activeTab() === tab.id\"\n (click)=\"setActiveTab(tab.id)\"\n role=\"tab\"\n [attr.aria-selected]=\"activeTab() === tab.id\"\n >\n {{ resolveTabLabel(tab) }}\n </button>\n }\n </div>\n }\n\n <!-- Sub-tab bar \u2014 only if active tab has sub-tabs -->\n @if (visibleSubTabs.length > 0) {\n <div class=\"ngx-form__tabs ngx-form__tabs--sub\" role=\"tablist\" data-testid=\"subtab-strip\" style=\"margin-top: -12px; margin-bottom: 12px; font-size: 13px;\">\n @for (subTab of visibleSubTabs; track subTab.id) {\n <button\n type=\"button\"\n class=\"ngx-form__tab ngx-form__tab--sub\"\n [attr.data-testid]=\"'subtab-' + subTab.id\"\n [class.ngx-form__tab--active]=\"(activeSubTabConfig?.id) === subTab.id\"\n (click)=\"setActiveSubTab(subTab.id)\"\n role=\"tab\"\n [attr.aria-selected]=\"(activeSubTabConfig?.id) === subTab.id\"\n >\n {{ resolveTabLabel(subTab) }}\n </button>\n }\n </div>\n }\n\n <!--\n One tabpanel per tab, wrapping both branches. Focus moves here on a tab switch, and a\n module tab is still a tab \u2014 putting the panel semantics on only the fields branch left\n a module tab with nowhere for focus to land.\n -->\n <div\n role=\"tabpanel\"\n tabindex=\"-1\"\n #formPanel\n class=\"ngx-form__tabpanel\"\n [attr.aria-label]=\"activeTabLabel\"\n >\n @if (activeTabModuleComponent) {\n <div class=\"ngx-form__module-panel\" data-testid=\"module-panel\">\n <ng-container *ngComponentOutlet=\"activeTabModuleComponent; inputs: (activeSubTabConfig || activeTabConfig)?.moduleInputs\"></ng-container>\n </div>\n } @else {\n <!-- Fields for active tab (Responsive 12-column grid) -->\n <div class=\"ngx-form__panel\" data-testid=\"form-panel\">\n @for (field of fieldsForActiveTab; track field.id) {\n <div\n class=\"ngx-form__field-slot\"\n [style.gridColumn]=\"getFieldSpan(field)\"\n [attr.id]=\"'field-container-' + field.id\"\n >\n <ngx-dynamic-field\n [field]=\"field\"\n [control]=\"getControl(field.id, (activeSubTabConfig || activeTabConfig)?.id)!\"\n [config]=\"config\"\n [currentTabId]=\"(activeSubTabConfig || activeTabConfig)?.id\"\n [language]=\"language\"\n [readonly]=\"isFieldReadonly(field)\"\n [userRoles]=\"userRoles\"\n />\n @if (field.hasDrift) {\n <!--\n Drift was previously visible only in the builder, so a referenced field whose\n source had changed looked completely normal to the person filling the form in.\n role=\"status\" rather than \"alert\": it is information, not an error.\n -->\n <span\n class=\"ngx-field__drift\"\n role=\"status\"\n [attr.data-testid]=\"'field-' + field.id + '-drift'\"\n [title]=\"driftHint(field)\"\n >\u26A0 {{ driftHint(field) }}</span>\n }\n @if (field.criticalField && !readonly) {\n <button\n type=\"button\"\n class=\"ngx-form__lock\"\n [attr.data-testid]=\"'lock-' + field.id\"\n [attr.aria-pressed]=\"!isFieldLocked(field)\"\n [attr.aria-label]=\"\n (isFieldLocked(field) ? 'Unlock ' : 'Lock ') + resolveFieldLabel(field)\n \"\n [title]=\"\n isFieldLocked(field)\n ? 'Critical field \u2014 click to unlock for editing'\n : 'Critical field \u2014 click to lock'\n \"\n (click)=\"toggleFieldLock(field)\"\n >\n {{ isFieldLocked(field) ? '\uD83D\uDD12' : '\uD83D\uDD13' }}\n </button>\n }\n </div>\n }\n </div>\n }\n </div>\n\n <!-- Submit button \u2014 gated by RBAC and isSaving lock -->\n @if (!readonly && permissions.canEdit) {\n <div class=\"ngx-form__actions\" data-testid=\"form-actions\">\n <button\n type=\"button\"\n class=\"ngx-form__reset\"\n data-testid=\"form-reset\"\n (click)=\"reset()\"\n [disabled]=\"isSaving()\"\n >\n Reset\n </button>\n <button\n type=\"submit\"\n class=\"ngx-form__submit\"\n data-testid=\"form-submit\"\n [disabled]=\"submitBlocked\"\n title=\"Shortcut: Ctrl+S\"\n >\n @if (isSaving() || loading) {\n Saving\u2026\n } @else {\n Save\n }\n </button>\n </div>\n }\n\n </form>\n} @else {\n <!-- permissions.view denied: render nothing of the record.\n Presentational only \u2014 the server must not send data this user may not see. -->\n <div class=\"ngx-form__denied\" role=\"status\" data-testid=\"form-access-denied\">\n You do not have permission to view this record.\n </div>\n}\n", styles: [".ngx-form__panel{display:grid;grid-template-columns:repeat(12,minmax(0,1fr));gap:16px 20px;align-items:start}.ngx-form__field-slot{display:flex;align-items:flex-start;gap:6px;min-width:0}.ngx-form__field-slot ngx-dynamic-field{flex:1;min-width:0}.ngx-form__lock{flex:0 0 auto;margin-top:22px;background:none;border:1px solid transparent;border-radius:6px;cursor:pointer;font-size:14px;line-height:1;padding:4px 6px}.ngx-form__lock:hover{border-color:#d1d5db;background:#f9fafb}@media (max-width: 768px){.ngx-form__panel{grid-template-columns:1fr}.ngx-form__field-slot{grid-column:span 12!important}}\n"], dependencies: [{ kind: "ngmodule", type: ReactiveFormsModule }, { kind: "directive", type: i1.ɵNgNoValidate, selector: "form:not([ngNoForm]):not([ngNativeValidate])" }, { kind: "directive", type: i1.NgControlStatusGroup, selector: "[formGroupName],[formArrayName],[ngModelGroup],[formGroup],form:not([ngNoForm]),[ngForm]" }, { kind: "directive", type: i1.FormGroupDirective, selector: "[formGroup]", inputs: ["formGroup"], outputs: ["ngSubmit"], exportAs: ["ngForm"] }, { kind: "component", type: DynamicFieldComponent, selector: "ngx-dynamic-field", inputs: ["field", "control", "config", "currentTabId", "language", "readonly", "userRoles"] }, { kind: "directive", type: NgComponentOutlet, selector: "[ngComponentOutlet]", inputs: ["ngComponentOutlet", "ngComponentOutletInputs", "ngComponentOutletInjector", "ngComponentOutletContent", "ngComponentOutletNgModule", "ngComponentOutletNgModuleFactory"] }] });
4152
4436
  }
4153
4437
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: DynamicFormComponent, decorators: [{
4154
4438
  type: Component,
4155
- args: [{ selector: 'ngx-dynamic-form', standalone: true, imports: [ReactiveFormsModule, DynamicFieldComponent, NgComponentOutlet], providers: [EntityRefSelectionService], template: "@if (canView) {\n <form [formGroup]=\"form\" (ngSubmit)=\"submit()\" class=\"ngx-form\" novalidate>\n\n <!-- Error message banner -->\n @if (error) {\n <div class=\"ngx-form__error-banner\" role=\"alert\" data-testid=\"form-error\">{{ error }}</div>\n }\n\n <!-- Rules Info Banners -->\n @for (infoKey of showInfoBanners ? Object.keys(ruleResult().infoBanners) : []; track infoKey) {\n <div class=\"ngx-form__info-banner\" role=\"status\" [attr.data-testid]=\"'info-banner-' + infoKey\">\n \u2139\uFE0F {{ ruleResult().infoBanners[infoKey] }}\n </div>\n }\n\n <!-- Rules Validation Errors & Warnings -->\n @for (errKey of Object.keys(ruleResult().validationErrors); track errKey) {\n <div class=\"ngx-form__error-banner\" role=\"alert\" [attr.data-testid]=\"'rule-error-' + errKey\">\n \u26A0\uFE0F {{ ruleResult().validationErrors[errKey] }}\n </div>\n }\n @for (warnKey of Object.keys(ruleResult().validationWarnings); track warnKey) {\n <div class=\"ngx-form__warning-banner\" role=\"status\" [attr.data-testid]=\"'rule-warning-' + warnKey\">\n \u26A1 {{ ruleResult().validationWarnings[warnKey] }}\n </div>\n }\n\n <!-- Deferred VALUE_CHANGED notice for criticalField edits (vs session baseline) -->\n @if (changedCriticalFields().length > 0) {\n <div class=\"ngx-form__warning-banner\" role=\"status\" data-testid=\"critical-change-banner\">\n \uD83D\uDD12 Critical field changed:\n @for (field of changedCriticalFields(); track field.id) {\n <span>{{ resolveFieldLabel(field) }}{{ $last ? '' : ', ' }}</span>\n }\n \u2014 this differs from the value at the start of this session.\n </div>\n }\n\n <!-- Primary Tab bar \u2014 only if visible tabs defined -->\n @if (visibleTabs.length > 0) {\n <div class=\"ngx-form__tabs\" role=\"tablist\" data-testid=\"tab-strip\">\n @for (tab of visibleTabs; track tab.id) {\n <button\n type=\"button\"\n class=\"ngx-form__tab\"\n [attr.data-testid]=\"'tab-' + tab.id\"\n [class.ngx-form__tab--active]=\"activeTab() === tab.id\"\n (click)=\"setActiveTab(tab.id)\"\n role=\"tab\"\n [attr.aria-selected]=\"activeTab() === tab.id\"\n >\n {{ resolveTabLabel(tab) }}\n </button>\n }\n </div>\n }\n\n <!-- Sub-tab bar \u2014 only if active tab has sub-tabs -->\n @if (visibleSubTabs.length > 0) {\n <div class=\"ngx-form__tabs ngx-form__tabs--sub\" role=\"tablist\" data-testid=\"subtab-strip\" style=\"margin-top: -12px; margin-bottom: 12px; font-size: 13px;\">\n @for (subTab of visibleSubTabs; track subTab.id) {\n <button\n type=\"button\"\n class=\"ngx-form__tab ngx-form__tab--sub\"\n [attr.data-testid]=\"'subtab-' + subTab.id\"\n [class.ngx-form__tab--active]=\"(activeSubTabConfig?.id) === subTab.id\"\n (click)=\"setActiveSubTab(subTab.id)\"\n role=\"tab\"\n [attr.aria-selected]=\"(activeSubTabConfig?.id) === subTab.id\"\n >\n {{ resolveTabLabel(subTab) }}\n </button>\n }\n </div>\n }\n\n <!-- Module tab component OR Fields for active tab -->\n @if (activeTabModuleComponent) {\n <div class=\"ngx-form__module-panel\" data-testid=\"module-panel\">\n <ng-container *ngComponentOutlet=\"activeTabModuleComponent; inputs: (activeSubTabConfig || activeTabConfig)?.moduleInputs\"></ng-container>\n </div>\n } @else {\n <!-- Fields for active tab (Responsive 12-column grid) -->\n <div class=\"ngx-form__panel\" data-testid=\"form-panel\">\n @for (field of fieldsForActiveTab; track field.id) {\n <div\n class=\"ngx-form__field-slot\"\n [style.gridColumn]=\"getFieldSpan(field)\"\n [attr.id]=\"'field-container-' + field.id\"\n >\n <ngx-dynamic-field\n [field]=\"field\"\n [control]=\"getControl(field.id, (activeSubTabConfig || activeTabConfig)?.id)!\"\n [config]=\"config\"\n [currentTabId]=\"(activeSubTabConfig || activeTabConfig)?.id\"\n [language]=\"language\"\n [readonly]=\"isFieldReadonly(field)\"\n [userRoles]=\"userRoles\"\n />\n @if (field.criticalField && !readonly) {\n <button\n type=\"button\"\n class=\"ngx-form__lock\"\n [attr.data-testid]=\"'lock-' + field.id\"\n [attr.aria-pressed]=\"!isFieldLocked(field)\"\n [attr.aria-label]=\"\n (isFieldLocked(field) ? 'Unlock ' : 'Lock ') + resolveFieldLabel(field)\n \"\n [title]=\"\n isFieldLocked(field)\n ? 'Critical field \u2014 click to unlock for editing'\n : 'Critical field \u2014 click to lock'\n \"\n (click)=\"toggleFieldLock(field)\"\n >\n {{ isFieldLocked(field) ? '\uD83D\uDD12' : '\uD83D\uDD13' }}\n </button>\n }\n </div>\n }\n </div>\n }\n\n <!-- Submit button \u2014 gated by RBAC and isSaving lock -->\n @if (!readonly && permissions.canEdit) {\n <div class=\"ngx-form__actions\" data-testid=\"form-actions\">\n <button\n type=\"button\"\n class=\"ngx-form__reset\"\n data-testid=\"form-reset\"\n (click)=\"reset()\"\n [disabled]=\"isSaving()\"\n >\n Reset\n </button>\n <button\n type=\"submit\"\n class=\"ngx-form__submit\"\n data-testid=\"form-submit\"\n [disabled]=\"submitBlocked\"\n title=\"Shortcut: Ctrl+S\"\n >\n @if (isSaving() || loading) {\n Saving\u2026\n } @else {\n Save\n }\n </button>\n </div>\n }\n\n </form>\n} @else {\n <!-- permissions.view denied: render nothing of the record.\n Presentational only \u2014 the server must not send data this user may not see. -->\n <div class=\"ngx-form__denied\" role=\"status\" data-testid=\"form-access-denied\">\n You do not have permission to view this record.\n </div>\n}\n", styles: [".ngx-form__panel{display:grid;grid-template-columns:repeat(12,minmax(0,1fr));gap:16px 20px;align-items:start}.ngx-form__field-slot{display:flex;align-items:flex-start;gap:6px;min-width:0}.ngx-form__field-slot ngx-dynamic-field{flex:1;min-width:0}.ngx-form__lock{flex:0 0 auto;margin-top:22px;background:none;border:1px solid transparent;border-radius:6px;cursor:pointer;font-size:14px;line-height:1;padding:4px 6px}.ngx-form__lock:hover{border-color:#d1d5db;background:#f9fafb}@media (max-width: 768px){.ngx-form__panel{grid-template-columns:1fr}.ngx-form__field-slot{grid-column:span 12!important}}\n"] }]
4439
+ args: [{ selector: 'ngx-dynamic-form', standalone: true, imports: [ReactiveFormsModule, DynamicFieldComponent, NgComponentOutlet], providers: [EntityRefSelectionService], template: "@if (canView) {\n <form [formGroup]=\"form\" (ngSubmit)=\"submit()\" class=\"ngx-form\" novalidate>\n\n <!-- Error message banner -->\n @if (error) {\n <div class=\"ngx-form__error-banner\" role=\"alert\" data-testid=\"form-error\">{{ error }}</div>\n }\n\n <!-- Rules Info Banners -->\n @for (infoKey of showInfoBanners ? Object.keys(ruleResult().infoBanners) : []; track infoKey) {\n <div class=\"ngx-form__info-banner\" role=\"status\" [attr.data-testid]=\"'info-banner-' + infoKey\">\n \u2139\uFE0F {{ ruleResult().infoBanners[infoKey] }}\n </div>\n }\n\n <!-- Rules Validation Errors & Warnings -->\n @for (errKey of Object.keys(ruleResult().validationErrors); track errKey) {\n <div class=\"ngx-form__error-banner\" role=\"alert\" [attr.data-testid]=\"'rule-error-' + errKey\">\n \u26A0\uFE0F {{ ruleResult().validationErrors[errKey] }}\n </div>\n }\n @for (warnKey of Object.keys(ruleResult().validationWarnings); track warnKey) {\n <div class=\"ngx-form__warning-banner\" role=\"status\" [attr.data-testid]=\"'rule-warning-' + warnKey\">\n \u26A1 {{ ruleResult().validationWarnings[warnKey] }}\n </div>\n }\n\n <!-- Deferred VALUE_CHANGED notice for criticalField edits (vs session baseline) -->\n @if (changedCriticalFields().length > 0) {\n <div class=\"ngx-form__warning-banner\" role=\"status\" data-testid=\"critical-change-banner\">\n \uD83D\uDD12 Critical field changed:\n @for (field of changedCriticalFields(); track field.id) {\n <span>{{ resolveFieldLabel(field) }}{{ $last ? '' : ', ' }}</span>\n }\n \u2014 this differs from the value at the start of this session.\n </div>\n }\n\n <!-- Primary Tab bar \u2014 only if visible tabs defined -->\n @if (visibleTabs.length > 0) {\n <div class=\"ngx-form__tabs\" role=\"tablist\" data-testid=\"tab-strip\">\n @for (tab of visibleTabs; track tab.id) {\n <button\n type=\"button\"\n class=\"ngx-form__tab\"\n [attr.data-testid]=\"'tab-' + tab.id\"\n [class.ngx-form__tab--active]=\"activeTab() === tab.id\"\n (click)=\"setActiveTab(tab.id)\"\n role=\"tab\"\n [attr.aria-selected]=\"activeTab() === tab.id\"\n >\n {{ resolveTabLabel(tab) }}\n </button>\n }\n </div>\n }\n\n <!-- Sub-tab bar \u2014 only if active tab has sub-tabs -->\n @if (visibleSubTabs.length > 0) {\n <div class=\"ngx-form__tabs ngx-form__tabs--sub\" role=\"tablist\" data-testid=\"subtab-strip\" style=\"margin-top: -12px; margin-bottom: 12px; font-size: 13px;\">\n @for (subTab of visibleSubTabs; track subTab.id) {\n <button\n type=\"button\"\n class=\"ngx-form__tab ngx-form__tab--sub\"\n [attr.data-testid]=\"'subtab-' + subTab.id\"\n [class.ngx-form__tab--active]=\"(activeSubTabConfig?.id) === subTab.id\"\n (click)=\"setActiveSubTab(subTab.id)\"\n role=\"tab\"\n [attr.aria-selected]=\"(activeSubTabConfig?.id) === subTab.id\"\n >\n {{ resolveTabLabel(subTab) }}\n </button>\n }\n </div>\n }\n\n <!--\n One tabpanel per tab, wrapping both branches. Focus moves here on a tab switch, and a\n module tab is still a tab \u2014 putting the panel semantics on only the fields branch left\n a module tab with nowhere for focus to land.\n -->\n <div\n role=\"tabpanel\"\n tabindex=\"-1\"\n #formPanel\n class=\"ngx-form__tabpanel\"\n [attr.aria-label]=\"activeTabLabel\"\n >\n @if (activeTabModuleComponent) {\n <div class=\"ngx-form__module-panel\" data-testid=\"module-panel\">\n <ng-container *ngComponentOutlet=\"activeTabModuleComponent; inputs: (activeSubTabConfig || activeTabConfig)?.moduleInputs\"></ng-container>\n </div>\n } @else {\n <!-- Fields for active tab (Responsive 12-column grid) -->\n <div class=\"ngx-form__panel\" data-testid=\"form-panel\">\n @for (field of fieldsForActiveTab; track field.id) {\n <div\n class=\"ngx-form__field-slot\"\n [style.gridColumn]=\"getFieldSpan(field)\"\n [attr.id]=\"'field-container-' + field.id\"\n >\n <ngx-dynamic-field\n [field]=\"field\"\n [control]=\"getControl(field.id, (activeSubTabConfig || activeTabConfig)?.id)!\"\n [config]=\"config\"\n [currentTabId]=\"(activeSubTabConfig || activeTabConfig)?.id\"\n [language]=\"language\"\n [readonly]=\"isFieldReadonly(field)\"\n [userRoles]=\"userRoles\"\n />\n @if (field.hasDrift) {\n <!--\n Drift was previously visible only in the builder, so a referenced field whose\n source had changed looked completely normal to the person filling the form in.\n role=\"status\" rather than \"alert\": it is information, not an error.\n -->\n <span\n class=\"ngx-field__drift\"\n role=\"status\"\n [attr.data-testid]=\"'field-' + field.id + '-drift'\"\n [title]=\"driftHint(field)\"\n >\u26A0 {{ driftHint(field) }}</span>\n }\n @if (field.criticalField && !readonly) {\n <button\n type=\"button\"\n class=\"ngx-form__lock\"\n [attr.data-testid]=\"'lock-' + field.id\"\n [attr.aria-pressed]=\"!isFieldLocked(field)\"\n [attr.aria-label]=\"\n (isFieldLocked(field) ? 'Unlock ' : 'Lock ') + resolveFieldLabel(field)\n \"\n [title]=\"\n isFieldLocked(field)\n ? 'Critical field \u2014 click to unlock for editing'\n : 'Critical field \u2014 click to lock'\n \"\n (click)=\"toggleFieldLock(field)\"\n >\n {{ isFieldLocked(field) ? '\uD83D\uDD12' : '\uD83D\uDD13' }}\n </button>\n }\n </div>\n }\n </div>\n }\n </div>\n\n <!-- Submit button \u2014 gated by RBAC and isSaving lock -->\n @if (!readonly && permissions.canEdit) {\n <div class=\"ngx-form__actions\" data-testid=\"form-actions\">\n <button\n type=\"button\"\n class=\"ngx-form__reset\"\n data-testid=\"form-reset\"\n (click)=\"reset()\"\n [disabled]=\"isSaving()\"\n >\n Reset\n </button>\n <button\n type=\"submit\"\n class=\"ngx-form__submit\"\n data-testid=\"form-submit\"\n [disabled]=\"submitBlocked\"\n title=\"Shortcut: Ctrl+S\"\n >\n @if (isSaving() || loading) {\n Saving\u2026\n } @else {\n Save\n }\n </button>\n </div>\n }\n\n </form>\n} @else {\n <!-- permissions.view denied: render nothing of the record.\n Presentational only \u2014 the server must not send data this user may not see. -->\n <div class=\"ngx-form__denied\" role=\"status\" data-testid=\"form-access-denied\">\n You do not have permission to view this record.\n </div>\n}\n", styles: [".ngx-form__panel{display:grid;grid-template-columns:repeat(12,minmax(0,1fr));gap:16px 20px;align-items:start}.ngx-form__field-slot{display:flex;align-items:flex-start;gap:6px;min-width:0}.ngx-form__field-slot ngx-dynamic-field{flex:1;min-width:0}.ngx-form__lock{flex:0 0 auto;margin-top:22px;background:none;border:1px solid transparent;border-radius:6px;cursor:pointer;font-size:14px;line-height:1;padding:4px 6px}.ngx-form__lock:hover{border-color:#d1d5db;background:#f9fafb}@media (max-width: 768px){.ngx-form__panel{grid-template-columns:1fr}.ngx-form__field-slot{grid-column:span 12!important}}\n"] }]
4156
4440
  }], propDecorators: { config: [{
4157
4441
  type: Input
4158
4442
  }], rules: [{
@@ -4187,6 +4471,14 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImpo
4187
4471
  type: Output
4188
4472
  }], activeTabChange: [{
4189
4473
  type: Output
4474
+ }], saveRejected: [{
4475
+ type: Output
4476
+ }], formPanel: [{
4477
+ type: ViewChild,
4478
+ args: ['formPanel']
4479
+ }], fieldHosts: [{
4480
+ type: ViewChildren,
4481
+ args: [DynamicFieldComponent]
4190
4482
  }], handleKeyboardEvent: [{
4191
4483
  type: HostListener,
4192
4484
  args: ['window:keydown', ['$event']]
@@ -4637,7 +4929,7 @@ class DynamicRecordFormComponent {
4637
4929
  }
4638
4930
  }
4639
4931
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: DynamicRecordFormComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
4640
- static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "17.3.12", type: DynamicRecordFormComponent, isStandalone: true, selector: "ngx-dynamic-record-form", inputs: { config: "config", rules: "rules", initialData: "initialData", userRoles: "userRoles", language: "language", readonly: "readonly", loading: "loading", error: "error", isReadOnly: "isReadOnly", readOnlyFields: "readOnlyFields", viewMode: "viewMode" }, outputs: { formSubmit: "formSubmit", formChange: "formChange", formReset: "formReset", sectionSave: "sectionSave" }, viewQueries: [{ propertyName: "dynamicFormComp", first: true, predicate: DynamicFormComponent, descendants: true }], usesOnChanges: true, ngImport: i0, template: "<div class=\"ngx-record-editor\">\n <!-- Record Header -->\n <div class=\"ngx-record-editor__header\">\n <div class=\"ngx-record-editor__title-group\">\n @if (profileImageUrl) {\n <img\n class=\"ngx-record-editor__avatar\"\n data-testid=\"header-profile-image\"\n [src]=\"profileImageUrl\"\n [alt]=\"recordTitle\"\n />\n } @else {\n <div class=\"ngx-record-editor__avatar\" data-testid=\"header-avatar-initial\">\n {{ avatarLetter }}\n </div>\n }\n <div>\n <h2 class=\"ngx-record-editor__title\" data-testid=\"record-title\">\n {{ recordTitle }}\n </h2>\n <span class=\"ngx-record-editor__subtitle\">Entity: {{ config.entity }}</span>\n </div>\n </div>\n\n <!-- Record-level status switch, outside section editing -->\n @if (headerToggleField; as toggleField) {\n <label class=\"ngx-record-editor__header-toggle\">\n <span>{{ formatFieldLabel(toggleField) }}</span>\n <input\n type=\"checkbox\"\n role=\"switch\"\n data-testid=\"header-toggle\"\n [checked]=\"headerToggleValue\"\n [disabled]=\"recordReadOnly\"\n [attr.aria-checked]=\"headerToggleValue\"\n (change)=\"toggleHeaderStatus()\"\n />\n <span class=\"ngx-record-editor__header-toggle-text\">{{ headerToggleValue ? 'Yes' : 'No' }}</span>\n </label>\n }\n </div>\n\n <!-- Dismissible info banners (rules `info` action) -->\n @for (banner of infoBanners; track banner.fieldId) {\n <div\n class=\"ngx-record-editor__banner ngx-record-editor__banner--info\"\n role=\"status\"\n [attr.data-testid]=\"'info-banner-' + banner.fieldId\"\n >\n <span>\u2139\uFE0F {{ banner.message }}</span>\n <button\n type=\"button\"\n class=\"ngx-record-editor__banner-dismiss\"\n [attr.data-testid]=\"'dismiss-' + banner.fieldId\"\n [attr.aria-label]=\"'Dismiss: ' + banner.message\"\n (click)=\"dismissInfoBanner(banner.fieldId)\"\n >\u2715</button>\n </div>\n }\n\n <!-- Modification baseline banner -->\n @if (isModified()) {\n <div class=\"ngx-record-editor__banner ngx-record-editor__banner--warning\">\n \uD83D\uDD12 Record modified from session baseline \u2014 unsaved changes pending.\n </div>\n }\n\n <!-- Summary Panel (showOnMinimize fields with interactive jump) -->\n @if (summaryFields().length > 0) {\n <div class=\"ngx-record-editor__summary-panel\" data-testid=\"summary-panel\">\n @for (sf of summaryFields(); track sf.id) {\n <div\n class=\"ngx-record-editor__summary-item\"\n [attr.data-testid]=\"'summary-' + sf.id\"\n (click)=\"jumpToField(sf.id)\"\n [title]=\"'Click to jump to ' + formatFieldLabel(sf)\"\n >\n <span class=\"ngx-record-editor__summary-label\">{{ formatFieldLabel(sf) }}</span>\n <span class=\"ngx-record-editor__summary-val\">{{ formatFieldValue(sf) }}</span>\n </div>\n }\n </div>\n }\n\n <!-- Section edit bar: one tab is edited at a time, and validated on its own -->\n @if (viewMode && !recordReadOnly) {\n <div class=\"ngx-record-editor__section-bar\">\n @if (isEditingActiveTab) {\n <span class=\"ngx-record-editor__section-hint\">Editing this section</span>\n <button type=\"button\" class=\"ngx-form__reset\" data-testid=\"cancel-section\" (click)=\"cancelSection()\">\n Cancel\n </button>\n <button type=\"button\" class=\"ngx-form__submit\" data-testid=\"save-section\" (click)=\"saveSection()\">\n Save section\n </button>\n } @else {\n <button type=\"button\" class=\"ngx-form__reset\" data-testid=\"edit-section\" (click)=\"editSection()\">\n Edit section\n </button>\n }\n </div>\n }\n\n @if (Object.keys(sectionErrors()).length > 0) {\n <div class=\"ngx-record-editor__banner ngx-record-editor__banner--error\" role=\"alert\" data-testid=\"section-errors\">\n @for (key of Object.keys(sectionErrors()); track key) {\n <div>\u26A0\uFE0F {{ sectionErrors()[key] }}</div>\n }\n </div>\n }\n\n <!-- Array rows: listed here, edited in the drawer below -->\n @for (arrayField of arrayFieldsForActiveTab; track arrayField.id) {\n <div class=\"ngx-record-editor__rows\" [attr.data-testid]=\"'rows-' + arrayField.id\">\n <div class=\"ngx-record-editor__rows-head\">\n <span class=\"ngx-record-editor__summary-label\">{{ formatFieldLabel(arrayField) }}</span>\n @if (!sectionReadOnly) {\n <button\n type=\"button\"\n class=\"ngx-form__reset\"\n [attr.data-testid]=\"'add-row-' + arrayField.id\"\n (click)=\"openAddRow(arrayField)\"\n >+ Row</button>\n }\n </div>\n\n @for (row of rowsOf(arrayField); track $index) {\n <div class=\"ngx-record-editor__row\" [attr.data-testid]=\"'row-' + arrayField.id + '-' + $index\">\n <span class=\"ngx-record-editor__row-text\">{{ rowSummary(arrayField, row) }}</span>\n @if (!sectionReadOnly) {\n <button type=\"button\" class=\"ngx-record-editor__row-btn\"\n [attr.data-testid]=\"'edit-row-' + arrayField.id + '-' + $index\"\n (click)=\"openEditRow(arrayField, $index)\">Edit</button>\n <button type=\"button\" class=\"ngx-record-editor__row-btn\"\n [attr.data-testid]=\"'delete-row-' + arrayField.id + '-' + $index\"\n (click)=\"deleteRow(arrayField, $index)\">Delete</button>\n }\n </div>\n } @empty {\n <p class=\"ngx-record-editor__row-empty\">No rows yet.</p>\n }\n </div>\n }\n\n <!--\n Row drawer. Deliberately outside the tab panel: building a row's FormGroup inside the\n tab view races the tab's own initialisation (the reference hit this with FormArray).\n -->\n @if (inlineRowField(); as rowField) {\n <div class=\"ngx-record-editor__drawer\" data-testid=\"row-drawer\" [formGroup]=\"inlineRowForm!\">\n <div class=\"ngx-record-editor__rows-head\">\n <strong>{{ inlineRowIndex() === null ? 'Add' : 'Edit' }} {{ formatFieldLabel(rowField) }} row</strong>\n </div>\n\n <div class=\"ngx-form__panel\">\n @for (child of rowField.children ?? []; track child.id) {\n <ngx-dynamic-field\n [field]=\"child\"\n [control]=\"inlineRowForm!.get(child.id)!\"\n [config]=\"config\"\n [language]=\"language\"\n [readonly]=\"false\"\n [userRoles]=\"userRoles\"\n [style.gridColumn]=\"'span 12'\"\n />\n }\n </div>\n\n <div class=\"ngx-record-editor__section-bar\">\n <button type=\"button\" class=\"ngx-form__reset\" data-testid=\"cancel-row\" (click)=\"cancelRow()\">Cancel</button>\n <button type=\"button\" class=\"ngx-form__submit\" data-testid=\"save-row\" (click)=\"saveRow()\">Save row</button>\n </div>\n </div>\n }\n\n <!-- Main Dynamic Form -->\n <ngx-dynamic-form\n [config]=\"config\"\n [rules]=\"rules\"\n [initialData]=\"initialData\"\n [originalData]=\"originalBaseline()\"\n [userRoles]=\"userRoles\"\n [language]=\"language\"\n [readonly]=\"sectionReadOnly\"\n [readOnlyFields]=\"readOnlyFields\"\n [showInfoBanners]=\"false\"\n [loading]=\"loading\"\n [error]=\"error\"\n (formChange)=\"onFormChange($event)\"\n (formSubmit)=\"onFormSubmit($event)\"\n (formReset)=\"onFormReset()\"\n (activeTabChange)=\"onActiveTabChange($event)\"\n />\n</div>\n", styles: [".ngx-record-editor{display:flex;flex-direction:column;gap:16px;background:#fff;border:1px solid #e5e7eb;border-radius:12px;padding:20px;box-shadow:0 4px 6px -1px #0000000d}.ngx-record-editor__header{display:flex;align-items:center;justify-content:space-between;padding-bottom:16px;border-bottom:1px solid #f3f4f6}.ngx-record-editor__title-group{display:flex;align-items:center;gap:12px}.ngx-record-editor__avatar{width:48px;height:48px;border-radius:50%;object-fit:cover;background:#e0e7ff;color:#4f46e5;display:flex;align-items:center;justify-content:center;font-weight:700;font-size:18px}.ngx-record-editor__title{font-size:20px;font-weight:700;color:#111827;margin:0}.ngx-record-editor__subtitle{font-size:13px;color:#6b7280}.ngx-record-editor__header-toggle{display:flex;align-items:center;gap:8px;font-size:13px;color:#374151}.ngx-record-editor__header-toggle-text{font-weight:600}.ngx-record-editor__banner{padding:10px 14px;border-radius:8px;font-size:13px;font-weight:500}.ngx-record-editor__banner--info{background:#eff6ff;border:1px solid #dbeafe;color:#1d4ed8;display:flex;align-items:center;justify-content:space-between;gap:12px}.ngx-record-editor__banner-dismiss{background:none;border:none;cursor:pointer;color:inherit;font-size:14px;line-height:1;padding:2px 6px;border-radius:4px}.ngx-record-editor__banner-dismiss:hover{background:#0000000f}.ngx-record-editor__banner--error{background:#fef2f2;border:1px solid #fecaca;color:#b91c1c}.ngx-record-editor__section-bar{display:flex;align-items:center;justify-content:flex-end;gap:8px}.ngx-record-editor__section-hint{margin-right:auto;font-size:12px;color:#6b7280}.ngx-record-editor__banner--warning{background:#fffbeb;border:1px solid #fef3c7;color:#b45309}.ngx-record-editor__rows{border:1px solid #f3f4f6;border-radius:8px;padding:12px 14px;display:flex;flex-direction:column;gap:6px}.ngx-record-editor__rows-head{display:flex;align-items:center;justify-content:space-between;gap:8px}.ngx-record-editor__row{display:flex;align-items:center;gap:8px;padding:6px 8px;border-radius:6px;background:#f9fafb}.ngx-record-editor__row-text{flex:1;font-size:13px;overflow-wrap:anywhere}.ngx-record-editor__row-btn{background:none;border:1px solid #e5e7eb;border-radius:6px;cursor:pointer;font-size:12px;padding:2px 8px}.ngx-record-editor__row-empty{font-size:12px;color:#6b7280;margin:0}.ngx-record-editor__drawer{border:1px solid #dbeafe;background:#f8fafc;border-radius:8px;padding:14px;display:flex;flex-direction:column;gap:12px}.ngx-record-editor__summary-panel{background:#f9fafb;border:1px solid #f3f4f6;border-radius:8px;padding:12px 16px;display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:12px}.ngx-record-editor__summary-item{display:flex;flex-direction:column;cursor:pointer;padding:6px 10px;border-radius:6px;transition:background .15s}.ngx-record-editor__summary-item:hover{background:#eff6ff}.ngx-record-editor__summary-label{font-size:11px;text-transform:uppercase;letter-spacing:.05em;color:#6b7280;font-weight:600}.ngx-record-editor__summary-val{font-size:14px;font-weight:600;color:#1f2937}\n"], dependencies: [{ kind: "ngmodule", type: CommonModule }, { kind: "ngmodule", type: ReactiveFormsModule }, { kind: "directive", type: i1.NgControlStatusGroup, selector: "[formGroupName],[formArrayName],[ngModelGroup],[formGroup],form:not([ngNoForm]),[ngForm]" }, { kind: "directive", type: i1.FormGroupDirective, selector: "[formGroup]", inputs: ["formGroup"], outputs: ["ngSubmit"], exportAs: ["ngForm"] }, { kind: "component", type: DynamicFormComponent, selector: "ngx-dynamic-form", inputs: ["config", "rules", "initialData", "originalData", "userRoles", "language", "readonly", "readOnlyFields", "showInfoBanners", "loading", "error", "changeDebounceMs", "preview"], outputs: ["formSubmit", "formChange", "formReset", "activeTabChange"] }, { kind: "component", type: DynamicFieldComponent, selector: "ngx-dynamic-field", inputs: ["field", "control", "config", "currentTabId", "language", "readonly", "userRoles"] }] });
4932
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "17.3.12", type: DynamicRecordFormComponent, isStandalone: true, selector: "ngx-dynamic-record-form", inputs: { config: "config", rules: "rules", initialData: "initialData", userRoles: "userRoles", language: "language", readonly: "readonly", loading: "loading", error: "error", isReadOnly: "isReadOnly", readOnlyFields: "readOnlyFields", viewMode: "viewMode" }, outputs: { formSubmit: "formSubmit", formChange: "formChange", formReset: "formReset", sectionSave: "sectionSave" }, viewQueries: [{ propertyName: "dynamicFormComp", first: true, predicate: DynamicFormComponent, descendants: true }], usesOnChanges: true, ngImport: i0, template: "<div class=\"ngx-record-editor\">\n <!-- Record Header -->\n <div class=\"ngx-record-editor__header\">\n <div class=\"ngx-record-editor__title-group\">\n @if (profileImageUrl) {\n <img\n class=\"ngx-record-editor__avatar\"\n data-testid=\"header-profile-image\"\n [src]=\"profileImageUrl\"\n [alt]=\"recordTitle\"\n />\n } @else {\n <div class=\"ngx-record-editor__avatar\" data-testid=\"header-avatar-initial\">\n {{ avatarLetter }}\n </div>\n }\n <div>\n <h2 class=\"ngx-record-editor__title\" data-testid=\"record-title\">\n {{ recordTitle }}\n </h2>\n <span class=\"ngx-record-editor__subtitle\">Entity: {{ config.entity }}</span>\n </div>\n </div>\n\n <!-- Record-level status switch, outside section editing -->\n @if (headerToggleField; as toggleField) {\n <label class=\"ngx-record-editor__header-toggle\">\n <span>{{ formatFieldLabel(toggleField) }}</span>\n <input\n type=\"checkbox\"\n role=\"switch\"\n data-testid=\"header-toggle\"\n [checked]=\"headerToggleValue\"\n [disabled]=\"recordReadOnly\"\n [attr.aria-checked]=\"headerToggleValue\"\n (change)=\"toggleHeaderStatus()\"\n />\n <span class=\"ngx-record-editor__header-toggle-text\">{{ headerToggleValue ? 'Yes' : 'No' }}</span>\n </label>\n }\n </div>\n\n <!-- Dismissible info banners (rules `info` action) -->\n @for (banner of infoBanners; track banner.fieldId) {\n <div\n class=\"ngx-record-editor__banner ngx-record-editor__banner--info\"\n role=\"status\"\n [attr.data-testid]=\"'info-banner-' + banner.fieldId\"\n >\n <span>\u2139\uFE0F {{ banner.message }}</span>\n <button\n type=\"button\"\n class=\"ngx-record-editor__banner-dismiss\"\n [attr.data-testid]=\"'dismiss-' + banner.fieldId\"\n [attr.aria-label]=\"'Dismiss: ' + banner.message\"\n (click)=\"dismissInfoBanner(banner.fieldId)\"\n >\u2715</button>\n </div>\n }\n\n <!-- Modification baseline banner -->\n @if (isModified()) {\n <div class=\"ngx-record-editor__banner ngx-record-editor__banner--warning\">\n \uD83D\uDD12 Record modified from session baseline \u2014 unsaved changes pending.\n </div>\n }\n\n <!-- Summary Panel (showOnMinimize fields with interactive jump) -->\n @if (summaryFields().length > 0) {\n <div class=\"ngx-record-editor__summary-panel\" data-testid=\"summary-panel\">\n @for (sf of summaryFields(); track sf.id) {\n <div\n class=\"ngx-record-editor__summary-item\"\n [attr.data-testid]=\"'summary-' + sf.id\"\n (click)=\"jumpToField(sf.id)\"\n [title]=\"'Click to jump to ' + formatFieldLabel(sf)\"\n >\n <span class=\"ngx-record-editor__summary-label\">{{ formatFieldLabel(sf) }}</span>\n <span class=\"ngx-record-editor__summary-val\">{{ formatFieldValue(sf) }}</span>\n </div>\n }\n </div>\n }\n\n <!-- Section edit bar: one tab is edited at a time, and validated on its own -->\n @if (viewMode && !recordReadOnly) {\n <div class=\"ngx-record-editor__section-bar\">\n @if (isEditingActiveTab) {\n <span class=\"ngx-record-editor__section-hint\">Editing this section</span>\n <button type=\"button\" class=\"ngx-form__reset\" data-testid=\"cancel-section\" (click)=\"cancelSection()\">\n Cancel\n </button>\n <button type=\"button\" class=\"ngx-form__submit\" data-testid=\"save-section\" (click)=\"saveSection()\">\n Save section\n </button>\n } @else {\n <button type=\"button\" class=\"ngx-form__reset\" data-testid=\"edit-section\" (click)=\"editSection()\">\n Edit section\n </button>\n }\n </div>\n }\n\n @if (Object.keys(sectionErrors()).length > 0) {\n <div class=\"ngx-record-editor__banner ngx-record-editor__banner--error\" role=\"alert\" data-testid=\"section-errors\">\n @for (key of Object.keys(sectionErrors()); track key) {\n <div>\u26A0\uFE0F {{ sectionErrors()[key] }}</div>\n }\n </div>\n }\n\n <!-- Array rows: listed here, edited in the drawer below -->\n @for (arrayField of arrayFieldsForActiveTab; track arrayField.id) {\n <div class=\"ngx-record-editor__rows\" [attr.data-testid]=\"'rows-' + arrayField.id\">\n <div class=\"ngx-record-editor__rows-head\">\n <span class=\"ngx-record-editor__summary-label\">{{ formatFieldLabel(arrayField) }}</span>\n @if (!sectionReadOnly) {\n <button\n type=\"button\"\n class=\"ngx-form__reset\"\n [attr.data-testid]=\"'add-row-' + arrayField.id\"\n (click)=\"openAddRow(arrayField)\"\n >+ Row</button>\n }\n </div>\n\n @for (row of rowsOf(arrayField); track $index) {\n <div class=\"ngx-record-editor__row\" [attr.data-testid]=\"'row-' + arrayField.id + '-' + $index\">\n <span class=\"ngx-record-editor__row-text\">{{ rowSummary(arrayField, row) }}</span>\n @if (!sectionReadOnly) {\n <button type=\"button\" class=\"ngx-record-editor__row-btn\"\n [attr.data-testid]=\"'edit-row-' + arrayField.id + '-' + $index\"\n (click)=\"openEditRow(arrayField, $index)\">Edit</button>\n <button type=\"button\" class=\"ngx-record-editor__row-btn\"\n [attr.data-testid]=\"'delete-row-' + arrayField.id + '-' + $index\"\n (click)=\"deleteRow(arrayField, $index)\">Delete</button>\n }\n </div>\n } @empty {\n <p class=\"ngx-record-editor__row-empty\">No rows yet.</p>\n }\n </div>\n }\n\n <!--\n Row drawer. Deliberately outside the tab panel: building a row's FormGroup inside the\n tab view races the tab's own initialisation (the reference hit this with FormArray).\n -->\n @if (inlineRowField(); as rowField) {\n <div class=\"ngx-record-editor__drawer\" data-testid=\"row-drawer\" [formGroup]=\"inlineRowForm!\">\n <div class=\"ngx-record-editor__rows-head\">\n <strong>{{ inlineRowIndex() === null ? 'Add' : 'Edit' }} {{ formatFieldLabel(rowField) }} row</strong>\n </div>\n\n <div class=\"ngx-form__panel\">\n @for (child of rowField.children ?? []; track child.id) {\n <ngx-dynamic-field\n [field]=\"child\"\n [control]=\"inlineRowForm!.get(child.id)!\"\n [config]=\"config\"\n [language]=\"language\"\n [readonly]=\"false\"\n [userRoles]=\"userRoles\"\n [style.gridColumn]=\"'span 12'\"\n />\n }\n </div>\n\n <div class=\"ngx-record-editor__section-bar\">\n <button type=\"button\" class=\"ngx-form__reset\" data-testid=\"cancel-row\" (click)=\"cancelRow()\">Cancel</button>\n <button type=\"button\" class=\"ngx-form__submit\" data-testid=\"save-row\" (click)=\"saveRow()\">Save row</button>\n </div>\n </div>\n }\n\n <!-- Main Dynamic Form -->\n <ngx-dynamic-form\n [config]=\"config\"\n [rules]=\"rules\"\n [initialData]=\"initialData\"\n [originalData]=\"originalBaseline()\"\n [userRoles]=\"userRoles\"\n [language]=\"language\"\n [readonly]=\"sectionReadOnly\"\n [readOnlyFields]=\"readOnlyFields\"\n [showInfoBanners]=\"false\"\n [loading]=\"loading\"\n [error]=\"error\"\n (formChange)=\"onFormChange($event)\"\n (formSubmit)=\"onFormSubmit($event)\"\n (formReset)=\"onFormReset()\"\n (activeTabChange)=\"onActiveTabChange($event)\"\n />\n</div>\n", styles: [".ngx-record-editor{display:flex;flex-direction:column;gap:16px;background:#fff;border:1px solid #e5e7eb;border-radius:12px;padding:20px;box-shadow:0 4px 6px -1px #0000000d}.ngx-record-editor__header{display:flex;align-items:center;justify-content:space-between;padding-bottom:16px;border-bottom:1px solid #f3f4f6}.ngx-record-editor__title-group{display:flex;align-items:center;gap:12px}.ngx-record-editor__avatar{width:48px;height:48px;border-radius:50%;object-fit:cover;background:#e0e7ff;color:#4f46e5;display:flex;align-items:center;justify-content:center;font-weight:700;font-size:18px}.ngx-record-editor__title{font-size:20px;font-weight:700;color:#111827;margin:0}.ngx-record-editor__subtitle{font-size:13px;color:#6b7280}.ngx-record-editor__header-toggle{display:flex;align-items:center;gap:8px;font-size:13px;color:#374151}.ngx-record-editor__header-toggle-text{font-weight:600}.ngx-record-editor__banner{padding:10px 14px;border-radius:8px;font-size:13px;font-weight:500}.ngx-record-editor__banner--info{background:#eff6ff;border:1px solid #dbeafe;color:#1d4ed8;display:flex;align-items:center;justify-content:space-between;gap:12px}.ngx-record-editor__banner-dismiss{background:none;border:none;cursor:pointer;color:inherit;font-size:14px;line-height:1;padding:2px 6px;border-radius:4px}.ngx-record-editor__banner-dismiss:hover{background:#0000000f}.ngx-record-editor__banner--error{background:#fef2f2;border:1px solid #fecaca;color:#b91c1c}.ngx-record-editor__section-bar{display:flex;align-items:center;justify-content:flex-end;gap:8px}.ngx-record-editor__section-hint{margin-right:auto;font-size:12px;color:#6b7280}.ngx-record-editor__banner--warning{background:#fffbeb;border:1px solid #fef3c7;color:#b45309}.ngx-record-editor__rows{border:1px solid #f3f4f6;border-radius:8px;padding:12px 14px;display:flex;flex-direction:column;gap:6px}.ngx-record-editor__rows-head{display:flex;align-items:center;justify-content:space-between;gap:8px}.ngx-record-editor__row{display:flex;align-items:center;gap:8px;padding:6px 8px;border-radius:6px;background:#f9fafb}.ngx-record-editor__row-text{flex:1;font-size:13px;overflow-wrap:anywhere}.ngx-record-editor__row-btn{background:none;border:1px solid #e5e7eb;border-radius:6px;cursor:pointer;font-size:12px;padding:2px 8px}.ngx-record-editor__row-empty{font-size:12px;color:#6b7280;margin:0}.ngx-record-editor__drawer{border:1px solid #dbeafe;background:#f8fafc;border-radius:8px;padding:14px;display:flex;flex-direction:column;gap:12px}.ngx-record-editor__summary-panel{background:#f9fafb;border:1px solid #f3f4f6;border-radius:8px;padding:12px 16px;display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:12px}.ngx-record-editor__summary-item{display:flex;flex-direction:column;cursor:pointer;padding:6px 10px;border-radius:6px;transition:background .15s}.ngx-record-editor__summary-item:hover{background:#eff6ff}.ngx-record-editor__summary-label{font-size:11px;text-transform:uppercase;letter-spacing:.05em;color:#6b7280;font-weight:600}.ngx-record-editor__summary-val{font-size:14px;font-weight:600;color:#1f2937}\n"], dependencies: [{ kind: "ngmodule", type: CommonModule }, { kind: "ngmodule", type: ReactiveFormsModule }, { kind: "directive", type: i1.NgControlStatusGroup, selector: "[formGroupName],[formArrayName],[ngModelGroup],[formGroup],form:not([ngNoForm]),[ngForm]" }, { kind: "directive", type: i1.FormGroupDirective, selector: "[formGroup]", inputs: ["formGroup"], outputs: ["ngSubmit"], exportAs: ["ngForm"] }, { kind: "component", type: DynamicFormComponent, selector: "ngx-dynamic-form", inputs: ["config", "rules", "initialData", "originalData", "userRoles", "language", "readonly", "readOnlyFields", "showInfoBanners", "loading", "error", "changeDebounceMs", "preview"], outputs: ["formSubmit", "formChange", "formReset", "activeTabChange", "saveRejected"] }, { kind: "component", type: DynamicFieldComponent, selector: "ngx-dynamic-field", inputs: ["field", "control", "config", "currentTabId", "language", "readonly", "userRoles"] }] });
4641
4933
  }
4642
4934
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: DynamicRecordFormComponent, decorators: [{
4643
4935
  type: Component,
@@ -4683,5 +4975,5 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImpo
4683
4975
  * Generated bundle index. Do not edit.
4684
4976
  */
4685
4977
 
4686
- export { ArrayFieldComponent, BooleanFieldComponent, COMMON_MODULES_REGISTRY, CONFIG_SOURCE, CascadeDataService, CheckboxFieldComponent, ConfigSourceService, CurrencyFieldComponent, DateFieldComponent, DropdownFieldComponent, DynamicFieldComponent, DynamicFormComponent, DynamicRecordFormComponent, ENTITY_REF_CACHE_STORE, ENTITY_REF_REGISTRY, EmailFieldComponent, EntityRefFieldComponent, EntityRefRegistryService, EntityRefSelectionService, EntityReferenceService, FIELD_TYPE_REGISTRY, FIELD_TYPE_SETS, FieldRegistryService, FileFieldComponent, GroupFieldComponent, HOOK_REGISTRY, HookRegistryService, ImageFieldComponent, InMemoryEntityRefCacheStore, LOOKUP_REGISTRY, LookupRegistryService, MASKED_ROLES, MonthYearFieldComponent, MultiSelectFieldComponent, NumberFieldComponent, PasswordFieldComponent, RECORD_MIGRATIONS, RadioFieldComponent, RbacService, RulesEvaluationService, SYSTEM_DEFAULT_CAN_EDIT, TextFieldComponent, TextareaFieldComponent, UPLOAD_HANDLER, VALIDATOR_REGISTRY, ValidatorRegistryService, builtInFieldTypes, provideBuiltInFieldTypes, provideFieldTypes, provideNgxDynamicEntity, refreshChoiceOptions };
4978
+ export { ASYNC_VALIDATOR_REGISTRY, ArrayFieldComponent, BooleanFieldComponent, COMMON_MODULES_REGISTRY, CONFIG_SOURCE, CascadeDataService, CheckboxFieldComponent, ConfigSourceService, CurrencyFieldComponent, DEFAULT_VALIDATION_MESSAGES, DateFieldComponent, DropdownFieldComponent, DynamicFieldComponent, DynamicFormComponent, DynamicRecordFormComponent, ENTITY_REF_CACHE_STORE, ENTITY_REF_REGISTRY, EmailFieldComponent, EntityRefFieldComponent, EntityRefRegistryService, EntityRefSelectionService, EntityReferenceService, FIELD_TYPE_REGISTRY, FIELD_TYPE_SETS, FieldRegistryService, FileFieldComponent, GroupFieldComponent, HOOK_REGISTRY, HookRegistryService, ImageFieldComponent, InMemoryEntityRefCacheStore, LOOKUP_REGISTRY, LookupRegistryService, MASKED_ROLES, MonthYearFieldComponent, MultiSelectFieldComponent, NumberFieldComponent, PasswordFieldComponent, RECORD_MIGRATIONS, RadioFieldComponent, RbacService, RulesEvaluationService, SYSTEM_DEFAULT_CAN_EDIT, TextFieldComponent, TextareaFieldComponent, UPLOAD_HANDLER, VALIDATION_MESSAGES, VALIDATOR_REGISTRY, ValidationMessagesService, ValidatorRegistryService, builtInFieldTypes, provideBuiltInFieldTypes, provideFieldTypes, provideNgxDynamicEntity, refreshChoiceOptions };
4687
4979
  //# sourceMappingURL=ngx-dynamic-entity.mjs.map