ngx-dynamic-entity 1.14.0 → 2.0.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.
@@ -1,8 +1,8 @@
1
1
  import * as i0 from '@angular/core';
2
2
  import { InjectionToken, makeEnvironmentProviders, inject, Injectable, signal, Input, ChangeDetectionStrategy, Component, ChangeDetectorRef, DestroyRef, Injector, isDevMode, ViewContainerRef, ViewChild, forwardRef, Optional, Inject, EventEmitter, ElementRef, computed, afterNextRender, HostListener, ViewChildren, Output } from '@angular/core';
3
3
  import * as i1 from '@angular/forms';
4
- import { ReactiveFormsModule, Validators, FormBuilder, FormArray, FormGroup, FormsModule } from '@angular/forms';
5
- import { resolveLabel, formatDisplayValue, normalizeLookupValues, lookupValuesToOptions, valuesMatch, getOptionStoredValue, resolveOptionLabel, resolveOptionValue, buildReferenceCacheKey, normalizeReferenceOptions, entityKeyFromCacheKey, applyCascadeFilter, resolveEffectiveMask, shouldMaskField, findTab, fileRefName, evaluateFormRules, filterRulesForTab, evaluateFieldVisibility, normalizeConfigOptions, migrateRecord, getTabData, getValueByPath, setTabData, setValueByPath, normalizeArrayStructures, ambiguousFieldIds, parseFieldRef, toRefToken, collectFieldScopes, fieldRefFor, refOf, getTabPath, applyAutoPatch, applyPatchOnTrue, parseCsv, deriveImportColumns, suggestMapping, applyMapping, toCsv, stripIndices, CORE_VERSION, coerceCell, buildTemplateSpec, collectLeafTargets, formatConfigProblems } from '@dynamic-entity/core';
4
+ import { ReactiveFormsModule, Validators, FormBuilder, FormGroup, FormArray, FormsModule } from '@angular/forms';
5
+ import { resolveLabel, formatDisplayValue, normalizeLookupValues, lookupValuesToOptions, valuesMatch, resolveOptionLabel, resolveOptionValue, buildReferenceCacheKey, normalizeReferenceOptions, entityKeyFromCacheKey, applyCascadeFilter, resolveEffectiveMask, shouldMaskField, findTab, fileRefName, evaluateFormRules, filterRulesForTab, evaluateFieldVisibility, getTabPath, parseFieldRef, setTabData, setValueByPath, normalizeArrayStructures, getTabData, getValueByPath, normalizeConfigOptions, migrateRecord, ambiguousFieldIds, toRefToken, fieldRefFor, collectFieldScopes, refOf, applyAutoPatch, applyPatchOnTrue, fieldsUnderTab, valuesEqual, parseCsv, deriveImportColumns, suggestMapping, applyMapping, toCsv, stripIndices, CORE_VERSION, coerceCell, buildTemplateSpec, collectLeafTargets, formatConfigProblems } from '@dynamic-entity/core';
6
6
  export { formatDisplayValue, normalizeConfig, normalizeOption, resolveLabel, resolveOptionLabel, resolveOptionValue, valuesMatch } from '@dynamic-entity/core';
7
7
  import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
8
8
  import { Subject, isObservable, firstValueFrom } from 'rxjs';
@@ -443,7 +443,7 @@ function fieldDomId(field, instance, suffix = '') {
443
443
  * is read first. `null` rather than an empty string when there is nothing to describe — an
444
444
  * empty `aria-describedby` is a dangling reference, not an absent one.
445
445
  *
446
- * Shared rather than written into each of the nineteen field components, because the rule is
446
+ * Shared rather than written into each of the twenty-one field components, because the rule is
447
447
  * the same everywhere and getting it wrong is invisible until somebody uses a screen reader.
448
448
  */
449
449
  function fieldDescribedBy(domId, parts) {
@@ -1100,17 +1100,29 @@ class MarkdownFieldComponent {
1100
1100
  * A renderer is consumer code and may throw on input it does not like. Letting that
1101
1101
  * escape would take down the whole form over one malformed field, so it falls back to the
1102
1102
  * source text — which is what the field would have shown with no renderer at all.
1103
+ *
1104
+ * Memoised on the source, because this is called from the template: without it the
1105
+ * consumer's markdown parser ran on every change-detection pass, for every markdown field
1106
+ * on screen, over the whole document — which on a form holding a few long ones is a parse
1107
+ * per keystroke *elsewhere* in the form. The cache is keyed by the exact source string,
1108
+ * so a value that comes back to something already rendered is free too.
1103
1109
  */
1110
+ cached;
1104
1111
  rendered() {
1105
1112
  const source = this.control?.value;
1106
1113
  if (!this.render || typeof source !== 'string' || source === '')
1107
1114
  return null;
1115
+ if (this.cached?.source === source)
1116
+ return this.cached.html;
1117
+ let html;
1108
1118
  try {
1109
- return this.render(source);
1119
+ html = this.render(source);
1110
1120
  }
1111
1121
  catch {
1112
- return null;
1122
+ html = null;
1113
1123
  }
1124
+ this.cached = { source, html };
1125
+ return html;
1114
1126
  }
1115
1127
  /**
1116
1128
  * Resolved through `ValidationMessagesService`, so `provideNgxDynamicEntity({
@@ -3578,8 +3590,7 @@ class DropdownFieldComponent {
3578
3590
  options = signal([], ...(ngDevMode ? [{ debugName: "options" }] : /* istanbul ignore next */ []));
3579
3591
  compareFn = (o1, o2) => valuesMatch(o1, o2, this.language);
3580
3592
  isObjectVal(option) {
3581
- const val = getOptionStoredValue(option);
3582
- return typeof val === 'object' && val !== null;
3593
+ return typeof option === 'object' && option !== null;
3583
3594
  }
3584
3595
  get label() {
3585
3596
  return resolveLabel(this.field?.label, this.language);
@@ -3587,8 +3598,17 @@ class DropdownFieldComponent {
3587
3598
  get placeholder() {
3588
3599
  return resolveLabel(this.field?.placeholder, this.language);
3589
3600
  }
3601
+ /**
3602
+ * The value this option stores — the option object itself.
3603
+ *
3604
+ * Kept as a method rather than binding `option` straight into the template, because it is
3605
+ * the one place that says *why* the two are the same thing. It used to delegate to
3606
+ * `getOptionStoredValue`, an exported identity function whose two branches returned their
3607
+ * argument unchanged; naming a transformation that does not happen is worse than naming
3608
+ * nothing, because a reader has to go and check.
3609
+ */
3590
3610
  getOptStoredVal(option) {
3591
- return getOptionStoredValue(option);
3611
+ return option;
3592
3612
  }
3593
3613
  getOptLabel(option) {
3594
3614
  return resolveOptionLabel(option, this.language);
@@ -3601,7 +3621,7 @@ class DropdownFieldComponent {
3601
3621
  getLabel(value) {
3602
3622
  if (value == null || value === '')
3603
3623
  return '—';
3604
- const option = this.options().find(o => valuesMatch(getOptionStoredValue(o), value, this.language));
3624
+ const option = this.options().find(o => valuesMatch(o, value, this.language));
3605
3625
  if (option)
3606
3626
  return this.getOptLabel(option);
3607
3627
  const cached = this.lookups.labelFor(this.field?.listName, value, this.language);
@@ -3823,8 +3843,17 @@ class RadioFieldComponent {
3823
3843
  get label() {
3824
3844
  return resolveLabel(this.field?.label, this.language);
3825
3845
  }
3846
+ /**
3847
+ * The value this option stores — the option object itself.
3848
+ *
3849
+ * Kept as a method rather than binding `option` straight into the template, because it is
3850
+ * the one place that says *why* the two are the same thing. It used to delegate to
3851
+ * `getOptionStoredValue`, an exported identity function whose two branches returned their
3852
+ * argument unchanged; naming a transformation that does not happen is worse than naming
3853
+ * nothing, because a reader has to go and check.
3854
+ */
3826
3855
  getOptStoredVal(option) {
3827
- return getOptionStoredValue(option);
3856
+ return option;
3828
3857
  }
3829
3858
  /**
3830
3859
  * Stable DOM id for one radio, so the <label for> association works.
@@ -3859,7 +3888,7 @@ class RadioFieldComponent {
3859
3888
  // `resolveLabel(null)` and rendered as blank — dropdown and multiSelect both show an em dash.
3860
3889
  if (value === null || value === undefined || value === '')
3861
3890
  return '—';
3862
- const selected = this.options().find(o => valuesMatch(getOptionStoredValue(o), value, this.language));
3891
+ const selected = this.options().find(o => valuesMatch(o, value, this.language));
3863
3892
  if (selected)
3864
3893
  return this.getOptLabel(selected);
3865
3894
  const cached = this.lookups.labelFor(this.field?.listName, value, this.language);
@@ -4067,14 +4096,22 @@ class MultiSelectFieldComponent {
4067
4096
  options = signal([], ...(ngDevMode ? [{ debugName: "options" }] : /* istanbul ignore next */ []));
4068
4097
  compareFn = (o1, o2) => valuesMatch(o1, o2, this.language);
4069
4098
  isObjectVal(option) {
4070
- const val = getOptionStoredValue(option);
4071
- return typeof val === 'object' && val !== null;
4099
+ return typeof option === 'object' && option !== null;
4072
4100
  }
4073
4101
  get label() {
4074
4102
  return resolveLabel(this.field?.label, this.language);
4075
4103
  }
4104
+ /**
4105
+ * The value this option stores — the option object itself.
4106
+ *
4107
+ * Kept as a method rather than binding `option` straight into the template, because it is
4108
+ * the one place that says *why* the two are the same thing. It used to delegate to
4109
+ * `getOptionStoredValue`, an exported identity function whose two branches returned their
4110
+ * argument unchanged; naming a transformation that does not happen is worse than naming
4111
+ * nothing, because a reader has to go and check.
4112
+ */
4076
4113
  getOptStoredVal(option) {
4077
- return getOptionStoredValue(option);
4114
+ return option;
4078
4115
  }
4079
4116
  getOptLabel(option) {
4080
4117
  return resolveOptionLabel(option, this.language);
@@ -4085,7 +4122,7 @@ class MultiSelectFieldComponent {
4085
4122
  return '—';
4086
4123
  return values
4087
4124
  .map(v => {
4088
- const opt = this.options().find(o => valuesMatch(getOptionStoredValue(o), v, this.language));
4125
+ const opt = this.options().find(o => valuesMatch(o, v, this.language));
4089
4126
  if (opt)
4090
4127
  return this.getOptLabel(opt);
4091
4128
  const cached = this.lookups.labelFor(this.field?.listName, v, this.language);
@@ -4877,7 +4914,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.22", ngImpo
4877
4914
  *
4878
4915
  * **Tree-shaking contract:** this service holds *no* static imports of field components.
4879
4916
  * Nothing is bundled until something registers it, which is what makes the built-ins
4880
- * opt-in. Register them with `provideBuiltInFieldTypes()` (all 19 keys) or a narrower
4917
+ * opt-in. Register them with `provideBuiltInFieldTypes()` (all 21 keys) or a narrower
4881
4918
  * subset via `provideFieldTypes({ ... })` — see `providers/provide-field-types.ts`.
4882
4919
  *
4883
4920
  * Resolution order: consumer token registry → runtime registrations → nothing.
@@ -5286,8 +5323,20 @@ class ValidatorRegistryService {
5286
5323
  fnList.push(Validators.minLength(config.minLength));
5287
5324
  if (config.maxLength !== undefined)
5288
5325
  fnList.push(Validators.maxLength(config.maxLength));
5289
- if (config.pattern)
5290
- fnList.push(Validators.pattern(config.pattern));
5326
+ if (config.pattern) {
5327
+ // `Validators.pattern` compiles the string, so an unparseable one threw here — during
5328
+ // control construction, which takes the whole form down rather than one field's format
5329
+ // check. `validateConfig` reports it as an error; at runtime the field degrades to no
5330
+ // pattern validation, which is exactly what the server-side import path already does.
5331
+ try {
5332
+ fnList.push(Validators.pattern(config.pattern));
5333
+ }
5334
+ catch (err) {
5335
+ this.warnOnce(`pattern:${config.pattern}`, `The pattern "${config.pattern}" is not a valid regular expression ` +
5336
+ `(${err instanceof Error ? err.message : String(err)}), so no format check is ` +
5337
+ `applied to this field. Run "dynamic-entity validate" over the config to find it.`);
5338
+ }
5339
+ }
5291
5340
  if (config.email)
5292
5341
  fnList.push(Validators.email);
5293
5342
  // Named validators from the consumer registry. This branch previously hardcoded the
@@ -5298,6 +5347,8 @@ class ValidatorRegistryService {
5298
5347
  const fn = this.resolve(key);
5299
5348
  if (fn)
5300
5349
  fnList.push(fn);
5350
+ else
5351
+ this.warnUnresolved('custom', key);
5301
5352
  }
5302
5353
  return fnList;
5303
5354
  }
@@ -5319,9 +5370,35 @@ class ValidatorRegistryService {
5319
5370
  const fn = this.asyncRegistry.get(key);
5320
5371
  if (fn)
5321
5372
  out.push(fn);
5373
+ else
5374
+ this.warnUnresolved('customAsync', key);
5322
5375
  }
5323
5376
  return out;
5324
5377
  }
5378
+ /**
5379
+ * Say when a named validator resolved to nothing.
5380
+ *
5381
+ * It used to be dropped in silence, which is the worst failure mode this library has: the
5382
+ * schema says the field is checked, the field is not checked, the form saves, and nothing
5383
+ * anywhere said so. For `customAsync` that is typically a uniqueness check, so the duplicate
5384
+ * lands in the database.
5385
+ *
5386
+ * Dev only, once per name — this runs while controls are built, which happens on every
5387
+ * config change. `validateConfig` with `knownValidators` is the build-time version of the
5388
+ * same check, and it fails CI rather than a console.
5389
+ */
5390
+ warnUnresolved(kind, key) {
5391
+ this.warnOnce(`${kind}:${key}`, `No validator named "${key}" is registered, so validators.${kind} drops it and it never ` +
5392
+ `runs. Register it with provideNgxDynamicEntity({ ${kind === 'customAsync' ? 'asyncValidators' : 'validators'}: { '${key}': ... } }).`);
5393
+ }
5394
+ /** Per service instance, which is per injector — so a test gets a clean slate. */
5395
+ warned = new Set();
5396
+ warnOnce(id, message) {
5397
+ if (!isDevMode() || this.warned.has(id))
5398
+ return;
5399
+ this.warned.add(id);
5400
+ console.warn(`[ngx-dynamic-entity] ${message}`);
5401
+ }
5325
5402
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.22", ngImport: i0, type: ValidatorRegistryService, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
5326
5403
  static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "21.2.22", ngImport: i0, type: ValidatorRegistryService, providedIn: 'root' });
5327
5404
  }
@@ -6159,7 +6236,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.22", ngImpo
6159
6236
  *
6160
6237
  * `FieldRegistryService` imports no components. Whatever you register here is what gets
6161
6238
  * bundled, so an app that only uses text/number/dropdown pays for three components and
6162
- * their imports, not nineteen.
6239
+ * their imports, not twenty-one.
6163
6240
  *
6164
6241
  * ```ts
6165
6242
  * // Everything (convenient; bundles all built-ins):
@@ -6203,7 +6280,7 @@ function provideFieldTypes(types) {
6203
6280
  { provide: FIELD_TYPE_SETS, useValue: types, multi: true },
6204
6281
  ]);
6205
6282
  }
6206
- /** Register all 19 built-in field type keys. Convenience for apps that use most of them. */
6283
+ /** Register all 21 built-in field type keys. Convenience for apps that use most of them. */
6207
6284
  function provideBuiltInFieldTypes() {
6208
6285
  return provideFieldTypes(builtInFieldTypes());
6209
6286
  }
@@ -6232,14 +6309,24 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.22", ngImpo
6232
6309
  }] });
6233
6310
 
6234
6311
  /**
6235
- * RulesEvaluationService — evaluates dynamic form rules against active reactive form state.
6312
+ * RulesEvaluationService — evaluates rules, and owns what a result *means*.
6313
+ *
6314
+ * It used to be two passthrough methods, with the interpretation spread across the component:
6315
+ * the render filter decided one way what a rule result did to a field, and the validity sync
6316
+ * decided again forty lines further down. They disagreed about what "every field" meant, and
6317
+ * the consequence was a required field inside a `group` that a rule had hidden holding
6318
+ * `form.invalid` true forever with nothing on screen to explain it.
6319
+ *
6320
+ * Precedence lives here now, in one method that both callers go through. Still stateless —
6321
+ * every method takes the result it is interpreting, so there is nothing to go stale against a
6322
+ * rebuilt form and nothing to reset between configs.
6236
6323
  */
6237
6324
  class RulesEvaluationService {
6238
6325
  /**
6239
6326
  * Evaluate rules against form values and optional original baseline values.
6240
6327
  */
6241
- evaluate(rules, values, originalValues) {
6242
- return evaluateFormRules(rules, values, originalValues);
6328
+ evaluate(rules, values, originalValues, options) {
6329
+ return evaluateFormRules(rules, values, originalValues, options);
6243
6330
  }
6244
6331
  /**
6245
6332
  * Filter rules pertinent to a single tab.
@@ -6247,6 +6334,89 @@ class RulesEvaluationService {
6247
6334
  filterForTab(rules, tabId, config) {
6248
6335
  return filterRulesForTab(rules, tabId, config);
6249
6336
  }
6337
+ /**
6338
+ * Whether a field renders, under the precedence documented on `RuleEvaluationResult`.
6339
+ *
6340
+ * An explicit hide beats everything. A show beats static hiding — `visibility: false`, or a
6341
+ * `showWhen` that does not currently hold — because a rule that could only hide what the
6342
+ * config already hides would have nothing to say, and `{ visibility: true }` was a
6343
+ * documented no-op until it did. Otherwise the config's own visibility decides.
6344
+ */
6345
+ isFieldVisible(result, field, names, values) {
6346
+ const sets = this.nameSets(result);
6347
+ if (names.some(name => sets.hidden.has(name)))
6348
+ return false;
6349
+ if (names.some(name => sets.shown.has(name)))
6350
+ return true;
6351
+ return evaluateFieldVisibility(field, values);
6352
+ }
6353
+ /** The same precedence for a tab. */
6354
+ isTabVisible(result, tab) {
6355
+ if (result.hiddenTabs.includes(tab.id))
6356
+ return false;
6357
+ return tab.visibility !== false || result.shownTabs.includes(tab.id);
6358
+ }
6359
+ /**
6360
+ * Keep a hidden field's control out of form validity.
6361
+ *
6362
+ * A field hidden by a rule or a `showWhen` is filtered out of the render, but its
6363
+ * validators stay attached — so a hidden *required* field holds `form.invalid` true
6364
+ * forever, permanently disabling Save with nothing on screen to explain why.
6365
+ *
6366
+ * Disabling is the fix rather than stripping validators: Angular excludes disabled controls
6367
+ * from validity, and the field's own validators survive intact for when it comes back.
6368
+ * Values are untouched — a disabled control still carries its value, so rule evaluation and
6369
+ * the emitted record see exactly what they saw before. That also keeps this from feeding
6370
+ * back on itself: hiding a field cannot change the values the rules are evaluated over.
6371
+ *
6372
+ * The predicate is `isFieldVisible`, the same one the render filter uses, so what is on
6373
+ * screen and what counts toward validity cannot drift apart.
6374
+ *
6375
+ * Entries must arrive parent-first, which is what `collectFieldScopes` yields: a container's
6376
+ * children are skipped once the container itself is hidden, because Angular's `disable()`
6377
+ * cascades to descendants and a child's `enable()` walks back up recalculating ancestors.
6378
+ * Toggling a child inside a hidden group would fight its parent.
6379
+ */
6380
+ syncHiddenFieldState(entries, ctx) {
6381
+ const hiddenContainers = [];
6382
+ for (const entry of entries) {
6383
+ const field = entry.field;
6384
+ if (!field?.id)
6385
+ continue;
6386
+ const address = ctx.addressOf(field);
6387
+ if (hiddenContainers.some(prefix => address.startsWith(`${prefix}.`)))
6388
+ continue;
6389
+ const hidden = !this.isFieldVisible(ctx.result, field, ctx.namesOf(field), ctx.values);
6390
+ if (hidden && (field.type === 'group' || field.type === 'array'))
6391
+ hiddenContainers.push(address);
6392
+ const ctrl = ctx.controlFor(entry);
6393
+ if (!ctrl)
6394
+ continue;
6395
+ if (hidden) {
6396
+ if (ctrl.enabled)
6397
+ ctrl.disable({ emitEvent: false });
6398
+ }
6399
+ else if (ctrl.disabled && !field.disabled) {
6400
+ ctrl.enable({ emitEvent: false });
6401
+ }
6402
+ }
6403
+ }
6404
+ /**
6405
+ * The hidden and shown names as sets, memoised against the result they came from.
6406
+ *
6407
+ * `isFieldVisible` runs once per field per change-detection pass; building two sets from
6408
+ * two arrays each time turns a rule result into allocation proportional to the form. A
6409
+ * `WeakMap` keyed by the result means it is built once per evaluation and collected with it.
6410
+ */
6411
+ nameSets(result) {
6412
+ let sets = this.nameSetCache.get(result);
6413
+ if (!sets) {
6414
+ sets = { hidden: new Set(result.hiddenFields), shown: new Set(result.shownFields) };
6415
+ this.nameSetCache.set(result, sets);
6416
+ }
6417
+ return sets;
6418
+ }
6419
+ nameSetCache = new WeakMap();
6250
6420
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.22", ngImport: i0, type: RulesEvaluationService, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
6251
6421
  static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "21.2.22", ngImport: i0, type: RulesEvaluationService, providedIn: 'root' });
6252
6422
  }
@@ -6318,6 +6488,255 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.22", ngImpo
6318
6488
  args: [CONFIG_SOURCE]
6319
6489
  }] }] });
6320
6490
 
6491
+ /**
6492
+ * FormStructureService — config ⇄ `FormGroup`, and nothing else.
6493
+ *
6494
+ * The one question this answers is *where a value lives*: which control a field owns, how
6495
+ * the tab tree nests into groups, how a record is read out of the tree and patched back in.
6496
+ * It was half of `DynamicFormComponent`, mixed in with tab state, focus management, rule
6497
+ * evaluation and RBAC — and the cost of that was not length but ownership: the bug Phase 2
6498
+ * fixed was two methods forty lines apart disagreeing about what "every field" meant.
6499
+ *
6500
+ * Deliberately stateless. Every method takes the form and the config it is operating on, so
6501
+ * there is no cached `this.form` to go stale against a rebuilt one, and every method is
6502
+ * testable without a component or a TestBed.
6503
+ *
6504
+ * Provided at the component, not the root: `FormBuilder` and the validator registry are the
6505
+ * only things injected, but keeping the service per form leaves room for that to change
6506
+ * without turning into shared mutable state.
6507
+ */
6508
+ class FormStructureService {
6509
+ fb = inject(FormBuilder);
6510
+ validatorRegistry = inject(ValidatorRegistryService);
6511
+ /**
6512
+ * Build the control tree for a config.
6513
+ *
6514
+ * The tree mirrors the record's shape exactly: a tab opens a `FormGroup` under its id, a
6515
+ * `flatData` tab merges into its parent's, and a `group` field opens one for its children.
6516
+ * That correspondence is what lets a `[path]` ref resolve through `form.get()` and name
6517
+ * exactly one control — see `getControl`.
6518
+ */
6519
+ buildForm(config) {
6520
+ const group = {};
6521
+ const buildTabControls = (tabs, parentGroup) => {
6522
+ for (const tab of tabs) {
6523
+ if (tab.flatData) {
6524
+ for (const field of tab.fields || []) {
6525
+ this.buildFieldControl(field, parentGroup);
6526
+ }
6527
+ if (tab.children)
6528
+ buildTabControls(tab.children, parentGroup);
6529
+ }
6530
+ else {
6531
+ const tabGroup = {};
6532
+ for (const field of tab.fields || []) {
6533
+ this.buildFieldControl(field, tabGroup);
6534
+ }
6535
+ if (tab.children)
6536
+ buildTabControls(tab.children, tabGroup);
6537
+ parentGroup[tab.id] = this.fb.group(tabGroup);
6538
+ }
6539
+ }
6540
+ };
6541
+ buildTabControls(config.tabs || [], group);
6542
+ return this.fb.group(group);
6543
+ }
6544
+ /** One field's control: a nested group, an empty array, or a leaf carrying its validators. */
6545
+ buildFieldControl(field, group) {
6546
+ if (field.type === 'group') {
6547
+ const subGroup = {};
6548
+ for (const child of field.children || []) {
6549
+ this.buildFieldControl(child, subGroup);
6550
+ }
6551
+ group[field.id] = this.fb.group(subGroup);
6552
+ }
6553
+ else if (field.type === 'array') {
6554
+ group[field.id] = this.fb.array([]);
6555
+ }
6556
+ else {
6557
+ const validators = this.validatorRegistry.resolveFromConfig(field.validators);
6558
+ const asyncValidators = this.validatorRegistry.resolveAsyncFromConfig(field.validators);
6559
+ group[field.id] = this.fb.control({ value: field.defaultValue ?? null, disabled: field.disabled ?? false }, validators, asyncValidators);
6560
+ }
6561
+ }
6562
+ /** One row of an `array` field: a FormGroup when the field declares columns, else a bare control. */
6563
+ buildArrayRow(field, item) {
6564
+ if (!field?.children?.length)
6565
+ return this.fb.control(item);
6566
+ const rowGroup = {};
6567
+ for (const child of field.children) {
6568
+ this.buildFieldControl(child, rowGroup);
6569
+ }
6570
+ const group = this.fb.group(rowGroup);
6571
+ if (item && typeof item === 'object')
6572
+ group.patchValue(item);
6573
+ return group;
6574
+ }
6575
+ /** The `FormGroup` a tab's fields live in, honouring `flatData`. */
6576
+ getTabGroup(form, config, tabId) {
6577
+ if (!form)
6578
+ return null;
6579
+ const path = getTabPath(config?.tabs, tabId);
6580
+ if (!path || path.length === 0)
6581
+ return form;
6582
+ let curr = form;
6583
+ for (const p of path) {
6584
+ if (!curr || !(curr instanceof FormGroup))
6585
+ return null;
6586
+ curr = curr.get(p);
6587
+ }
6588
+ return curr instanceof FormGroup ? curr : null;
6589
+ }
6590
+ /**
6591
+ * Find a control by reference: a bracketed path, or a bare field id.
6592
+ *
6593
+ * A bracketed path names exactly one control, because the tree nests exactly as the path
6594
+ * does — so `form.get('work.address.city')` walks straight to it. A bare id is what every
6595
+ * config written before paths existed uses: it is looked for on `currentTabId` first, then
6596
+ * at the root, then by a recursive search whose result is whichever the walk reaches first.
6597
+ * That last branch is the ambiguity refs exist to remove, and callers that can name a path
6598
+ * should.
6599
+ */
6600
+ getControl(form, config, fieldId, currentTabId) {
6601
+ if (!form)
6602
+ return null;
6603
+ const parsed = parseFieldRef(fieldId);
6604
+ if (parsed.kind === 'ref')
6605
+ return form.get(parsed.value);
6606
+ if (currentTabId) {
6607
+ const tabGrp = this.getTabGroup(form, config, currentTabId);
6608
+ const ctrl = tabGrp?.get(fieldId);
6609
+ if (ctrl)
6610
+ return ctrl;
6611
+ }
6612
+ const rootCtrl = form.get(fieldId);
6613
+ if (rootCtrl)
6614
+ return rootCtrl;
6615
+ const findInGroup = (group) => {
6616
+ for (const key of Object.keys(group.controls)) {
6617
+ const c = group.controls[key];
6618
+ if (key === fieldId)
6619
+ return c;
6620
+ if (c instanceof FormGroup) {
6621
+ const found = findInGroup(c);
6622
+ if (found)
6623
+ return found;
6624
+ }
6625
+ }
6626
+ return null;
6627
+ };
6628
+ return findInGroup(form);
6629
+ }
6630
+ /** Assemble the full nested record from per-tab groups, respecting flatData, refererField & arrays. */
6631
+ extractRecord(form, config) {
6632
+ if (!form || !config)
6633
+ return {};
6634
+ let record = {};
6635
+ const walkTabs = (tabs) => {
6636
+ for (const tab of tabs) {
6637
+ const fieldValBag = {};
6638
+ for (const field of tab.fields || []) {
6639
+ const ctrl = this.getControl(form, config, field.id, tab.id);
6640
+ if (ctrl)
6641
+ fieldValBag[field.id] = ctrl.value;
6642
+ }
6643
+ setTabData(record, tab.id, fieldValBag, config);
6644
+ for (const field of tab.fields || []) {
6645
+ if (field.refererField) {
6646
+ const ctrl = this.getControl(form, config, field.id, tab.id);
6647
+ if (ctrl)
6648
+ setValueByPath(record, field.refererField, ctrl.value);
6649
+ }
6650
+ }
6651
+ if (tab.children)
6652
+ walkTabs(tab.children);
6653
+ }
6654
+ };
6655
+ walkTabs(config.tabs || []);
6656
+ record = normalizeArrayStructures(record, config);
6657
+ return record;
6658
+ }
6659
+ /**
6660
+ * Write a record into the control tree.
6661
+ *
6662
+ * Returns the top-level keys that named a known field and were *not* consumed, so the
6663
+ * caller can say so — see `DynamicFormComponent.warnUnconsumedInitialData`. Reporting
6664
+ * rather than logging keeps the "warn once per form" bookkeeping with the form, and keeps
6665
+ * this service free of console output.
6666
+ */
6667
+ patchForm(form, config, data, fieldsById) {
6668
+ if (!data || !form || !config)
6669
+ return [];
6670
+ const patchedFieldIds = new Set();
6671
+ const walkTabs = (tabs) => {
6672
+ for (const tab of tabs) {
6673
+ const tabData = getTabData(tab.id, data, config);
6674
+ for (const field of tab.fields || []) {
6675
+ let val = tabData && typeof tabData === 'object' ? tabData[field.id] : undefined;
6676
+ if (field.refererField) {
6677
+ const refVal = getValueByPath(data, field.refererField);
6678
+ if (refVal !== undefined)
6679
+ val = refVal;
6680
+ }
6681
+ if (val === undefined)
6682
+ continue;
6683
+ const ctrl = this.getControl(form, config, field.id, tab.id);
6684
+ if (!ctrl)
6685
+ continue;
6686
+ if (ctrl instanceof FormArray && Array.isArray(val)) {
6687
+ ctrl.clear();
6688
+ for (const item of val) {
6689
+ ctrl.push(this.buildArrayRow(fieldsById.get(field.id), item));
6690
+ }
6691
+ }
6692
+ else {
6693
+ ctrl.patchValue(val, { emitEvent: false });
6694
+ }
6695
+ patchedFieldIds.add(field.id);
6696
+ }
6697
+ if (tab.children)
6698
+ walkTabs(tab.children);
6699
+ }
6700
+ };
6701
+ walkTabs(config.tabs || []);
6702
+ return this.unconsumedKeys(config, data, fieldsById, patchedFieldIds);
6703
+ }
6704
+ /**
6705
+ * Top-level keys that name a field but reached no control.
6706
+ *
6707
+ * A record is nested by tab id (`{ tabId: { fieldId: value } }`) unless the tab sets
6708
+ * `flatData: true`. Handing a flat record to a nested tab finds nothing, so the fields stay
6709
+ * empty — with no error and no clue.
6710
+ *
6711
+ * Only keys matching a known field id count: anything else is the consumer's own record
6712
+ * metadata (ids, timestamps, `_configVersion`) and is not our business.
6713
+ */
6714
+ unconsumedKeys(config, data, fieldsById, patchedFieldIds) {
6715
+ if (!isDevMode())
6716
+ return [];
6717
+ const tabIds = new Set();
6718
+ const collectTabIds = (tabs) => {
6719
+ for (const tab of tabs) {
6720
+ tabIds.add(tab.id);
6721
+ if (tab.children)
6722
+ collectTabIds(tab.children);
6723
+ }
6724
+ };
6725
+ collectTabIds(config.tabs || []);
6726
+ return Object.keys(data).filter(key =>
6727
+ // A tab id at the root is the nested container, not a stray field.
6728
+ !tabIds.has(key) &&
6729
+ data[key] !== undefined &&
6730
+ fieldsById.has(key) &&
6731
+ !patchedFieldIds.has(key));
6732
+ }
6733
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.22", ngImport: i0, type: FormStructureService, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
6734
+ static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "21.2.22", ngImport: i0, type: FormStructureService });
6735
+ }
6736
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.22", ngImport: i0, type: FormStructureService, decorators: [{
6737
+ type: Injectable
6738
+ }] });
6739
+
6321
6740
  /**
6322
6741
  * Grid width per field type, used only under `layout="auto"` and only where the field itself
6323
6742
  * names no `colSpan`.
@@ -6437,6 +6856,8 @@ class DynamicFormComponent {
6437
6856
  hookRegistry = inject(HookRegistryService);
6438
6857
  rbacService = inject(RbacService);
6439
6858
  rulesEvaluation = inject(RulesEvaluationService);
6859
+ /** config ⇄ FormGroup: where a value lives. See `FormStructureService`. */
6860
+ structure = inject(FormStructureService);
6440
6861
  /** Resolves what is wrong with a field, for the error summary — see `resolveForField`. */
6441
6862
  messages = inject(ValidationMessagesService);
6442
6863
  entityRefSelection = inject(EntityRefSelectionService);
@@ -6475,12 +6896,29 @@ class DynamicFormComponent {
6475
6896
  // invalid form is what produces the error summary. Guarding here instead meant Ctrl+S
6476
6897
  // on an incomplete form did nothing at all — and unlike the button, a shortcut cannot
6477
6898
  // even look disabled.
6899
+ // `void`, not an `await`: a `@HostListener` cannot be async without changing what
6900
+ // Angular does with its return value, and `submit()` reports its own failures through
6901
+ // `saveRejected`. Nothing here would have anywhere to put a rejection.
6478
6902
  if (!this.readonly && this.canSubmit)
6479
- this.submit();
6903
+ void this.submit();
6480
6904
  }
6481
6905
  }
6482
6906
  // ─── Computed ─────────────────────────────────────────────────────────────
6483
- ruleResult = computed(() => this.rulesEvaluation.evaluate(this.rules, this.formValues(), this.baseline()), ...(ngDevMode ? [{ debugName: "ruleResult" }] : /* istanbul ignore next */ []));
6907
+ ruleResult = computed(() => this.rulesEvaluation.evaluate(this.rules, this.formValues(), this.baseline(), {
6908
+ // The engine reports rather than logs, because it also runs on a server. This is the
6909
+ // client's answer: say it once per distinct problem, in dev only. A rule the author
6910
+ // half-finished used to throw out of change detection; now it is skipped, and a
6911
+ // silently skipped rule is exactly as hard to diagnose as a thrown one.
6912
+ onProblem: message => this.warnOnce(message),
6913
+ }), ...(ngDevMode ? [{ debugName: "ruleResult" }] : /* istanbul ignore next */ []));
6914
+ /** Warned-about rule problems, per component instance — see `warnedAmbiguousIds`. */
6915
+ warnedProblems = new Set();
6916
+ warnOnce(message) {
6917
+ if (!isDevMode() || this.warnedProblems.has(message))
6918
+ return;
6919
+ this.warnedProblems.add(message);
6920
+ console.warn(`[ngx-dynamic-entity] ${message}`);
6921
+ }
6484
6922
  /**
6485
6923
  * Critical fields whose value differs from the session baseline.
6486
6924
  * These drive the deferred `VALUE_CHANGED` banner — a critical edit is announced once,
@@ -6497,8 +6935,10 @@ class DynamicFormComponent {
6497
6935
  return this.config?.tabs || [];
6498
6936
  }
6499
6937
  get visibleTabs() {
6500
- const hidden = this.ruleResult().hiddenTabs;
6501
- return this.tabs.filter(tab => tab.visibility !== false && !hidden.includes(tab.id));
6938
+ return this.tabs.filter(tab => this.isTabVisible(tab));
6939
+ }
6940
+ isTabVisible(tab) {
6941
+ return this.rulesEvaluation.isTabVisible(this.ruleResult(), tab);
6502
6942
  }
6503
6943
  get activeTabConfig() {
6504
6944
  const tabId = this.activeTab();
@@ -6510,8 +6950,7 @@ class DynamicFormComponent {
6510
6950
  const active = this.activeTabConfig;
6511
6951
  if (!active?.children || active.children.length === 0)
6512
6952
  return [];
6513
- const hidden = this.ruleResult().hiddenTabs;
6514
- return active.children.filter(tab => tab.visibility !== false && !hidden.includes(tab.id));
6953
+ return active.children.filter(tab => this.isTabVisible(tab));
6515
6954
  }
6516
6955
  get activeSubTabConfig() {
6517
6956
  const subTabs = this.visibleSubTabs;
@@ -6561,8 +7000,15 @@ class DynamicFormComponent {
6561
7000
  return [];
6562
7001
  const rawFields = active ? active.fields || [] : (this.config?.tabs || []).flatMap(t => t.fields || []);
6563
7002
  const currentValues = this.formValues();
6564
- const hiddenFields = new Set(this.ruleResult().hiddenFields);
6565
- return rawFields.filter(field => evaluateFieldVisibility(field, currentValues) && !this.namesField(hiddenFields, field));
7003
+ return rawFields.filter(field => this.isFieldVisible(field, currentValues));
7004
+ }
7005
+ /**
7006
+ * Whether a field renders. Precedence lives in `RulesEvaluationService`, which is also
7007
+ * what `syncHiddenFieldState` asks — so what is on screen and what counts toward validity
7008
+ * cannot drift apart.
7009
+ */
7010
+ isFieldVisible(field, values) {
7011
+ return this.rulesEvaluation.isFieldVisible(this.ruleResult(), field, this.namesOf(field), values);
6566
7012
  }
6567
7013
  /**
6568
7014
  * Entity-level permissions for the current roles.
@@ -6788,19 +7234,36 @@ class DynamicFormComponent {
6788
7234
  if (this.invalidFieldsCache)
6789
7235
  return this.invalidFieldsCache;
6790
7236
  const out = [];
6791
- const collect = (tab, parentId) => {
6792
- for (const field of tab.fields ?? []) {
6793
- const control = this.getControl(field.id, tab.id);
6794
- if (!control || control.disabled || control.valid || control.pending)
6795
- continue;
6796
- // The same message the field renders under itself — see `resolveForField`. Saying
6797
- // only *which* field is wrong leaves the user to go and look at each one; saying what
6798
- // is wrong with it is usually enough to fix it without leaving the summary.
6799
- const message = this.messages.resolveForField(control.errors, this.language, field.type);
6800
- out.push(parentId
6801
- ? { field, message, tabId: parentId, subTabId: tab.id }
6802
- : { field, message, tabId: tab.id });
7237
+ /*
7238
+ * Descends into `group` children rather than stopping at the container.
7239
+ *
7240
+ * A `FormGroup` is invalid whenever any descendant is, so reporting the container said
7241
+ * "Contacts is invalid" and left the user to open it and hunt. The offending leaf is
7242
+ * both the useful answer and the one `jumpToField` can actually move focus to.
7243
+ *
7244
+ * An `array` is reported as itself: its rows are built and destroyed at runtime, so
7245
+ * there is no configured child to name and nothing stable to jump to.
7246
+ */
7247
+ const collectField = (field, tab, parentId) => {
7248
+ const control = this.getControl(field.id, tab.id);
7249
+ if (!control || control.disabled || control.valid || control.pending)
7250
+ return;
7251
+ if (field.type === 'group' && field.children?.length) {
7252
+ for (const child of field.children)
7253
+ collectField(child, tab, parentId);
7254
+ return;
6803
7255
  }
7256
+ // The same message the field renders under itself — see `resolveForField`. Saying
7257
+ // only *which* field is wrong leaves the user to go and look at each one; saying what
7258
+ // is wrong with it is usually enough to fix it without leaving the summary.
7259
+ const message = this.messages.resolveForField(control.errors, this.language, field.type);
7260
+ out.push(parentId
7261
+ ? { field, message, tabId: parentId, subTabId: tab.id }
7262
+ : { field, message, tabId: tab.id });
7263
+ };
7264
+ const collect = (tab, parentId) => {
7265
+ for (const field of tab.fields ?? [])
7266
+ collectField(field, tab, parentId);
6804
7267
  for (const child of tab.children ?? [])
6805
7268
  collect(child, parentId ?? tab.id);
6806
7269
  };
@@ -6852,16 +7315,31 @@ class DynamicFormComponent {
6852
7315
  el.focus();
6853
7316
  }, { injector: this.injector });
6854
7317
  }
7318
+ /**
7319
+ * Which tab (and sub-tab) holds a field.
7320
+ *
7321
+ * Descends into `group`/`array` children: this compared top-level fields only, so "jump to
7322
+ * the first invalid field" found nothing for a nested one and silently did nothing. Only
7323
+ * sub-tabs one level deep are named, because that is what the tab strip can select —
7324
+ * anything deeper resolves to the ancestor it renders under.
7325
+ */
6855
7326
  locateField(fieldId) {
6856
- for (const tab of this.tabs) {
6857
- if ((tab.fields ?? []).some(f => f.id === fieldId))
6858
- return { tabId: tab.id };
6859
- for (const child of tab.children ?? []) {
6860
- if ((child.fields ?? []).some(f => f.id === fieldId))
6861
- return { tabId: tab.id, subTabId: child.id };
7327
+ const holds = (fields) => (fields ?? []).some(f => f?.id === fieldId || holds(f?.children));
7328
+ const search = (tabs, tabId, subTabId) => {
7329
+ for (const tab of tabs ?? []) {
7330
+ if (!tab?.id)
7331
+ continue;
7332
+ const at = tabId ?? tab.id;
7333
+ const sub = tabId ? (subTabId ?? tab.id) : undefined;
7334
+ if (holds(tab.fields))
7335
+ return sub ? { tabId: at, subTabId: sub } : { tabId: at };
7336
+ const found = search(tab.children, at, sub);
7337
+ if (found)
7338
+ return found;
6862
7339
  }
6863
- }
6864
- return null;
7340
+ return null;
7341
+ };
7342
+ return search(this.tabs);
6865
7343
  }
6866
7344
  // ─── criticalField locking ────────────────────────────────────────────────
6867
7345
  /** A critical field stays read-only until the user deliberately unlocks it. */
@@ -6921,29 +7399,9 @@ class DynamicFormComponent {
6921
7399
  buildForm() {
6922
7400
  if (!this.config)
6923
7401
  return;
6924
- const group = {};
6925
- const buildTabControls = (tabs, parentGroup) => {
6926
- for (const tab of tabs) {
6927
- if (tab.flatData) {
6928
- for (const field of tab.fields || []) {
6929
- this.buildFieldControl(field, parentGroup);
6930
- }
6931
- if (tab.children)
6932
- buildTabControls(tab.children, parentGroup);
6933
- }
6934
- else {
6935
- const tabGroup = {};
6936
- for (const field of tab.fields || []) {
6937
- this.buildFieldControl(field, tabGroup);
6938
- }
6939
- if (tab.children)
6940
- buildTabControls(tab.children, tabGroup);
6941
- parentGroup[tab.id] = this.fb.group(tabGroup);
6942
- }
6943
- }
6944
- };
6945
- buildTabControls(this.config.tabs || [], group);
6946
- this.form = this.fb.group(group);
7402
+ this.form = this.structure.buildForm(this.config);
7403
+ // A rebuild replaces every control, and the scope index is keyed by config identity.
7404
+ this.scopeCache = undefined;
6947
7405
  this.warnAmbiguousRuleReferences();
6948
7406
  if (this.initialData) {
6949
7407
  this.patchForm(this.upgradeRecord(this.initialData));
@@ -6989,24 +7447,6 @@ class DynamicFormComponent {
6989
7447
  this.formChange.emit(this.extractRecord());
6990
7448
  });
6991
7449
  }
6992
- buildFieldControl(field, group) {
6993
- if (field.type === 'group') {
6994
- const subGroup = {};
6995
- for (const child of field.children || []) {
6996
- this.buildFieldControl(child, subGroup);
6997
- }
6998
- group[field.id] = this.fb.group(subGroup);
6999
- }
7000
- else if (field.type === 'array') {
7001
- group[field.id] = this.fb.array([]);
7002
- }
7003
- else {
7004
- const validators = this.validatorRegistry.resolveFromConfig(field.validators);
7005
- const asyncValidators = this.validatorRegistry.resolveAsyncFromConfig(field.validators);
7006
- group[field.id] = this.fb.control({ value: field.defaultValue ?? null, disabled: field.disabled ?? false }, validators, asyncValidators);
7007
- }
7008
- }
7009
- /** One row of an `array` field: a FormGroup when the field declares columns, else a bare control. */
7010
7450
  /**
7011
7451
  * The `FormArray` behind an `array` field, for callers that manage rows themselves —
7012
7452
  * the record editor's inline row drawer.
@@ -7023,16 +7463,7 @@ class DynamicFormComponent {
7023
7463
  return this.buildArrayRow(field, value);
7024
7464
  }
7025
7465
  buildArrayRow(field, item) {
7026
- if (!field?.children?.length)
7027
- return this.fb.control(item);
7028
- const rowGroup = {};
7029
- for (const child of field.children) {
7030
- this.buildFieldControl(child, rowGroup);
7031
- }
7032
- const group = this.fb.group(rowGroup);
7033
- if (item && typeof item === 'object')
7034
- group.patchValue(item);
7035
- return group;
7466
+ return this.structure.buildArrayRow(field, item);
7036
7467
  }
7037
7468
  /**
7038
7469
  * Mark every control touched, then re-check the field components.
@@ -7065,42 +7496,11 @@ class DynamicFormComponent {
7065
7496
  return result.record;
7066
7497
  }
7067
7498
  patchForm(data) {
7068
- if (!data || !this.form)
7499
+ if (!data || !this.form || !this.config)
7069
7500
  return;
7070
7501
  const fieldsById = new Map(this.allFields().map(f => [f.id, f]));
7071
- const patchedFieldIds = new Set();
7072
- const walkTabs = (tabs) => {
7073
- for (const tab of tabs) {
7074
- const tabData = getTabData(tab.id, data, this.config);
7075
- for (const field of tab.fields || []) {
7076
- let val = tabData && typeof tabData === 'object' ? tabData[field.id] : undefined;
7077
- if (field.refererField) {
7078
- const refVal = getValueByPath(data, field.refererField);
7079
- if (refVal !== undefined)
7080
- val = refVal;
7081
- }
7082
- if (val === undefined)
7083
- continue;
7084
- const ctrl = this.getControl(field.id, tab.id);
7085
- if (!ctrl)
7086
- continue;
7087
- if (ctrl instanceof FormArray && Array.isArray(val)) {
7088
- ctrl.clear();
7089
- for (const item of val) {
7090
- ctrl.push(this.buildArrayRow(fieldsById.get(field.id), item));
7091
- }
7092
- }
7093
- else {
7094
- ctrl.patchValue(val, { emitEvent: false });
7095
- }
7096
- patchedFieldIds.add(field.id);
7097
- }
7098
- if (tab.children)
7099
- walkTabs(tab.children);
7100
- }
7101
- };
7102
- walkTabs(this.config.tabs || []);
7103
- this.warnUnconsumedInitialData(data, fieldsById, patchedFieldIds);
7502
+ const unconsumed = this.structure.patchForm(this.form, this.config, data, fieldsById);
7503
+ this.warnUnconsumedInitialData(unconsumed);
7104
7504
  }
7105
7505
  /**
7106
7506
  * A record is nested by tab id (`{ tabId: { fieldId: value } }`) unless the tab sets
@@ -7108,34 +7508,18 @@ class DynamicFormComponent {
7108
7508
  * stay empty — with no error and no clue. That silent miss is the most expensive way to
7109
7509
  * lose an hour with this library, so name it in dev builds.
7110
7510
  *
7111
- * Only top-level keys that match a known field id are reported: anything else is assumed
7112
- * to be the consumer's own record metadata (ids, timestamps, `_configVersion`) and is not
7113
- * our business.
7511
+ * Which keys were missed is `FormStructureService`'s answer; warning once per key per form
7512
+ * is this component's, because it is the thing that lives as long as the form does.
7114
7513
  */
7115
- warnUnconsumedInitialData(data, fieldsById, patchedFieldIds) {
7116
- if (!isDevMode() || !this.config)
7514
+ warnUnconsumedInitialData(keys) {
7515
+ const fresh = keys.filter(key => !this.warnedUnconsumedKeys.has(key));
7516
+ if (!fresh.length)
7117
7517
  return;
7118
- const tabIds = new Set();
7119
- const collectTabIds = (tabs) => {
7120
- for (const tab of tabs) {
7121
- tabIds.add(tab.id);
7122
- if (tab.children)
7123
- collectTabIds(tab.children);
7124
- }
7125
- };
7126
- collectTabIds(this.config.tabs || []);
7127
- const unconsumed = Object.keys(data).filter(key => !tabIds.has(key) && // a tab id at the root is the nested container, not a stray field
7128
- data[key] !== undefined &&
7129
- fieldsById.has(key) &&
7130
- !patchedFieldIds.has(key) &&
7131
- !this.warnedUnconsumedKeys.has(key));
7132
- if (!unconsumed.length)
7133
- return;
7134
- for (const key of unconsumed)
7518
+ for (const key of fresh)
7135
7519
  this.warnedUnconsumedKeys.add(key);
7136
- const shown = unconsumed.slice(0, 5).join(', ');
7137
- const more = unconsumed.length > 5 ? ` (+${unconsumed.length - 5} more)` : '';
7138
- const plural = unconsumed.length === 1;
7520
+ const shown = fresh.slice(0, 5).join(', ');
7521
+ const more = fresh.length > 5 ? ` (+${fresh.length - 5} more)` : '';
7522
+ const plural = fresh.length === 1;
7139
7523
  console.warn(`[ngx-dynamic-entity] initialData has top-level ${plural ? 'key' : 'keys'} matching ` +
7140
7524
  `${plural ? 'a field that was' : 'fields that were'} not populated: ${shown}${more}. ` +
7141
7525
  `A record is nested by tab id ({ tabId: { fieldId: value } }) unless the tab sets ` +
@@ -7146,34 +7530,7 @@ class DynamicFormComponent {
7146
7530
  warnedUnconsumedKeys = new Set();
7147
7531
  /** Assemble full nested record from per-tab FormGroups, respecting flatData, refererField & arrays. */
7148
7532
  extractRecord() {
7149
- if (!this.form || !this.config)
7150
- return {};
7151
- let record = {};
7152
- const walkTabs = (tabs) => {
7153
- for (const tab of tabs) {
7154
- const fieldValBag = {};
7155
- for (const field of tab.fields || []) {
7156
- const ctrl = this.getControl(field.id, tab.id);
7157
- if (ctrl) {
7158
- fieldValBag[field.id] = ctrl.value;
7159
- }
7160
- }
7161
- setTabData(record, tab.id, fieldValBag, this.config);
7162
- for (const field of tab.fields || []) {
7163
- if (field.refererField) {
7164
- const ctrl = this.getControl(field.id, tab.id);
7165
- if (ctrl) {
7166
- setValueByPath(record, field.refererField, ctrl.value);
7167
- }
7168
- }
7169
- }
7170
- if (tab.children)
7171
- walkTabs(tab.children);
7172
- }
7173
- };
7174
- walkTabs(this.config.tabs || []);
7175
- record = normalizeArrayStructures(record, this.config);
7176
- return record;
7533
+ return this.structure.extractRecord(this.form, this.config);
7177
7534
  }
7178
7535
  /**
7179
7536
  * Warns when a rule names a field id that two scopes define.
@@ -7211,15 +7568,24 @@ class DynamicFormComponent {
7211
7568
  continue;
7212
7569
  const id = parsed.value;
7213
7570
  const scopes = ambiguous.get(id);
7214
- if (!scopes || DynamicFormComponent.warnedAmbiguousIds.has(id))
7571
+ if (!scopes || this.warnedAmbiguousIds.has(id))
7215
7572
  continue;
7216
- DynamicFormComponent.warnedAmbiguousIds.add(id);
7573
+ this.warnedAmbiguousIds.add(id);
7217
7574
  console.warn(`[ngx-dynamic-entity] A rule references field "${id}", which is defined in ${scopes.join(' and ')}. ` +
7218
7575
  `A bare id cannot say which one is meant, so the rule will read whichever the form finds first. ` +
7219
7576
  `Name it by path instead, as [${scopes[0]}.${id}].`);
7220
7577
  }
7221
7578
  }
7222
- static warnedAmbiguousIds = new Set();
7579
+ /**
7580
+ * Ids already warned about, per component instance.
7581
+ *
7582
+ * This was `private static`, so it was module-global mutable state that nothing ever
7583
+ * cleared: under SSR every render of every form in the process accumulated into one set
7584
+ * that lived as long as the server did, and the first request to render a config
7585
+ * suppressed the warning for every request after it. Per instance, the warning is once per
7586
+ * form — which is what "warn once" was meant to mean.
7587
+ */
7588
+ warnedAmbiguousIds = new Set();
7223
7589
  /**
7224
7590
  * The value map rules and `showWhen` are evaluated against.
7225
7591
  *
@@ -7233,23 +7599,75 @@ class DynamicFormComponent {
7233
7599
  * unknown>` and needs no knowledge of refs at all — the extra keys simply resolve.
7234
7600
  */
7235
7601
  /**
7236
- * Whether a rule result names this field.
7602
+ * Every name a rule may address this field by.
7237
7603
  *
7238
7604
  * A rule target is either a bare field id — how every config so far addresses a field — or
7239
- * a bracketed path, which is the only way to name one of two fields that share an id.
7240
- * Both have to match, or a rule written either way would fail to hide what it targeted.
7605
+ * a bracketed path, which is the only way to name one of two fields that share an id. Both
7606
+ * have to match, or a rule written either way would fail to hide what it targeted.
7607
+ *
7608
+ * The path comes last, and `syncHiddenFieldState` relies on that: it uses the last entry
7609
+ * as the field's address when deciding whether a hidden container already covers it.
7241
7610
  */
7242
- namesField(names, field) {
7243
- if (names.has(field.id))
7244
- return true;
7245
- const ref = field.refererField ?? this.pathFor(field);
7246
- return ref ? names.has(toRefToken(ref)) : false;
7611
+ namesOf(field) {
7612
+ const path = field.refererField ?? this.pathFor(field);
7613
+ return path ? [field.id, toRefToken(path)] : [field.id];
7614
+ }
7615
+ /**
7616
+ * The field's dotted address, unbracketed.
7617
+ *
7618
+ * The positional path rather than an authored `refererField`: this is compared as a prefix
7619
+ * to decide whether a hidden container already covers a field, and containment is a fact
7620
+ * about where the field *is*. A `refererField` is a binding override — it says where the
7621
+ * value goes, not which group renders it.
7622
+ */
7623
+ addressOf(field) {
7624
+ return this.pathFor(field) ?? field.id;
7247
7625
  }
7248
7626
  /** The field's declared path, or the one its position implies when it carries none. */
7249
7627
  pathFor(field) {
7250
- const entry = collectFieldScopes(this.config).find(e => e.field === field);
7628
+ const entry = this.scopeEntryFor(field);
7251
7629
  return entry ? fieldRefFor(entry.scope, field.id) : null;
7252
7630
  }
7631
+ /**
7632
+ * Every field in the config with the scope its value is stored under, cached per config.
7633
+ *
7634
+ * `collectFieldScopes` walks the whole tree. It was called once per field from `pathFor`,
7635
+ * which `namesField` calls, which the render filter calls for every field on the active
7636
+ * tab — a quadratic walk on every change-detection pass. Keyed by config identity, so a
7637
+ * new config invalidates it without an explicit lifecycle hook to forget.
7638
+ */
7639
+ fieldScopes() {
7640
+ if (this.scopeCache?.config !== this.config) {
7641
+ const entries = collectFieldScopes(this.config);
7642
+ this.scopeCache = {
7643
+ config: this.config,
7644
+ entries,
7645
+ byField: new Map(entries.map(entry => [entry.field, entry])),
7646
+ };
7647
+ }
7648
+ return this.scopeCache.entries;
7649
+ }
7650
+ scopeEntryFor(field) {
7651
+ this.fieldScopes();
7652
+ return this.scopeCache?.byField.get(field);
7653
+ }
7654
+ scopeCache;
7655
+ /**
7656
+ * The control a scope entry addresses, named by path rather than by bare id.
7657
+ *
7658
+ * `getControl(field.id)` with no tab falls through to a first-match recursive search of the
7659
+ * whole form, so with `address` on two tabs, hiding one disabled the other. The control
7660
+ * tree mirrors the scope path exactly — `buildTabControls` nests by tab id and
7661
+ * `buildFieldControl` nests a `group` under its own id — so a `[path]` ref resolves through
7662
+ * `form.get()` on `getControl`'s first branch and names exactly one control.
7663
+ *
7664
+ * A field inside an `array` has no static path: its controls live in `FormArray` rows built
7665
+ * per row. `form.get()` returns null for those and they are left alone, which is correct —
7666
+ * a row's controls are created and destroyed with the row.
7667
+ */
7668
+ controlForEntry(entry) {
7669
+ return this.getControl(toRefToken(fieldRefFor(entry.scope, entry.field.id)));
7670
+ }
7253
7671
  flattenFormValues() {
7254
7672
  const out = {};
7255
7673
  for (const entry of collectFieldScopes(this.config)) {
@@ -7264,18 +7682,6 @@ class DynamicFormComponent {
7264
7682
  }
7265
7683
  return out;
7266
7684
  }
7267
- getTabGroup(tabId) {
7268
- const path = getTabPath(this.config?.tabs, tabId);
7269
- if (!path || path.length === 0)
7270
- return this.form;
7271
- let curr = this.form;
7272
- for (const p of path) {
7273
- if (!curr || !(curr instanceof FormGroup))
7274
- return null;
7275
- curr = curr.get(p);
7276
- }
7277
- return curr instanceof FormGroup ? curr : null;
7278
- }
7279
7685
  // ─── autoPatch / patchOnTrue ──────────────────────────────────────────────
7280
7686
  /**
7281
7687
  * `autoPatch` — copy mapped fields out of the record the user just selected in an
@@ -7317,65 +7723,48 @@ class DynamicFormComponent {
7317
7723
  }
7318
7724
  }
7319
7725
  }
7320
- /** Prefer a control declared on the configured target tab; fall back to a top-level control. */
7726
+ /**
7727
+ * Prefer a control declared on the configured target tab; fall back to a top-level control.
7728
+ *
7729
+ * "Declared on the tab" now means anywhere under it, sub-tabs and `group` children
7730
+ * included. It meant the tab's own top-level fields only, so an `autoPatch` mapping onto a
7731
+ * field inside a group resolved to `null` and copied nothing, silently.
7732
+ */
7321
7733
  resolveTargetControl(autoPatch, targetId) {
7322
7734
  const tab = findTab(this.tabs, autoPatch.targetTab);
7323
- const inTargetTab = (tab?.fields ?? []).some(f => f.id === targetId);
7324
- if (tab && !inTargetTab)
7735
+ if (!tab)
7736
+ return this.getControl(targetId, autoPatch.targetTab);
7737
+ const entry = fieldsUnderTab(this.config, autoPatch.targetTab).find(e => {
7738
+ if (!e.field?.id)
7739
+ return false;
7740
+ const parsed = parseFieldRef(targetId);
7741
+ return parsed.kind === 'ref'
7742
+ ? fieldRefFor(e.scope, e.field.id) === parsed.value
7743
+ : e.field.id === parsed.value;
7744
+ });
7745
+ if (!entry)
7325
7746
  return null;
7326
- return this.getControl(targetId, autoPatch.targetTab);
7747
+ return this.controlForEntry(entry);
7327
7748
  }
7328
7749
  // ─── Helpers ──────────────────────────────────────────────────────────────
7329
7750
  /**
7330
- * The fields the render filter operates on — a tab's own fields, at every tab depth.
7331
- * Deliberately not `allFields()`: `group` and `array` children are shown or hidden with
7332
- * their parent, and toggling a child's state independently would fight the parent's.
7333
- */
7334
- tabFields() {
7335
- const out = [];
7336
- const walk = (tabs) => {
7337
- for (const tab of tabs ?? []) {
7338
- out.push(...(tab.fields ?? []));
7339
- walk(tab.children);
7340
- }
7341
- };
7342
- walk(this.config?.tabs);
7343
- return out;
7344
- }
7345
- /**
7346
- * Keep a hidden field's control out of form validity.
7347
- *
7348
- * A field hidden by a rule or a `showWhen` condition is filtered out of the render, but
7349
- * its validators stay attached — so a hidden *required* field holds `form.invalid` true
7350
- * forever, permanently disabling Save with nothing on screen to explain why.
7751
+ * Keep a hidden field's control out of form validity — see
7752
+ * `RulesEvaluationService.syncHiddenFieldState`, which owns the rule and the reasoning.
7351
7753
  *
7352
- * Disabling is the fix rather than stripping validators: Angular excludes disabled
7353
- * controls from validity, and the field's own validators survive intact for when it
7354
- * comes back. Values are untouched — `flattenFormValues` and `extractRecord` both read
7355
- * `control.value`, which a disabled control still carries, so rule evaluation and the
7356
- * emitted record see exactly what they saw before. That also keeps this from feeding
7357
- * back on itself: hiding a field cannot change the values the rules are evaluated over.
7358
- *
7359
- * The visibility predicate is the same one `fieldsForActiveTab` filters with, so what is
7360
- * rendered and what counts toward validity cannot drift apart.
7754
+ * This supplies the three things it cannot know: which fields exist and in what order
7755
+ * (`collectFieldScopes`, parent-first), what each one is called, and which control each
7756
+ * one addresses.
7361
7757
  */
7362
7758
  syncHiddenFieldState(values) {
7363
7759
  if (this.preview)
7364
7760
  return; // preview freezes the whole form on purpose
7365
- const hiddenByRule = new Set(this.ruleResult().hiddenFields);
7366
- for (const field of this.tabFields()) {
7367
- const ctrl = this.getControl(field.id);
7368
- if (!ctrl)
7369
- continue;
7370
- const hidden = this.namesField(hiddenByRule, field) || !evaluateFieldVisibility(field, values);
7371
- if (hidden) {
7372
- if (ctrl.enabled)
7373
- ctrl.disable({ emitEvent: false });
7374
- }
7375
- else if (ctrl.disabled && !field.disabled) {
7376
- ctrl.enable({ emitEvent: false });
7377
- }
7378
- }
7761
+ this.rulesEvaluation.syncHiddenFieldState(this.fieldScopes(), {
7762
+ result: this.ruleResult(),
7763
+ values,
7764
+ namesOf: field => this.namesOf(field),
7765
+ addressOf: field => this.addressOf(field),
7766
+ controlFor: entry => this.controlForEntry(entry),
7767
+ });
7379
7768
  }
7380
7769
  /** Every field in the config, including `group`/`array` children. */
7381
7770
  allFields() {
@@ -7403,51 +7792,31 @@ class DynamicFormComponent {
7403
7792
  return supplied;
7404
7793
  return this.sessionBaseline();
7405
7794
  }
7795
+ /**
7796
+ * Whether a critical field still holds what it held when the session started.
7797
+ *
7798
+ * The same question `VALUE_CHANGED` asks, so it is answered by the same comparator rather
7799
+ * than by a third one. The local version compared objects through `JSON.stringify`, which
7800
+ * is key-order sensitive — two serialisers writing the same option in a different key
7801
+ * order made the field read as edited — and had its own idea of which values count as
7802
+ * empty. `valuesEqual` is order-independent and treats `null` and `undefined` as the same
7803
+ * absence; the empty-string case is kept here, because a cleared text input is not an edit.
7804
+ */
7406
7805
  sameValue(a, b) {
7407
- if (a === b)
7408
- return true;
7409
7806
  const aEmpty = a === null || a === undefined || a === '';
7410
7807
  const bEmpty = b === null || b === undefined || b === '';
7411
7808
  if (aEmpty && bEmpty)
7412
7809
  return true;
7413
- if (typeof a === 'object' || typeof b === 'object') {
7414
- return JSON.stringify(a ?? null) === JSON.stringify(b ?? null);
7415
- }
7416
- return false;
7810
+ return valuesEqual(a, b);
7417
7811
  }
7812
+ /**
7813
+ * Find a control by a bracketed path or a bare field id.
7814
+ *
7815
+ * Public API, so it stays on the component and delegates — see `FormStructureService`,
7816
+ * which owns the resolution rule and is where the behaviour is documented.
7817
+ */
7418
7818
  getControl(fieldId, currentTabId) {
7419
- if (!this.form)
7420
- return null;
7421
- // A bracketed path names exactly one control, and the form nests by tab exactly as the
7422
- // path does — so `form.get('work.address')` walks straight to it. Anything that names a
7423
- // field can therefore use a path: `showWhen` keys, `patchOnTrue` mappings and `autoPatch`
7424
- // targets, not only rules.
7425
- const parsed = parseFieldRef(fieldId);
7426
- if (parsed.kind === 'ref')
7427
- return this.form.get(parsed.value);
7428
- if (currentTabId) {
7429
- const tabGrp = this.getTabGroup(currentTabId);
7430
- const ctrl = tabGrp?.get(fieldId);
7431
- if (ctrl)
7432
- return ctrl;
7433
- }
7434
- const rootCtrl = this.form.get(fieldId);
7435
- if (rootCtrl)
7436
- return rootCtrl;
7437
- const findInGroup = (group) => {
7438
- for (const key of Object.keys(group.controls)) {
7439
- const c = group.controls[key];
7440
- if (key === fieldId)
7441
- return c;
7442
- if (c instanceof FormGroup) {
7443
- const found = findInGroup(c);
7444
- if (found)
7445
- return found;
7446
- }
7447
- }
7448
- return null;
7449
- };
7450
- return findInGroup(this.form);
7819
+ return this.structure.getControl(this.form, this.config, fieldId, currentTabId);
7451
7820
  }
7452
7821
  /**
7453
7822
  * Move focus into the newly shown panel.
@@ -7547,11 +7916,11 @@ class DynamicFormComponent {
7547
7916
  this.formReset.emit();
7548
7917
  }
7549
7918
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.22", ngImport: i0, type: DynamicFormComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
7550
- static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.22", 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", layout: "layout" }, 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\" [class.ngx-form--readonly]=\"readonly\" novalidate>\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 <!--\n Why the last Save did nothing.\n Only after a refused submit \u2014 see `submitAttempted`. Each entry jumps to its field,\n switching tabs on the way, because the field is usually not the one on screen.\n -->\n @if (submitAttempted() && invalidFields().length > 0) {\n <div class=\"ngx-form__error-summary\" role=\"alert\" data-testid=\"error-summary\" tabindex=\"-1\">\n <!--\n `error-summary-heading`, not `-title`: the links below are `error-summary-<fieldId>`,\n and a field whose id happens to be `title` would then share a test id with this line.\n The `extensions` entity has exactly such a field.\n -->\n <p class=\"ngx-form__error-summary-title\" data-testid=\"error-summary-heading\">\n {{ ui.text('errorSummaryTitle', language, { count: invalidFields().length }) }}\n </p>\n <ul class=\"ngx-form__error-summary-list\">\n @for (entry of invalidFields(); track entry.field.id) {\n <li>\n <!--\n The field, and what is wrong with it \u2014 the same sentence the field renders\n under itself, so the summary is not a second opinion. Naming only the field\n left the user to visit each one to find out what it wanted.\n -->\n <button\n type=\"button\"\n class=\"ngx-form__error-summary-link\"\n [attr.data-testid]=\"'error-summary-' + entry.field.id\"\n (click)=\"jumpToField(entry.field.id)\"\n >\n <span class=\"ngx-form__error-summary-field\">{{ resolveFieldLabel(entry.field) }}</span>\n @if (entry.message) {\n <span class=\"ngx-form__error-summary-reason\">{{ entry.message }}</span>\n }\n </button>\n </li>\n }\n </ul>\n </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 {{ ui.text('criticalFieldChanged', language, { fields: changedCriticalFieldLabels() }) }}\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 <!--\n How many fields on this tab are stopping the save.\n The count is in the accessible name rather than the badge, because \"3\" read out\n after a tab name says nothing about what it counts.\n -->\n @if (tabErrorCount(tab); as errors) {\n <span\n class=\"ngx-form__tab-badge\"\n [attr.data-testid]=\"'tab-errors-' + tab.id\"\n [attr.aria-label]=\"ui.text('tabErrorCount', language, { count: errors })\"\n >{{ errors }}</span\n >\n }\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\n class=\"ngx-form__tabs ngx-form__tabs--sub\"\n role=\"tablist\"\n data-testid=\"subtab-strip\"\n style=\"margin-top: -12px; margin-bottom: 12px; font-size: 13px\"\n >\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 @if (subTabErrorCount(subTab); as errors) {\n <span\n class=\"ngx-form__tab-badge\"\n [attr.data-testid]=\"'subtab-errors-' + subTab.id\"\n [attr.aria-label]=\"ui.text('tabErrorCount', language, { count: errors })\"\n >{{ errors }}</span\n >\n }\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 role=\"tabpanel\" tabindex=\"-1\" #formPanel class=\"ngx-form__tabpanel\" [attr.aria-label]=\"activeTabLabel\">\n @if (activeTabModuleComponent) {\n <div class=\"ngx-form__module-panel\" data-testid=\"module-panel\">\n <ng-container\n *ngComponentOutlet=\"activeTabModuleComponent; inputs: (activeSubTabConfig || activeTabConfig)?.moduleInputs\"\n ></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 tabindex=\"-1\"\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 }\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 ui.text(isFieldLocked(field) ? 'unlockField' : 'lockField', language, {\n field: resolveFieldLabel(field),\n })\n \"\n [title]=\"ui.text(isFieldLocked(field) ? 'criticalFieldUnlockHint' : 'criticalFieldLockHint', language)\"\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 type=\"button\" class=\"ngx-form__reset\" data-testid=\"form-reset\" (click)=\"reset()\" [disabled]=\"isSaving()\">\n {{ ui.text('reset', language) }}\n </button>\n <!--\n Two different \"unavailable\" states, and they are not the same thing.\n\n `disabled` is for what a retry cannot fix \u2014 a save in flight, an async check still\n outstanding. `data-blocked` is for validation: the button stays fully operable,\n because pressing it is how the user gets the error summary, but it should not look\n ready either. The stylesheet mutes it without taking the click away.\n\n Deliberately not `aria-disabled`. That announces a control as present-but-inoperable,\n and this one is operable and useful \u2014 pressing it raises a `role=\"alert\"` naming\n every field at fault, which is more than a disabled button could ever say. Tooling\n agrees: Playwright refuses to click through `aria-disabled`, and it would be right\n to, because the attribute would be claiming something untrue.\n -->\n <button\n type=\"submit\"\n class=\"ngx-form__submit\"\n data-testid=\"form-submit\"\n [disabled]=\"submitDisabled\"\n [attr.data-blocked]=\"submitBlocked ? '' : null\"\n [title]=\"ui.text('saveShortcutHint', language)\"\n >\n @if (isSaving() || loading) {\n {{ ui.text('saving', language) }}\n } @else {\n {{ ui.text('save', language) }}\n }\n </button>\n </div>\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 {{ ui.text('accessDenied', language) }}\n </div>\n}\n", styles: [".ngx-form__panel{display:grid;grid-template-columns:repeat(12,minmax(0,1fr));gap:var(--ngx-gap, 18px);align-items:start}.ngx-form--readonly .ngx-form__panel{grid-template-columns:repeat(auto-fit,minmax(230px,1fr));gap:2px var(--ngx-gap, 18px)}.ngx-form--readonly .ngx-form__field-slot{grid-column:auto}.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:var(--ngx-radius-sm, 6px);cursor:pointer;font-size:14px;line-height:1;padding:4px 6px}.ngx-form__lock:hover{border-color:var(--ngx-color-border, #d1d5db);background:var(--ngx-color-surface-alt, #f9fafb)}@media(max-width:640px){.ngx-form__panel,.ngx-form--readonly .ngx-form__panel{grid-template-columns:minmax(0,1fr)}.ngx-form__field-slot{grid-column:1 / -1!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],[formArray],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", "ngComponentOutletEnvironmentInjector", "ngComponentOutletContent", "ngComponentOutletNgModule"], exportAs: ["ngComponentOutlet"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
7919
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.22", 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", layout: "layout" }, outputs: { formSubmit: "formSubmit", formChange: "formChange", formReset: "formReset", activeTabChange: "activeTabChange", saveRejected: "saveRejected" }, host: { listeners: { "window:keydown": "handleKeyboardEvent($event)" } }, providers: [EntityRefSelectionService, FormStructureService], 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\" [class.ngx-form--readonly]=\"readonly\" novalidate>\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 <!--\n Why the last Save did nothing.\n Only after a refused submit \u2014 see `submitAttempted`. Each entry jumps to its field,\n switching tabs on the way, because the field is usually not the one on screen.\n -->\n @if (submitAttempted() && invalidFields().length > 0) {\n <div class=\"ngx-form__error-summary\" role=\"alert\" data-testid=\"error-summary\" tabindex=\"-1\">\n <!--\n `error-summary-heading`, not `-title`: the links below are `error-summary-<fieldId>`,\n and a field whose id happens to be `title` would then share a test id with this line.\n The `extensions` entity has exactly such a field.\n -->\n <p class=\"ngx-form__error-summary-title\" data-testid=\"error-summary-heading\">\n {{ ui.text('errorSummaryTitle', language, { count: invalidFields().length }) }}\n </p>\n <ul class=\"ngx-form__error-summary-list\">\n @for (entry of invalidFields(); track entry.field.id) {\n <li>\n <!--\n The field, and what is wrong with it \u2014 the same sentence the field renders\n under itself, so the summary is not a second opinion. Naming only the field\n left the user to visit each one to find out what it wanted.\n -->\n <button\n type=\"button\"\n class=\"ngx-form__error-summary-link\"\n [attr.data-testid]=\"'error-summary-' + entry.field.id\"\n (click)=\"jumpToField(entry.field.id)\"\n >\n <span class=\"ngx-form__error-summary-field\">{{ resolveFieldLabel(entry.field) }}</span>\n @if (entry.message) {\n <span class=\"ngx-form__error-summary-reason\">{{ entry.message }}</span>\n }\n </button>\n </li>\n }\n </ul>\n </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 {{ ui.text('criticalFieldChanged', language, { fields: changedCriticalFieldLabels() }) }}\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 <!--\n How many fields on this tab are stopping the save.\n The count is in the accessible name rather than the badge, because \"3\" read out\n after a tab name says nothing about what it counts.\n -->\n @if (tabErrorCount(tab); as errors) {\n <span\n class=\"ngx-form__tab-badge\"\n [attr.data-testid]=\"'tab-errors-' + tab.id\"\n [attr.aria-label]=\"ui.text('tabErrorCount', language, { count: errors })\"\n >{{ errors }}</span\n >\n }\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\n class=\"ngx-form__tabs ngx-form__tabs--sub\"\n role=\"tablist\"\n data-testid=\"subtab-strip\"\n style=\"margin-top: -12px; margin-bottom: 12px; font-size: 13px\"\n >\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 @if (subTabErrorCount(subTab); as errors) {\n <span\n class=\"ngx-form__tab-badge\"\n [attr.data-testid]=\"'subtab-errors-' + subTab.id\"\n [attr.aria-label]=\"ui.text('tabErrorCount', language, { count: errors })\"\n >{{ errors }}</span\n >\n }\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 role=\"tabpanel\" tabindex=\"-1\" #formPanel class=\"ngx-form__tabpanel\" [attr.aria-label]=\"activeTabLabel\">\n @if (activeTabModuleComponent) {\n <div class=\"ngx-form__module-panel\" data-testid=\"module-panel\">\n <ng-container\n *ngComponentOutlet=\"activeTabModuleComponent; inputs: (activeSubTabConfig || activeTabConfig)?.moduleInputs\"\n ></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 tabindex=\"-1\"\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 }\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 ui.text(isFieldLocked(field) ? 'unlockField' : 'lockField', language, {\n field: resolveFieldLabel(field),\n })\n \"\n [title]=\"ui.text(isFieldLocked(field) ? 'criticalFieldUnlockHint' : 'criticalFieldLockHint', language)\"\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 type=\"button\" class=\"ngx-form__reset\" data-testid=\"form-reset\" (click)=\"reset()\" [disabled]=\"isSaving()\">\n {{ ui.text('reset', language) }}\n </button>\n <!--\n Two different \"unavailable\" states, and they are not the same thing.\n\n `disabled` is for what a retry cannot fix \u2014 a save in flight, an async check still\n outstanding. `data-blocked` is for validation: the button stays fully operable,\n because pressing it is how the user gets the error summary, but it should not look\n ready either. The stylesheet mutes it without taking the click away.\n\n Deliberately not `aria-disabled`. That announces a control as present-but-inoperable,\n and this one is operable and useful \u2014 pressing it raises a `role=\"alert\"` naming\n every field at fault, which is more than a disabled button could ever say. Tooling\n agrees: Playwright refuses to click through `aria-disabled`, and it would be right\n to, because the attribute would be claiming something untrue.\n -->\n <button\n type=\"submit\"\n class=\"ngx-form__submit\"\n data-testid=\"form-submit\"\n [disabled]=\"submitDisabled\"\n [attr.data-blocked]=\"submitBlocked ? '' : null\"\n [title]=\"ui.text('saveShortcutHint', language)\"\n >\n @if (isSaving() || loading) {\n {{ ui.text('saving', language) }}\n } @else {\n {{ ui.text('save', language) }}\n }\n </button>\n </div>\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 {{ ui.text('accessDenied', language) }}\n </div>\n}\n", styles: [".ngx-form__panel{display:grid;grid-template-columns:repeat(12,minmax(0,1fr));gap:var(--ngx-gap, 18px);align-items:start}.ngx-form--readonly .ngx-form__panel{grid-template-columns:repeat(auto-fit,minmax(230px,1fr));gap:2px var(--ngx-gap, 18px)}.ngx-form--readonly .ngx-form__field-slot{grid-column:auto}.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:var(--ngx-radius-sm, 6px);cursor:pointer;font-size:14px;line-height:1;padding:4px 6px}.ngx-form__lock:hover{border-color:var(--ngx-color-border, #d1d5db);background:var(--ngx-color-surface-alt, #f9fafb)}@media(max-width:640px){.ngx-form__panel,.ngx-form--readonly .ngx-form__panel{grid-template-columns:minmax(0,1fr)}.ngx-form__field-slot{grid-column:1 / -1!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],[formArray],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", "ngComponentOutletEnvironmentInjector", "ngComponentOutletContent", "ngComponentOutletNgModule"], exportAs: ["ngComponentOutlet"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
7551
7920
  }
7552
7921
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.22", ngImport: i0, type: DynamicFormComponent, decorators: [{
7553
7922
  type: Component,
7554
- args: [{ changeDetection: ChangeDetectionStrategy.OnPush, selector: 'ngx-dynamic-form', standalone: true, imports: [ReactiveFormsModule, DynamicFieldComponent, NgComponentOutlet], providers: [EntityRefSelectionService], template: "@if (canView) {\n <form [formGroup]=\"form\" (ngSubmit)=\"submit()\" class=\"ngx-form\" [class.ngx-form--readonly]=\"readonly\" novalidate>\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 <!--\n Why the last Save did nothing.\n Only after a refused submit \u2014 see `submitAttempted`. Each entry jumps to its field,\n switching tabs on the way, because the field is usually not the one on screen.\n -->\n @if (submitAttempted() && invalidFields().length > 0) {\n <div class=\"ngx-form__error-summary\" role=\"alert\" data-testid=\"error-summary\" tabindex=\"-1\">\n <!--\n `error-summary-heading`, not `-title`: the links below are `error-summary-<fieldId>`,\n and a field whose id happens to be `title` would then share a test id with this line.\n The `extensions` entity has exactly such a field.\n -->\n <p class=\"ngx-form__error-summary-title\" data-testid=\"error-summary-heading\">\n {{ ui.text('errorSummaryTitle', language, { count: invalidFields().length }) }}\n </p>\n <ul class=\"ngx-form__error-summary-list\">\n @for (entry of invalidFields(); track entry.field.id) {\n <li>\n <!--\n The field, and what is wrong with it \u2014 the same sentence the field renders\n under itself, so the summary is not a second opinion. Naming only the field\n left the user to visit each one to find out what it wanted.\n -->\n <button\n type=\"button\"\n class=\"ngx-form__error-summary-link\"\n [attr.data-testid]=\"'error-summary-' + entry.field.id\"\n (click)=\"jumpToField(entry.field.id)\"\n >\n <span class=\"ngx-form__error-summary-field\">{{ resolveFieldLabel(entry.field) }}</span>\n @if (entry.message) {\n <span class=\"ngx-form__error-summary-reason\">{{ entry.message }}</span>\n }\n </button>\n </li>\n }\n </ul>\n </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 {{ ui.text('criticalFieldChanged', language, { fields: changedCriticalFieldLabels() }) }}\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 <!--\n How many fields on this tab are stopping the save.\n The count is in the accessible name rather than the badge, because \"3\" read out\n after a tab name says nothing about what it counts.\n -->\n @if (tabErrorCount(tab); as errors) {\n <span\n class=\"ngx-form__tab-badge\"\n [attr.data-testid]=\"'tab-errors-' + tab.id\"\n [attr.aria-label]=\"ui.text('tabErrorCount', language, { count: errors })\"\n >{{ errors }}</span\n >\n }\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\n class=\"ngx-form__tabs ngx-form__tabs--sub\"\n role=\"tablist\"\n data-testid=\"subtab-strip\"\n style=\"margin-top: -12px; margin-bottom: 12px; font-size: 13px\"\n >\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 @if (subTabErrorCount(subTab); as errors) {\n <span\n class=\"ngx-form__tab-badge\"\n [attr.data-testid]=\"'subtab-errors-' + subTab.id\"\n [attr.aria-label]=\"ui.text('tabErrorCount', language, { count: errors })\"\n >{{ errors }}</span\n >\n }\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 role=\"tabpanel\" tabindex=\"-1\" #formPanel class=\"ngx-form__tabpanel\" [attr.aria-label]=\"activeTabLabel\">\n @if (activeTabModuleComponent) {\n <div class=\"ngx-form__module-panel\" data-testid=\"module-panel\">\n <ng-container\n *ngComponentOutlet=\"activeTabModuleComponent; inputs: (activeSubTabConfig || activeTabConfig)?.moduleInputs\"\n ></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 tabindex=\"-1\"\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 }\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 ui.text(isFieldLocked(field) ? 'unlockField' : 'lockField', language, {\n field: resolveFieldLabel(field),\n })\n \"\n [title]=\"ui.text(isFieldLocked(field) ? 'criticalFieldUnlockHint' : 'criticalFieldLockHint', language)\"\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 type=\"button\" class=\"ngx-form__reset\" data-testid=\"form-reset\" (click)=\"reset()\" [disabled]=\"isSaving()\">\n {{ ui.text('reset', language) }}\n </button>\n <!--\n Two different \"unavailable\" states, and they are not the same thing.\n\n `disabled` is for what a retry cannot fix \u2014 a save in flight, an async check still\n outstanding. `data-blocked` is for validation: the button stays fully operable,\n because pressing it is how the user gets the error summary, but it should not look\n ready either. The stylesheet mutes it without taking the click away.\n\n Deliberately not `aria-disabled`. That announces a control as present-but-inoperable,\n and this one is operable and useful \u2014 pressing it raises a `role=\"alert\"` naming\n every field at fault, which is more than a disabled button could ever say. Tooling\n agrees: Playwright refuses to click through `aria-disabled`, and it would be right\n to, because the attribute would be claiming something untrue.\n -->\n <button\n type=\"submit\"\n class=\"ngx-form__submit\"\n data-testid=\"form-submit\"\n [disabled]=\"submitDisabled\"\n [attr.data-blocked]=\"submitBlocked ? '' : null\"\n [title]=\"ui.text('saveShortcutHint', language)\"\n >\n @if (isSaving() || loading) {\n {{ ui.text('saving', language) }}\n } @else {\n {{ ui.text('save', language) }}\n }\n </button>\n </div>\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 {{ ui.text('accessDenied', language) }}\n </div>\n}\n", styles: [".ngx-form__panel{display:grid;grid-template-columns:repeat(12,minmax(0,1fr));gap:var(--ngx-gap, 18px);align-items:start}.ngx-form--readonly .ngx-form__panel{grid-template-columns:repeat(auto-fit,minmax(230px,1fr));gap:2px var(--ngx-gap, 18px)}.ngx-form--readonly .ngx-form__field-slot{grid-column:auto}.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:var(--ngx-radius-sm, 6px);cursor:pointer;font-size:14px;line-height:1;padding:4px 6px}.ngx-form__lock:hover{border-color:var(--ngx-color-border, #d1d5db);background:var(--ngx-color-surface-alt, #f9fafb)}@media(max-width:640px){.ngx-form__panel,.ngx-form--readonly .ngx-form__panel{grid-template-columns:minmax(0,1fr)}.ngx-form__field-slot{grid-column:1 / -1!important}}\n"] }]
7923
+ args: [{ changeDetection: ChangeDetectionStrategy.OnPush, selector: 'ngx-dynamic-form', standalone: true, imports: [ReactiveFormsModule, DynamicFieldComponent, NgComponentOutlet], providers: [EntityRefSelectionService, FormStructureService], template: "@if (canView) {\n <form [formGroup]=\"form\" (ngSubmit)=\"submit()\" class=\"ngx-form\" [class.ngx-form--readonly]=\"readonly\" novalidate>\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 <!--\n Why the last Save did nothing.\n Only after a refused submit \u2014 see `submitAttempted`. Each entry jumps to its field,\n switching tabs on the way, because the field is usually not the one on screen.\n -->\n @if (submitAttempted() && invalidFields().length > 0) {\n <div class=\"ngx-form__error-summary\" role=\"alert\" data-testid=\"error-summary\" tabindex=\"-1\">\n <!--\n `error-summary-heading`, not `-title`: the links below are `error-summary-<fieldId>`,\n and a field whose id happens to be `title` would then share a test id with this line.\n The `extensions` entity has exactly such a field.\n -->\n <p class=\"ngx-form__error-summary-title\" data-testid=\"error-summary-heading\">\n {{ ui.text('errorSummaryTitle', language, { count: invalidFields().length }) }}\n </p>\n <ul class=\"ngx-form__error-summary-list\">\n @for (entry of invalidFields(); track entry.field.id) {\n <li>\n <!--\n The field, and what is wrong with it \u2014 the same sentence the field renders\n under itself, so the summary is not a second opinion. Naming only the field\n left the user to visit each one to find out what it wanted.\n -->\n <button\n type=\"button\"\n class=\"ngx-form__error-summary-link\"\n [attr.data-testid]=\"'error-summary-' + entry.field.id\"\n (click)=\"jumpToField(entry.field.id)\"\n >\n <span class=\"ngx-form__error-summary-field\">{{ resolveFieldLabel(entry.field) }}</span>\n @if (entry.message) {\n <span class=\"ngx-form__error-summary-reason\">{{ entry.message }}</span>\n }\n </button>\n </li>\n }\n </ul>\n </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 {{ ui.text('criticalFieldChanged', language, { fields: changedCriticalFieldLabels() }) }}\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 <!--\n How many fields on this tab are stopping the save.\n The count is in the accessible name rather than the badge, because \"3\" read out\n after a tab name says nothing about what it counts.\n -->\n @if (tabErrorCount(tab); as errors) {\n <span\n class=\"ngx-form__tab-badge\"\n [attr.data-testid]=\"'tab-errors-' + tab.id\"\n [attr.aria-label]=\"ui.text('tabErrorCount', language, { count: errors })\"\n >{{ errors }}</span\n >\n }\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\n class=\"ngx-form__tabs ngx-form__tabs--sub\"\n role=\"tablist\"\n data-testid=\"subtab-strip\"\n style=\"margin-top: -12px; margin-bottom: 12px; font-size: 13px\"\n >\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 @if (subTabErrorCount(subTab); as errors) {\n <span\n class=\"ngx-form__tab-badge\"\n [attr.data-testid]=\"'subtab-errors-' + subTab.id\"\n [attr.aria-label]=\"ui.text('tabErrorCount', language, { count: errors })\"\n >{{ errors }}</span\n >\n }\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 role=\"tabpanel\" tabindex=\"-1\" #formPanel class=\"ngx-form__tabpanel\" [attr.aria-label]=\"activeTabLabel\">\n @if (activeTabModuleComponent) {\n <div class=\"ngx-form__module-panel\" data-testid=\"module-panel\">\n <ng-container\n *ngComponentOutlet=\"activeTabModuleComponent; inputs: (activeSubTabConfig || activeTabConfig)?.moduleInputs\"\n ></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 tabindex=\"-1\"\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 }\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 ui.text(isFieldLocked(field) ? 'unlockField' : 'lockField', language, {\n field: resolveFieldLabel(field),\n })\n \"\n [title]=\"ui.text(isFieldLocked(field) ? 'criticalFieldUnlockHint' : 'criticalFieldLockHint', language)\"\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 type=\"button\" class=\"ngx-form__reset\" data-testid=\"form-reset\" (click)=\"reset()\" [disabled]=\"isSaving()\">\n {{ ui.text('reset', language) }}\n </button>\n <!--\n Two different \"unavailable\" states, and they are not the same thing.\n\n `disabled` is for what a retry cannot fix \u2014 a save in flight, an async check still\n outstanding. `data-blocked` is for validation: the button stays fully operable,\n because pressing it is how the user gets the error summary, but it should not look\n ready either. The stylesheet mutes it without taking the click away.\n\n Deliberately not `aria-disabled`. That announces a control as present-but-inoperable,\n and this one is operable and useful \u2014 pressing it raises a `role=\"alert\"` naming\n every field at fault, which is more than a disabled button could ever say. Tooling\n agrees: Playwright refuses to click through `aria-disabled`, and it would be right\n to, because the attribute would be claiming something untrue.\n -->\n <button\n type=\"submit\"\n class=\"ngx-form__submit\"\n data-testid=\"form-submit\"\n [disabled]=\"submitDisabled\"\n [attr.data-blocked]=\"submitBlocked ? '' : null\"\n [title]=\"ui.text('saveShortcutHint', language)\"\n >\n @if (isSaving() || loading) {\n {{ ui.text('saving', language) }}\n } @else {\n {{ ui.text('save', language) }}\n }\n </button>\n </div>\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 {{ ui.text('accessDenied', language) }}\n </div>\n}\n", styles: [".ngx-form__panel{display:grid;grid-template-columns:repeat(12,minmax(0,1fr));gap:var(--ngx-gap, 18px);align-items:start}.ngx-form--readonly .ngx-form__panel{grid-template-columns:repeat(auto-fit,minmax(230px,1fr));gap:2px var(--ngx-gap, 18px)}.ngx-form--readonly .ngx-form__field-slot{grid-column:auto}.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:var(--ngx-radius-sm, 6px);cursor:pointer;font-size:14px;line-height:1;padding:4px 6px}.ngx-form__lock:hover{border-color:var(--ngx-color-border, #d1d5db);background:var(--ngx-color-surface-alt, #f9fafb)}@media(max-width:640px){.ngx-form__panel,.ngx-form--readonly .ngx-form__panel{grid-template-columns:minmax(0,1fr)}.ngx-form__field-slot{grid-column:1 / -1!important}}\n"] }]
7555
7924
  }], propDecorators: { config: [{
7556
7925
  type: Input
7557
7926
  }], rules: [{