ngx-dynamic-entity 1.14.0 → 2.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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({
@@ -1785,7 +1797,11 @@ class EmailFieldComponent {
1785
1797
  }}</span>
1786
1798
  } @else if (readonly) {
1787
1799
  <span class="ngx-field__value" [attr.data-testid]="'field-' + field.id + '-value'">
1788
- <a [href]="'mailto:' + control.value" class="ngx-field__email-link">{{ control.value || '—' }}</a>
1800
+ @if (control.value) {
1801
+ <a [href]="'mailto:' + control.value" class="ngx-field__email-link">{{ control.value }}</a>
1802
+ } @else {
1803
+ —
1804
+ }
1789
1805
  </span>
1790
1806
  } @else {
1791
1807
  <input
@@ -1848,7 +1864,11 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.22", ngImpo
1848
1864
  }}</span>
1849
1865
  } @else if (readonly) {
1850
1866
  <span class="ngx-field__value" [attr.data-testid]="'field-' + field.id + '-value'">
1851
- <a [href]="'mailto:' + control.value" class="ngx-field__email-link">{{ control.value || '—' }}</a>
1867
+ @if (control.value) {
1868
+ <a [href]="'mailto:' + control.value" class="ngx-field__email-link">{{ control.value }}</a>
1869
+ } @else {
1870
+ —
1871
+ }
1852
1872
  </span>
1853
1873
  } @else {
1854
1874
  <input
@@ -3171,7 +3191,7 @@ class MonthYearFieldComponent {
3171
3191
  >
3172
3192
  <option value="">{{ ui.text('month', language) }}</option>
3173
3193
  @for (m of months; track m.value) {
3174
- <option [value]="m.value">{{ m.label }}</option>
3194
+ <option [value]="m.value" [selected]="m.value === selectedMonth">{{ m.label }}</option>
3175
3195
  }
3176
3196
  </select>
3177
3197
  <select
@@ -3186,7 +3206,7 @@ class MonthYearFieldComponent {
3186
3206
  >
3187
3207
  <option value="">{{ ui.text('year', language) }}</option>
3188
3208
  @for (y of years; track y) {
3189
- <option [value]="y">{{ y }}</option>
3209
+ <option [value]="y" [selected]="y.toString() === selectedYear">{{ y }}</option>
3190
3210
  }
3191
3211
  </select>
3192
3212
  </div>
@@ -3257,7 +3277,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.22", ngImpo
3257
3277
  >
3258
3278
  <option value="">{{ ui.text('month', language) }}</option>
3259
3279
  @for (m of months; track m.value) {
3260
- <option [value]="m.value">{{ m.label }}</option>
3280
+ <option [value]="m.value" [selected]="m.value === selectedMonth">{{ m.label }}</option>
3261
3281
  }
3262
3282
  </select>
3263
3283
  <select
@@ -3272,7 +3292,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.22", ngImpo
3272
3292
  >
3273
3293
  <option value="">{{ ui.text('year', language) }}</option>
3274
3294
  @for (y of years; track y) {
3275
- <option [value]="y">{{ y }}</option>
3295
+ <option [value]="y" [selected]="y.toString() === selectedYear">{{ y }}</option>
3276
3296
  }
3277
3297
  </select>
3278
3298
  </div>
@@ -3578,8 +3598,7 @@ class DropdownFieldComponent {
3578
3598
  options = signal([], ...(ngDevMode ? [{ debugName: "options" }] : /* istanbul ignore next */ []));
3579
3599
  compareFn = (o1, o2) => valuesMatch(o1, o2, this.language);
3580
3600
  isObjectVal(option) {
3581
- const val = getOptionStoredValue(option);
3582
- return typeof val === 'object' && val !== null;
3601
+ return typeof option === 'object' && option !== null;
3583
3602
  }
3584
3603
  get label() {
3585
3604
  return resolveLabel(this.field?.label, this.language);
@@ -3587,8 +3606,17 @@ class DropdownFieldComponent {
3587
3606
  get placeholder() {
3588
3607
  return resolveLabel(this.field?.placeholder, this.language);
3589
3608
  }
3609
+ /**
3610
+ * The value this option stores — the option object itself.
3611
+ *
3612
+ * Kept as a method rather than binding `option` straight into the template, because it is
3613
+ * the one place that says *why* the two are the same thing. It used to delegate to
3614
+ * `getOptionStoredValue`, an exported identity function whose two branches returned their
3615
+ * argument unchanged; naming a transformation that does not happen is worse than naming
3616
+ * nothing, because a reader has to go and check.
3617
+ */
3590
3618
  getOptStoredVal(option) {
3591
- return getOptionStoredValue(option);
3619
+ return option;
3592
3620
  }
3593
3621
  getOptLabel(option) {
3594
3622
  return resolveOptionLabel(option, this.language);
@@ -3601,7 +3629,7 @@ class DropdownFieldComponent {
3601
3629
  getLabel(value) {
3602
3630
  if (value == null || value === '')
3603
3631
  return '—';
3604
- const option = this.options().find(o => valuesMatch(getOptionStoredValue(o), value, this.language));
3632
+ const option = this.options().find(o => valuesMatch(o, value, this.language));
3605
3633
  if (option)
3606
3634
  return this.getOptLabel(option);
3607
3635
  const cached = this.lookups.labelFor(this.field?.listName, value, this.language);
@@ -3798,8 +3826,14 @@ class RadioFieldComponent {
3798
3826
  maskedText = inject(MASKED_PLACEHOLDER, { optional: true }) ?? 'XXXXXXXXX';
3799
3827
  messages = inject(ValidationMessagesService);
3800
3828
  lookups = inject(LookupRegistryService);
3829
+ cdr = inject(ChangeDetectorRef);
3801
3830
  _field;
3802
3831
  _language = 'en';
3832
+ _control;
3833
+ _controlSub;
3834
+ ngOnDestroy() {
3835
+ this._controlSub?.unsubscribe();
3836
+ }
3803
3837
  /** Setter-based so options resolve however the input is set — see `refreshChoiceOptions`. */
3804
3838
  set field(value) {
3805
3839
  this._field = value;
@@ -3815,16 +3849,39 @@ class RadioFieldComponent {
3815
3849
  get language() {
3816
3850
  return this._language;
3817
3851
  }
3818
- control;
3852
+ set control(value) {
3853
+ this._control = value;
3854
+ this._controlSub?.unsubscribe();
3855
+ this._controlSub = this._control?.valueChanges?.subscribe(() => {
3856
+ this.cdr.markForCheck();
3857
+ });
3858
+ }
3859
+ get control() {
3860
+ return this._control;
3861
+ }
3819
3862
  readonly = false;
3820
3863
  masked = false;
3821
3864
  /** Inline `options`, or the field's named list resolved through the registry (§6.3). */
3822
3865
  options = signal([], ...(ngDevMode ? [{ debugName: "options" }] : /* istanbul ignore next */ []));
3866
+ onSelect(option) {
3867
+ this.control?.setValue(option);
3868
+ this.control?.markAsTouched();
3869
+ this.control?.markAsDirty();
3870
+ }
3823
3871
  get label() {
3824
3872
  return resolveLabel(this.field?.label, this.language);
3825
3873
  }
3874
+ /**
3875
+ * The value this option stores — the option object itself.
3876
+ *
3877
+ * Kept as a method rather than binding `option` straight into the template, because it is
3878
+ * the one place that says *why* the two are the same thing. It used to delegate to
3879
+ * `getOptionStoredValue`, an exported identity function whose two branches returned their
3880
+ * argument unchanged; naming a transformation that does not happen is worse than naming
3881
+ * nothing, because a reader has to go and check.
3882
+ */
3826
3883
  getOptStoredVal(option) {
3827
- return getOptionStoredValue(option);
3884
+ return option;
3828
3885
  }
3829
3886
  /**
3830
3887
  * Stable DOM id for one radio, so the <label for> association works.
@@ -3852,6 +3909,10 @@ class RadioFieldComponent {
3852
3909
  getOptLabel(option) {
3853
3910
  return resolveOptionLabel(option, this.language);
3854
3911
  }
3912
+ /** Whether the given option matches the control's current value. */
3913
+ isChecked(option) {
3914
+ return valuesMatch(this.control?.value, option, this.language);
3915
+ }
3855
3916
  /** Read-only display — synchronous, per §6.2. See `DropdownFieldComponent.getLabel`. */
3856
3917
  getSelectedLabel() {
3857
3918
  const value = this.control?.value;
@@ -3859,7 +3920,7 @@ class RadioFieldComponent {
3859
3920
  // `resolveLabel(null)` and rendered as blank — dropdown and multiSelect both show an em dash.
3860
3921
  if (value === null || value === undefined || value === '')
3861
3922
  return '—';
3862
- const selected = this.options().find(o => valuesMatch(getOptionStoredValue(o), value, this.language));
3923
+ const selected = this.options().find(o => valuesMatch(o, value, this.language));
3863
3924
  if (selected)
3864
3925
  return this.getOptLabel(selected);
3865
3926
  const cached = this.lookups.labelFor(this.field?.listName, value, this.language);
@@ -3930,10 +3991,12 @@ class RadioFieldComponent {
3930
3991
  [attr.data-testid]="'field-' + field.id + '-option-' + optionSlug(option)"
3931
3992
  type="radio"
3932
3993
  class="ngx-field__radio-input"
3933
- [formControl]="$any(control)"
3994
+ [name]="domId()"
3934
3995
  [attr.aria-describedby]="describedBy()"
3935
3996
  [value]="getOptStoredVal(option)"
3997
+ [checked]="isChecked(option)"
3936
3998
  [attr.disabled]="field.disabled ? true : null"
3999
+ (change)="onSelect(option)"
3937
4000
  />
3938
4001
  <span class="ngx-field__radio-label">{{ getOptLabel(option) }}</span>
3939
4002
  </label>
@@ -3947,7 +4010,7 @@ class RadioFieldComponent {
3947
4010
  }
3948
4011
  </fieldset>
3949
4012
  </div>
3950
- `, isInline: true, dependencies: [{ kind: "ngmodule", type: ReactiveFormsModule }, { kind: "directive", type: i1.DefaultValueAccessor, selector: "input:not([type=checkbox])[formControlName],textarea[formControlName],input:not([type=checkbox])[formControl],textarea[formControl],input:not([type=checkbox])[ngModel],textarea[ngModel],[ngDefaultControl]" }, { kind: "directive", type: i1.RadioControlValueAccessor, selector: "input[type=radio][formControlName],input[type=radio][formControl],input[type=radio][ngModel]", inputs: ["name", "formControlName", "value"] }, { kind: "directive", type: i1.NgControlStatus, selector: "[formControlName],[ngModel],[formControl]" }, { kind: "directive", type: i1.FormControlDirective, selector: "[formControl]", inputs: ["formControl", "disabled", "ngModel"], outputs: ["ngModelChange"], exportAs: ["ngForm"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
4013
+ `, isInline: true, dependencies: [{ kind: "ngmodule", type: ReactiveFormsModule }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
3951
4014
  }
3952
4015
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.22", ngImport: i0, type: RadioFieldComponent, decorators: [{
3953
4016
  type: Component,
@@ -3999,10 +4062,12 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.22", ngImpo
3999
4062
  [attr.data-testid]="'field-' + field.id + '-option-' + optionSlug(option)"
4000
4063
  type="radio"
4001
4064
  class="ngx-field__radio-input"
4002
- [formControl]="$any(control)"
4065
+ [name]="domId()"
4003
4066
  [attr.aria-describedby]="describedBy()"
4004
4067
  [value]="getOptStoredVal(option)"
4068
+ [checked]="isChecked(option)"
4005
4069
  [attr.disabled]="field.disabled ? true : null"
4070
+ (change)="onSelect(option)"
4006
4071
  />
4007
4072
  <span class="ngx-field__radio-label">{{ getOptLabel(option) }}</span>
4008
4073
  </label>
@@ -4067,14 +4132,22 @@ class MultiSelectFieldComponent {
4067
4132
  options = signal([], ...(ngDevMode ? [{ debugName: "options" }] : /* istanbul ignore next */ []));
4068
4133
  compareFn = (o1, o2) => valuesMatch(o1, o2, this.language);
4069
4134
  isObjectVal(option) {
4070
- const val = getOptionStoredValue(option);
4071
- return typeof val === 'object' && val !== null;
4135
+ return typeof option === 'object' && option !== null;
4072
4136
  }
4073
4137
  get label() {
4074
4138
  return resolveLabel(this.field?.label, this.language);
4075
4139
  }
4140
+ /**
4141
+ * The value this option stores — the option object itself.
4142
+ *
4143
+ * Kept as a method rather than binding `option` straight into the template, because it is
4144
+ * the one place that says *why* the two are the same thing. It used to delegate to
4145
+ * `getOptionStoredValue`, an exported identity function whose two branches returned their
4146
+ * argument unchanged; naming a transformation that does not happen is worse than naming
4147
+ * nothing, because a reader has to go and check.
4148
+ */
4076
4149
  getOptStoredVal(option) {
4077
- return getOptionStoredValue(option);
4150
+ return option;
4078
4151
  }
4079
4152
  getOptLabel(option) {
4080
4153
  return resolveOptionLabel(option, this.language);
@@ -4085,7 +4158,7 @@ class MultiSelectFieldComponent {
4085
4158
  return '—';
4086
4159
  return values
4087
4160
  .map(v => {
4088
- const opt = this.options().find(o => valuesMatch(getOptionStoredValue(o), v, this.language));
4161
+ const opt = this.options().find(o => valuesMatch(o, v, this.language));
4089
4162
  if (opt)
4090
4163
  return this.getOptLabel(opt);
4091
4164
  const cached = this.lookups.labelFor(this.field?.listName, v, this.language);
@@ -4877,7 +4950,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.22", ngImpo
4877
4950
  *
4878
4951
  * **Tree-shaking contract:** this service holds *no* static imports of field components.
4879
4952
  * 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
4953
+ * opt-in. Register them with `provideBuiltInFieldTypes()` (all 21 keys) or a narrower
4881
4954
  * subset via `provideFieldTypes({ ... })` — see `providers/provide-field-types.ts`.
4882
4955
  *
4883
4956
  * Resolution order: consumer token registry → runtime registrations → nothing.
@@ -5084,16 +5157,11 @@ class DynamicFieldComponent {
5084
5157
  ref.setInput('masked', masked);
5085
5158
  }
5086
5159
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.22", ngImport: i0, type: DynamicFieldComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
5087
- static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "21.2.22", type: DynamicFieldComponent, isStandalone: true, selector: "ngx-dynamic-field", inputs: { field: "field", control: "control", config: "config", currentTabId: "currentTabId", language: "language", readonly: "readonly", userRoles: "userRoles" }, viewQueries: [{ propertyName: "fieldHost", first: true, predicate: ["fieldHost"], descendants: true, read: ViewContainerRef, static: true }], usesOnChanges: true, ngImport: i0, template: `<ng-container #fieldHost></ng-container>`, isInline: true, changeDetection: i0.ChangeDetectionStrategy.OnPush });
5160
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "21.2.22", type: DynamicFieldComponent, isStandalone: true, selector: "ngx-dynamic-field", inputs: { field: "field", control: "control", config: "config", currentTabId: "currentTabId", language: "language", readonly: "readonly", userRoles: "userRoles" }, viewQueries: [{ propertyName: "fieldHost", first: true, predicate: ["fieldHost"], descendants: true, read: ViewContainerRef, static: true }], usesOnChanges: true, ngImport: i0, template: `<ng-container #fieldHost></ng-container>`, isInline: true, styles: [":host{display:block;width:100%;min-width:0}\n"], changeDetection: i0.ChangeDetectionStrategy.OnPush });
5088
5161
  }
5089
5162
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.22", ngImport: i0, type: DynamicFieldComponent, decorators: [{
5090
5163
  type: Component,
5091
- args: [{
5092
- changeDetection: ChangeDetectionStrategy.OnPush,
5093
- selector: 'ngx-dynamic-field',
5094
- standalone: true,
5095
- template: `<ng-container #fieldHost></ng-container>`,
5096
- }]
5164
+ args: [{ changeDetection: ChangeDetectionStrategy.OnPush, selector: 'ngx-dynamic-field', standalone: true, template: `<ng-container #fieldHost></ng-container>`, styles: [":host{display:block;width:100%;min-width:0}\n"] }]
5097
5165
  }], propDecorators: { field: [{
5098
5166
  type: Input
5099
5167
  }], control: [{
@@ -5286,8 +5354,20 @@ class ValidatorRegistryService {
5286
5354
  fnList.push(Validators.minLength(config.minLength));
5287
5355
  if (config.maxLength !== undefined)
5288
5356
  fnList.push(Validators.maxLength(config.maxLength));
5289
- if (config.pattern)
5290
- fnList.push(Validators.pattern(config.pattern));
5357
+ if (config.pattern) {
5358
+ // `Validators.pattern` compiles the string, so an unparseable one threw here — during
5359
+ // control construction, which takes the whole form down rather than one field's format
5360
+ // check. `validateConfig` reports it as an error; at runtime the field degrades to no
5361
+ // pattern validation, which is exactly what the server-side import path already does.
5362
+ try {
5363
+ fnList.push(Validators.pattern(config.pattern));
5364
+ }
5365
+ catch (err) {
5366
+ this.warnOnce(`pattern:${config.pattern}`, `The pattern "${config.pattern}" is not a valid regular expression ` +
5367
+ `(${err instanceof Error ? err.message : String(err)}), so no format check is ` +
5368
+ `applied to this field. Run "dynamic-entity validate" over the config to find it.`);
5369
+ }
5370
+ }
5291
5371
  if (config.email)
5292
5372
  fnList.push(Validators.email);
5293
5373
  // Named validators from the consumer registry. This branch previously hardcoded the
@@ -5298,6 +5378,8 @@ class ValidatorRegistryService {
5298
5378
  const fn = this.resolve(key);
5299
5379
  if (fn)
5300
5380
  fnList.push(fn);
5381
+ else
5382
+ this.warnUnresolved('custom', key);
5301
5383
  }
5302
5384
  return fnList;
5303
5385
  }
@@ -5319,9 +5401,35 @@ class ValidatorRegistryService {
5319
5401
  const fn = this.asyncRegistry.get(key);
5320
5402
  if (fn)
5321
5403
  out.push(fn);
5404
+ else
5405
+ this.warnUnresolved('customAsync', key);
5322
5406
  }
5323
5407
  return out;
5324
5408
  }
5409
+ /**
5410
+ * Say when a named validator resolved to nothing.
5411
+ *
5412
+ * It used to be dropped in silence, which is the worst failure mode this library has: the
5413
+ * schema says the field is checked, the field is not checked, the form saves, and nothing
5414
+ * anywhere said so. For `customAsync` that is typically a uniqueness check, so the duplicate
5415
+ * lands in the database.
5416
+ *
5417
+ * Dev only, once per name — this runs while controls are built, which happens on every
5418
+ * config change. `validateConfig` with `knownValidators` is the build-time version of the
5419
+ * same check, and it fails CI rather than a console.
5420
+ */
5421
+ warnUnresolved(kind, key) {
5422
+ this.warnOnce(`${kind}:${key}`, `No validator named "${key}" is registered, so validators.${kind} drops it and it never ` +
5423
+ `runs. Register it with provideNgxDynamicEntity({ ${kind === 'customAsync' ? 'asyncValidators' : 'validators'}: { '${key}': ... } }).`);
5424
+ }
5425
+ /** Per service instance, which is per injector — so a test gets a clean slate. */
5426
+ warned = new Set();
5427
+ warnOnce(id, message) {
5428
+ if (!isDevMode() || this.warned.has(id))
5429
+ return;
5430
+ this.warned.add(id);
5431
+ console.warn(`[ngx-dynamic-entity] ${message}`);
5432
+ }
5325
5433
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.22", ngImport: i0, type: ValidatorRegistryService, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
5326
5434
  static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "21.2.22", ngImport: i0, type: ValidatorRegistryService, providedIn: 'root' });
5327
5435
  }
@@ -6159,7 +6267,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.22", ngImpo
6159
6267
  *
6160
6268
  * `FieldRegistryService` imports no components. Whatever you register here is what gets
6161
6269
  * bundled, so an app that only uses text/number/dropdown pays for three components and
6162
- * their imports, not nineteen.
6270
+ * their imports, not twenty-one.
6163
6271
  *
6164
6272
  * ```ts
6165
6273
  * // Everything (convenient; bundles all built-ins):
@@ -6203,7 +6311,7 @@ function provideFieldTypes(types) {
6203
6311
  { provide: FIELD_TYPE_SETS, useValue: types, multi: true },
6204
6312
  ]);
6205
6313
  }
6206
- /** Register all 19 built-in field type keys. Convenience for apps that use most of them. */
6314
+ /** Register all 21 built-in field type keys. Convenience for apps that use most of them. */
6207
6315
  function provideBuiltInFieldTypes() {
6208
6316
  return provideFieldTypes(builtInFieldTypes());
6209
6317
  }
@@ -6232,14 +6340,24 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.22", ngImpo
6232
6340
  }] });
6233
6341
 
6234
6342
  /**
6235
- * RulesEvaluationService — evaluates dynamic form rules against active reactive form state.
6343
+ * RulesEvaluationService — evaluates rules, and owns what a result *means*.
6344
+ *
6345
+ * It used to be two passthrough methods, with the interpretation spread across the component:
6346
+ * the render filter decided one way what a rule result did to a field, and the validity sync
6347
+ * decided again forty lines further down. They disagreed about what "every field" meant, and
6348
+ * the consequence was a required field inside a `group` that a rule had hidden holding
6349
+ * `form.invalid` true forever with nothing on screen to explain it.
6350
+ *
6351
+ * Precedence lives here now, in one method that both callers go through. Still stateless —
6352
+ * every method takes the result it is interpreting, so there is nothing to go stale against a
6353
+ * rebuilt form and nothing to reset between configs.
6236
6354
  */
6237
6355
  class RulesEvaluationService {
6238
6356
  /**
6239
6357
  * Evaluate rules against form values and optional original baseline values.
6240
6358
  */
6241
- evaluate(rules, values, originalValues) {
6242
- return evaluateFormRules(rules, values, originalValues);
6359
+ evaluate(rules, values, originalValues, options) {
6360
+ return evaluateFormRules(rules, values, originalValues, options);
6243
6361
  }
6244
6362
  /**
6245
6363
  * Filter rules pertinent to a single tab.
@@ -6247,6 +6365,89 @@ class RulesEvaluationService {
6247
6365
  filterForTab(rules, tabId, config) {
6248
6366
  return filterRulesForTab(rules, tabId, config);
6249
6367
  }
6368
+ /**
6369
+ * Whether a field renders, under the precedence documented on `RuleEvaluationResult`.
6370
+ *
6371
+ * An explicit hide beats everything. A show beats static hiding — `visibility: false`, or a
6372
+ * `showWhen` that does not currently hold — because a rule that could only hide what the
6373
+ * config already hides would have nothing to say, and `{ visibility: true }` was a
6374
+ * documented no-op until it did. Otherwise the config's own visibility decides.
6375
+ */
6376
+ isFieldVisible(result, field, names, values) {
6377
+ const sets = this.nameSets(result);
6378
+ if (names.some(name => sets.hidden.has(name)))
6379
+ return false;
6380
+ if (names.some(name => sets.shown.has(name)))
6381
+ return true;
6382
+ return evaluateFieldVisibility(field, values);
6383
+ }
6384
+ /** The same precedence for a tab. */
6385
+ isTabVisible(result, tab) {
6386
+ if (result.hiddenTabs.includes(tab.id))
6387
+ return false;
6388
+ return tab.visibility !== false || result.shownTabs.includes(tab.id);
6389
+ }
6390
+ /**
6391
+ * Keep a hidden field's control out of form validity.
6392
+ *
6393
+ * A field hidden by a rule or a `showWhen` is filtered out of the render, but its
6394
+ * validators stay attached — so a hidden *required* field holds `form.invalid` true
6395
+ * forever, permanently disabling Save with nothing on screen to explain why.
6396
+ *
6397
+ * Disabling is the fix rather than stripping validators: Angular excludes disabled controls
6398
+ * from validity, and the field's own validators survive intact for when it comes back.
6399
+ * Values are untouched — a disabled control still carries its value, so rule evaluation and
6400
+ * the emitted record see exactly what they saw before. That also keeps this from feeding
6401
+ * back on itself: hiding a field cannot change the values the rules are evaluated over.
6402
+ *
6403
+ * The predicate is `isFieldVisible`, the same one the render filter uses, so what is on
6404
+ * screen and what counts toward validity cannot drift apart.
6405
+ *
6406
+ * Entries must arrive parent-first, which is what `collectFieldScopes` yields: a container's
6407
+ * children are skipped once the container itself is hidden, because Angular's `disable()`
6408
+ * cascades to descendants and a child's `enable()` walks back up recalculating ancestors.
6409
+ * Toggling a child inside a hidden group would fight its parent.
6410
+ */
6411
+ syncHiddenFieldState(entries, ctx) {
6412
+ const hiddenContainers = [];
6413
+ for (const entry of entries) {
6414
+ const field = entry.field;
6415
+ if (!field?.id)
6416
+ continue;
6417
+ const address = ctx.addressOf(field);
6418
+ if (hiddenContainers.some(prefix => address.startsWith(`${prefix}.`)))
6419
+ continue;
6420
+ const hidden = !this.isFieldVisible(ctx.result, field, ctx.namesOf(field), ctx.values);
6421
+ if (hidden && (field.type === 'group' || field.type === 'array'))
6422
+ hiddenContainers.push(address);
6423
+ const ctrl = ctx.controlFor(entry);
6424
+ if (!ctrl)
6425
+ continue;
6426
+ if (hidden) {
6427
+ if (ctrl.enabled)
6428
+ ctrl.disable({ emitEvent: false });
6429
+ }
6430
+ else if (ctrl.disabled && !field.disabled) {
6431
+ ctrl.enable({ emitEvent: false });
6432
+ }
6433
+ }
6434
+ }
6435
+ /**
6436
+ * The hidden and shown names as sets, memoised against the result they came from.
6437
+ *
6438
+ * `isFieldVisible` runs once per field per change-detection pass; building two sets from
6439
+ * two arrays each time turns a rule result into allocation proportional to the form. A
6440
+ * `WeakMap` keyed by the result means it is built once per evaluation and collected with it.
6441
+ */
6442
+ nameSets(result) {
6443
+ let sets = this.nameSetCache.get(result);
6444
+ if (!sets) {
6445
+ sets = { hidden: new Set(result.hiddenFields), shown: new Set(result.shownFields) };
6446
+ this.nameSetCache.set(result, sets);
6447
+ }
6448
+ return sets;
6449
+ }
6450
+ nameSetCache = new WeakMap();
6250
6451
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.22", ngImport: i0, type: RulesEvaluationService, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
6251
6452
  static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "21.2.22", ngImport: i0, type: RulesEvaluationService, providedIn: 'root' });
6252
6453
  }
@@ -6318,6 +6519,255 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.22", ngImpo
6318
6519
  args: [CONFIG_SOURCE]
6319
6520
  }] }] });
6320
6521
 
6522
+ /**
6523
+ * FormStructureService — config ⇄ `FormGroup`, and nothing else.
6524
+ *
6525
+ * The one question this answers is *where a value lives*: which control a field owns, how
6526
+ * the tab tree nests into groups, how a record is read out of the tree and patched back in.
6527
+ * It was half of `DynamicFormComponent`, mixed in with tab state, focus management, rule
6528
+ * evaluation and RBAC — and the cost of that was not length but ownership: the bug Phase 2
6529
+ * fixed was two methods forty lines apart disagreeing about what "every field" meant.
6530
+ *
6531
+ * Deliberately stateless. Every method takes the form and the config it is operating on, so
6532
+ * there is no cached `this.form` to go stale against a rebuilt one, and every method is
6533
+ * testable without a component or a TestBed.
6534
+ *
6535
+ * Provided at the component, not the root: `FormBuilder` and the validator registry are the
6536
+ * only things injected, but keeping the service per form leaves room for that to change
6537
+ * without turning into shared mutable state.
6538
+ */
6539
+ class FormStructureService {
6540
+ fb = inject(FormBuilder);
6541
+ validatorRegistry = inject(ValidatorRegistryService);
6542
+ /**
6543
+ * Build the control tree for a config.
6544
+ *
6545
+ * The tree mirrors the record's shape exactly: a tab opens a `FormGroup` under its id, a
6546
+ * `flatData` tab merges into its parent's, and a `group` field opens one for its children.
6547
+ * That correspondence is what lets a `[path]` ref resolve through `form.get()` and name
6548
+ * exactly one control — see `getControl`.
6549
+ */
6550
+ buildForm(config) {
6551
+ const group = {};
6552
+ const buildTabControls = (tabs, parentGroup) => {
6553
+ for (const tab of tabs) {
6554
+ if (tab.flatData) {
6555
+ for (const field of tab.fields || []) {
6556
+ this.buildFieldControl(field, parentGroup);
6557
+ }
6558
+ if (tab.children)
6559
+ buildTabControls(tab.children, parentGroup);
6560
+ }
6561
+ else {
6562
+ const tabGroup = {};
6563
+ for (const field of tab.fields || []) {
6564
+ this.buildFieldControl(field, tabGroup);
6565
+ }
6566
+ if (tab.children)
6567
+ buildTabControls(tab.children, tabGroup);
6568
+ parentGroup[tab.id] = this.fb.group(tabGroup);
6569
+ }
6570
+ }
6571
+ };
6572
+ buildTabControls(config.tabs || [], group);
6573
+ return this.fb.group(group);
6574
+ }
6575
+ /** One field's control: a nested group, an empty array, or a leaf carrying its validators. */
6576
+ buildFieldControl(field, group) {
6577
+ if (field.type === 'group') {
6578
+ const subGroup = {};
6579
+ for (const child of field.children || []) {
6580
+ this.buildFieldControl(child, subGroup);
6581
+ }
6582
+ group[field.id] = this.fb.group(subGroup);
6583
+ }
6584
+ else if (field.type === 'array') {
6585
+ group[field.id] = this.fb.array([]);
6586
+ }
6587
+ else {
6588
+ const validators = this.validatorRegistry.resolveFromConfig(field.validators);
6589
+ const asyncValidators = this.validatorRegistry.resolveAsyncFromConfig(field.validators);
6590
+ group[field.id] = this.fb.control({ value: field.defaultValue ?? null, disabled: field.disabled ?? false }, validators, asyncValidators);
6591
+ }
6592
+ }
6593
+ /** One row of an `array` field: a FormGroup when the field declares columns, else a bare control. */
6594
+ buildArrayRow(field, item) {
6595
+ if (!field?.children?.length)
6596
+ return this.fb.control(item);
6597
+ const rowGroup = {};
6598
+ for (const child of field.children) {
6599
+ this.buildFieldControl(child, rowGroup);
6600
+ }
6601
+ const group = this.fb.group(rowGroup);
6602
+ if (item && typeof item === 'object')
6603
+ group.patchValue(item);
6604
+ return group;
6605
+ }
6606
+ /** The `FormGroup` a tab's fields live in, honouring `flatData`. */
6607
+ getTabGroup(form, config, tabId) {
6608
+ if (!form)
6609
+ return null;
6610
+ const path = getTabPath(config?.tabs, tabId);
6611
+ if (!path || path.length === 0)
6612
+ return form;
6613
+ let curr = form;
6614
+ for (const p of path) {
6615
+ if (!curr || !(curr instanceof FormGroup))
6616
+ return null;
6617
+ curr = curr.get(p);
6618
+ }
6619
+ return curr instanceof FormGroup ? curr : null;
6620
+ }
6621
+ /**
6622
+ * Find a control by reference: a bracketed path, or a bare field id.
6623
+ *
6624
+ * A bracketed path names exactly one control, because the tree nests exactly as the path
6625
+ * does — so `form.get('work.address.city')` walks straight to it. A bare id is what every
6626
+ * config written before paths existed uses: it is looked for on `currentTabId` first, then
6627
+ * at the root, then by a recursive search whose result is whichever the walk reaches first.
6628
+ * That last branch is the ambiguity refs exist to remove, and callers that can name a path
6629
+ * should.
6630
+ */
6631
+ getControl(form, config, fieldId, currentTabId) {
6632
+ if (!form)
6633
+ return null;
6634
+ const parsed = parseFieldRef(fieldId);
6635
+ if (parsed.kind === 'ref')
6636
+ return form.get(parsed.value);
6637
+ if (currentTabId) {
6638
+ const tabGrp = this.getTabGroup(form, config, currentTabId);
6639
+ const ctrl = tabGrp?.get(fieldId);
6640
+ if (ctrl)
6641
+ return ctrl;
6642
+ }
6643
+ const rootCtrl = form.get(fieldId);
6644
+ if (rootCtrl)
6645
+ return rootCtrl;
6646
+ const findInGroup = (group) => {
6647
+ for (const key of Object.keys(group.controls)) {
6648
+ const c = group.controls[key];
6649
+ if (key === fieldId)
6650
+ return c;
6651
+ if (c instanceof FormGroup) {
6652
+ const found = findInGroup(c);
6653
+ if (found)
6654
+ return found;
6655
+ }
6656
+ }
6657
+ return null;
6658
+ };
6659
+ return findInGroup(form);
6660
+ }
6661
+ /** Assemble the full nested record from per-tab groups, respecting flatData, refererField & arrays. */
6662
+ extractRecord(form, config) {
6663
+ if (!form || !config)
6664
+ return {};
6665
+ let record = {};
6666
+ const walkTabs = (tabs) => {
6667
+ for (const tab of tabs) {
6668
+ const fieldValBag = {};
6669
+ for (const field of tab.fields || []) {
6670
+ const ctrl = this.getControl(form, config, field.id, tab.id);
6671
+ if (ctrl)
6672
+ fieldValBag[field.id] = ctrl.value;
6673
+ }
6674
+ setTabData(record, tab.id, fieldValBag, config);
6675
+ for (const field of tab.fields || []) {
6676
+ if (field.refererField) {
6677
+ const ctrl = this.getControl(form, config, field.id, tab.id);
6678
+ if (ctrl)
6679
+ setValueByPath(record, field.refererField, ctrl.value);
6680
+ }
6681
+ }
6682
+ if (tab.children)
6683
+ walkTabs(tab.children);
6684
+ }
6685
+ };
6686
+ walkTabs(config.tabs || []);
6687
+ record = normalizeArrayStructures(record, config);
6688
+ return record;
6689
+ }
6690
+ /**
6691
+ * Write a record into the control tree.
6692
+ *
6693
+ * Returns the top-level keys that named a known field and were *not* consumed, so the
6694
+ * caller can say so — see `DynamicFormComponent.warnUnconsumedInitialData`. Reporting
6695
+ * rather than logging keeps the "warn once per form" bookkeeping with the form, and keeps
6696
+ * this service free of console output.
6697
+ */
6698
+ patchForm(form, config, data, fieldsById) {
6699
+ if (!data || !form || !config)
6700
+ return [];
6701
+ const patchedFieldIds = new Set();
6702
+ const walkTabs = (tabs) => {
6703
+ for (const tab of tabs) {
6704
+ const tabData = getTabData(tab.id, data, config);
6705
+ for (const field of tab.fields || []) {
6706
+ let val = tabData && typeof tabData === 'object' ? tabData[field.id] : undefined;
6707
+ if (field.refererField) {
6708
+ const refVal = getValueByPath(data, field.refererField);
6709
+ if (refVal !== undefined)
6710
+ val = refVal;
6711
+ }
6712
+ if (val === undefined)
6713
+ continue;
6714
+ const ctrl = this.getControl(form, config, field.id, tab.id);
6715
+ if (!ctrl)
6716
+ continue;
6717
+ if (ctrl instanceof FormArray && Array.isArray(val)) {
6718
+ ctrl.clear();
6719
+ for (const item of val) {
6720
+ ctrl.push(this.buildArrayRow(fieldsById.get(field.id), item));
6721
+ }
6722
+ }
6723
+ else {
6724
+ ctrl.patchValue(val, { emitEvent: false });
6725
+ }
6726
+ patchedFieldIds.add(field.id);
6727
+ }
6728
+ if (tab.children)
6729
+ walkTabs(tab.children);
6730
+ }
6731
+ };
6732
+ walkTabs(config.tabs || []);
6733
+ return this.unconsumedKeys(config, data, fieldsById, patchedFieldIds);
6734
+ }
6735
+ /**
6736
+ * Top-level keys that name a field but reached no control.
6737
+ *
6738
+ * A record is nested by tab id (`{ tabId: { fieldId: value } }`) unless the tab sets
6739
+ * `flatData: true`. Handing a flat record to a nested tab finds nothing, so the fields stay
6740
+ * empty — with no error and no clue.
6741
+ *
6742
+ * Only keys matching a known field id count: anything else is the consumer's own record
6743
+ * metadata (ids, timestamps, `_configVersion`) and is not our business.
6744
+ */
6745
+ unconsumedKeys(config, data, fieldsById, patchedFieldIds) {
6746
+ if (!isDevMode())
6747
+ return [];
6748
+ const tabIds = new Set();
6749
+ const collectTabIds = (tabs) => {
6750
+ for (const tab of tabs) {
6751
+ tabIds.add(tab.id);
6752
+ if (tab.children)
6753
+ collectTabIds(tab.children);
6754
+ }
6755
+ };
6756
+ collectTabIds(config.tabs || []);
6757
+ return Object.keys(data).filter(key =>
6758
+ // A tab id at the root is the nested container, not a stray field.
6759
+ !tabIds.has(key) &&
6760
+ data[key] !== undefined &&
6761
+ fieldsById.has(key) &&
6762
+ !patchedFieldIds.has(key));
6763
+ }
6764
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.22", ngImport: i0, type: FormStructureService, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
6765
+ static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "21.2.22", ngImport: i0, type: FormStructureService });
6766
+ }
6767
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.22", ngImport: i0, type: FormStructureService, decorators: [{
6768
+ type: Injectable
6769
+ }] });
6770
+
6321
6771
  /**
6322
6772
  * Grid width per field type, used only under `layout="auto"` and only where the field itself
6323
6773
  * names no `colSpan`.
@@ -6437,6 +6887,8 @@ class DynamicFormComponent {
6437
6887
  hookRegistry = inject(HookRegistryService);
6438
6888
  rbacService = inject(RbacService);
6439
6889
  rulesEvaluation = inject(RulesEvaluationService);
6890
+ /** config ⇄ FormGroup: where a value lives. See `FormStructureService`. */
6891
+ structure = inject(FormStructureService);
6440
6892
  /** Resolves what is wrong with a field, for the error summary — see `resolveForField`. */
6441
6893
  messages = inject(ValidationMessagesService);
6442
6894
  entityRefSelection = inject(EntityRefSelectionService);
@@ -6475,12 +6927,29 @@ class DynamicFormComponent {
6475
6927
  // invalid form is what produces the error summary. Guarding here instead meant Ctrl+S
6476
6928
  // on an incomplete form did nothing at all — and unlike the button, a shortcut cannot
6477
6929
  // even look disabled.
6930
+ // `void`, not an `await`: a `@HostListener` cannot be async without changing what
6931
+ // Angular does with its return value, and `submit()` reports its own failures through
6932
+ // `saveRejected`. Nothing here would have anywhere to put a rejection.
6478
6933
  if (!this.readonly && this.canSubmit)
6479
- this.submit();
6934
+ void this.submit();
6480
6935
  }
6481
6936
  }
6482
6937
  // ─── Computed ─────────────────────────────────────────────────────────────
6483
- ruleResult = computed(() => this.rulesEvaluation.evaluate(this.rules, this.formValues(), this.baseline()), ...(ngDevMode ? [{ debugName: "ruleResult" }] : /* istanbul ignore next */ []));
6938
+ ruleResult = computed(() => this.rulesEvaluation.evaluate(this.rules, this.formValues(), this.baseline(), {
6939
+ // The engine reports rather than logs, because it also runs on a server. This is the
6940
+ // client's answer: say it once per distinct problem, in dev only. A rule the author
6941
+ // half-finished used to throw out of change detection; now it is skipped, and a
6942
+ // silently skipped rule is exactly as hard to diagnose as a thrown one.
6943
+ onProblem: message => this.warnOnce(message),
6944
+ }), ...(ngDevMode ? [{ debugName: "ruleResult" }] : /* istanbul ignore next */ []));
6945
+ /** Warned-about rule problems, per component instance — see `warnedAmbiguousIds`. */
6946
+ warnedProblems = new Set();
6947
+ warnOnce(message) {
6948
+ if (!isDevMode() || this.warnedProblems.has(message))
6949
+ return;
6950
+ this.warnedProblems.add(message);
6951
+ console.warn(`[ngx-dynamic-entity] ${message}`);
6952
+ }
6484
6953
  /**
6485
6954
  * Critical fields whose value differs from the session baseline.
6486
6955
  * These drive the deferred `VALUE_CHANGED` banner — a critical edit is announced once,
@@ -6497,8 +6966,10 @@ class DynamicFormComponent {
6497
6966
  return this.config?.tabs || [];
6498
6967
  }
6499
6968
  get visibleTabs() {
6500
- const hidden = this.ruleResult().hiddenTabs;
6501
- return this.tabs.filter(tab => tab.visibility !== false && !hidden.includes(tab.id));
6969
+ return this.tabs.filter(tab => this.isTabVisible(tab));
6970
+ }
6971
+ isTabVisible(tab) {
6972
+ return this.rulesEvaluation.isTabVisible(this.ruleResult(), tab);
6502
6973
  }
6503
6974
  get activeTabConfig() {
6504
6975
  const tabId = this.activeTab();
@@ -6510,8 +6981,7 @@ class DynamicFormComponent {
6510
6981
  const active = this.activeTabConfig;
6511
6982
  if (!active?.children || active.children.length === 0)
6512
6983
  return [];
6513
- const hidden = this.ruleResult().hiddenTabs;
6514
- return active.children.filter(tab => tab.visibility !== false && !hidden.includes(tab.id));
6984
+ return active.children.filter(tab => this.isTabVisible(tab));
6515
6985
  }
6516
6986
  get activeSubTabConfig() {
6517
6987
  const subTabs = this.visibleSubTabs;
@@ -6561,8 +7031,15 @@ class DynamicFormComponent {
6561
7031
  return [];
6562
7032
  const rawFields = active ? active.fields || [] : (this.config?.tabs || []).flatMap(t => t.fields || []);
6563
7033
  const currentValues = this.formValues();
6564
- const hiddenFields = new Set(this.ruleResult().hiddenFields);
6565
- return rawFields.filter(field => evaluateFieldVisibility(field, currentValues) && !this.namesField(hiddenFields, field));
7034
+ return rawFields.filter(field => this.isFieldVisible(field, currentValues));
7035
+ }
7036
+ /**
7037
+ * Whether a field renders. Precedence lives in `RulesEvaluationService`, which is also
7038
+ * what `syncHiddenFieldState` asks — so what is on screen and what counts toward validity
7039
+ * cannot drift apart.
7040
+ */
7041
+ isFieldVisible(field, values) {
7042
+ return this.rulesEvaluation.isFieldVisible(this.ruleResult(), field, this.namesOf(field), values);
6566
7043
  }
6567
7044
  /**
6568
7045
  * Entity-level permissions for the current roles.
@@ -6788,19 +7265,36 @@ class DynamicFormComponent {
6788
7265
  if (this.invalidFieldsCache)
6789
7266
  return this.invalidFieldsCache;
6790
7267
  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 });
7268
+ /*
7269
+ * Descends into `group` children rather than stopping at the container.
7270
+ *
7271
+ * A `FormGroup` is invalid whenever any descendant is, so reporting the container said
7272
+ * "Contacts is invalid" and left the user to open it and hunt. The offending leaf is
7273
+ * both the useful answer and the one `jumpToField` can actually move focus to.
7274
+ *
7275
+ * An `array` is reported as itself: its rows are built and destroyed at runtime, so
7276
+ * there is no configured child to name and nothing stable to jump to.
7277
+ */
7278
+ const collectField = (field, tab, parentId) => {
7279
+ const control = this.getControl(field.id, tab.id);
7280
+ if (!control || control.disabled || control.valid || control.pending)
7281
+ return;
7282
+ if (field.type === 'group' && field.children?.length) {
7283
+ for (const child of field.children)
7284
+ collectField(child, tab, parentId);
7285
+ return;
6803
7286
  }
7287
+ // The same message the field renders under itself — see `resolveForField`. Saying
7288
+ // only *which* field is wrong leaves the user to go and look at each one; saying what
7289
+ // is wrong with it is usually enough to fix it without leaving the summary.
7290
+ const message = this.messages.resolveForField(control.errors, this.language, field.type);
7291
+ out.push(parentId
7292
+ ? { field, message, tabId: parentId, subTabId: tab.id }
7293
+ : { field, message, tabId: tab.id });
7294
+ };
7295
+ const collect = (tab, parentId) => {
7296
+ for (const field of tab.fields ?? [])
7297
+ collectField(field, tab, parentId);
6804
7298
  for (const child of tab.children ?? [])
6805
7299
  collect(child, parentId ?? tab.id);
6806
7300
  };
@@ -6852,16 +7346,31 @@ class DynamicFormComponent {
6852
7346
  el.focus();
6853
7347
  }, { injector: this.injector });
6854
7348
  }
7349
+ /**
7350
+ * Which tab (and sub-tab) holds a field.
7351
+ *
7352
+ * Descends into `group`/`array` children: this compared top-level fields only, so "jump to
7353
+ * the first invalid field" found nothing for a nested one and silently did nothing. Only
7354
+ * sub-tabs one level deep are named, because that is what the tab strip can select —
7355
+ * anything deeper resolves to the ancestor it renders under.
7356
+ */
6855
7357
  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 };
7358
+ const holds = (fields) => (fields ?? []).some(f => f?.id === fieldId || holds(f?.children));
7359
+ const search = (tabs, tabId, subTabId) => {
7360
+ for (const tab of tabs ?? []) {
7361
+ if (!tab?.id)
7362
+ continue;
7363
+ const at = tabId ?? tab.id;
7364
+ const sub = tabId ? (subTabId ?? tab.id) : undefined;
7365
+ if (holds(tab.fields))
7366
+ return sub ? { tabId: at, subTabId: sub } : { tabId: at };
7367
+ const found = search(tab.children, at, sub);
7368
+ if (found)
7369
+ return found;
6862
7370
  }
6863
- }
6864
- return null;
7371
+ return null;
7372
+ };
7373
+ return search(this.tabs);
6865
7374
  }
6866
7375
  // ─── criticalField locking ────────────────────────────────────────────────
6867
7376
  /** A critical field stays read-only until the user deliberately unlocks it. */
@@ -6921,29 +7430,9 @@ class DynamicFormComponent {
6921
7430
  buildForm() {
6922
7431
  if (!this.config)
6923
7432
  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);
7433
+ this.form = this.structure.buildForm(this.config);
7434
+ // A rebuild replaces every control, and the scope index is keyed by config identity.
7435
+ this.scopeCache = undefined;
6947
7436
  this.warnAmbiguousRuleReferences();
6948
7437
  if (this.initialData) {
6949
7438
  this.patchForm(this.upgradeRecord(this.initialData));
@@ -6989,24 +7478,6 @@ class DynamicFormComponent {
6989
7478
  this.formChange.emit(this.extractRecord());
6990
7479
  });
6991
7480
  }
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
7481
  /**
7011
7482
  * The `FormArray` behind an `array` field, for callers that manage rows themselves —
7012
7483
  * the record editor's inline row drawer.
@@ -7023,16 +7494,7 @@ class DynamicFormComponent {
7023
7494
  return this.buildArrayRow(field, value);
7024
7495
  }
7025
7496
  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;
7497
+ return this.structure.buildArrayRow(field, item);
7036
7498
  }
7037
7499
  /**
7038
7500
  * Mark every control touched, then re-check the field components.
@@ -7065,42 +7527,11 @@ class DynamicFormComponent {
7065
7527
  return result.record;
7066
7528
  }
7067
7529
  patchForm(data) {
7068
- if (!data || !this.form)
7530
+ if (!data || !this.form || !this.config)
7069
7531
  return;
7070
7532
  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);
7533
+ const unconsumed = this.structure.patchForm(this.form, this.config, data, fieldsById);
7534
+ this.warnUnconsumedInitialData(unconsumed);
7104
7535
  }
7105
7536
  /**
7106
7537
  * A record is nested by tab id (`{ tabId: { fieldId: value } }`) unless the tab sets
@@ -7108,34 +7539,18 @@ class DynamicFormComponent {
7108
7539
  * stay empty — with no error and no clue. That silent miss is the most expensive way to
7109
7540
  * lose an hour with this library, so name it in dev builds.
7110
7541
  *
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.
7542
+ * Which keys were missed is `FormStructureService`'s answer; warning once per key per form
7543
+ * is this component's, because it is the thing that lives as long as the form does.
7114
7544
  */
7115
- warnUnconsumedInitialData(data, fieldsById, patchedFieldIds) {
7116
- if (!isDevMode() || !this.config)
7545
+ warnUnconsumedInitialData(keys) {
7546
+ const fresh = keys.filter(key => !this.warnedUnconsumedKeys.has(key));
7547
+ if (!fresh.length)
7117
7548
  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)
7549
+ for (const key of fresh)
7135
7550
  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;
7551
+ const shown = fresh.slice(0, 5).join(', ');
7552
+ const more = fresh.length > 5 ? ` (+${fresh.length - 5} more)` : '';
7553
+ const plural = fresh.length === 1;
7139
7554
  console.warn(`[ngx-dynamic-entity] initialData has top-level ${plural ? 'key' : 'keys'} matching ` +
7140
7555
  `${plural ? 'a field that was' : 'fields that were'} not populated: ${shown}${more}. ` +
7141
7556
  `A record is nested by tab id ({ tabId: { fieldId: value } }) unless the tab sets ` +
@@ -7146,34 +7561,7 @@ class DynamicFormComponent {
7146
7561
  warnedUnconsumedKeys = new Set();
7147
7562
  /** Assemble full nested record from per-tab FormGroups, respecting flatData, refererField & arrays. */
7148
7563
  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;
7564
+ return this.structure.extractRecord(this.form, this.config);
7177
7565
  }
7178
7566
  /**
7179
7567
  * Warns when a rule names a field id that two scopes define.
@@ -7211,15 +7599,24 @@ class DynamicFormComponent {
7211
7599
  continue;
7212
7600
  const id = parsed.value;
7213
7601
  const scopes = ambiguous.get(id);
7214
- if (!scopes || DynamicFormComponent.warnedAmbiguousIds.has(id))
7602
+ if (!scopes || this.warnedAmbiguousIds.has(id))
7215
7603
  continue;
7216
- DynamicFormComponent.warnedAmbiguousIds.add(id);
7604
+ this.warnedAmbiguousIds.add(id);
7217
7605
  console.warn(`[ngx-dynamic-entity] A rule references field "${id}", which is defined in ${scopes.join(' and ')}. ` +
7218
7606
  `A bare id cannot say which one is meant, so the rule will read whichever the form finds first. ` +
7219
7607
  `Name it by path instead, as [${scopes[0]}.${id}].`);
7220
7608
  }
7221
7609
  }
7222
- static warnedAmbiguousIds = new Set();
7610
+ /**
7611
+ * Ids already warned about, per component instance.
7612
+ *
7613
+ * This was `private static`, so it was module-global mutable state that nothing ever
7614
+ * cleared: under SSR every render of every form in the process accumulated into one set
7615
+ * that lived as long as the server did, and the first request to render a config
7616
+ * suppressed the warning for every request after it. Per instance, the warning is once per
7617
+ * form — which is what "warn once" was meant to mean.
7618
+ */
7619
+ warnedAmbiguousIds = new Set();
7223
7620
  /**
7224
7621
  * The value map rules and `showWhen` are evaluated against.
7225
7622
  *
@@ -7233,23 +7630,75 @@ class DynamicFormComponent {
7233
7630
  * unknown>` and needs no knowledge of refs at all — the extra keys simply resolve.
7234
7631
  */
7235
7632
  /**
7236
- * Whether a rule result names this field.
7633
+ * Every name a rule may address this field by.
7237
7634
  *
7238
7635
  * 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.
7636
+ * a bracketed path, which is the only way to name one of two fields that share an id. Both
7637
+ * have to match, or a rule written either way would fail to hide what it targeted.
7638
+ *
7639
+ * The path comes last, and `syncHiddenFieldState` relies on that: it uses the last entry
7640
+ * as the field's address when deciding whether a hidden container already covers it.
7241
7641
  */
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;
7642
+ namesOf(field) {
7643
+ const path = field.refererField ?? this.pathFor(field);
7644
+ return path ? [field.id, toRefToken(path)] : [field.id];
7645
+ }
7646
+ /**
7647
+ * The field's dotted address, unbracketed.
7648
+ *
7649
+ * The positional path rather than an authored `refererField`: this is compared as a prefix
7650
+ * to decide whether a hidden container already covers a field, and containment is a fact
7651
+ * about where the field *is*. A `refererField` is a binding override — it says where the
7652
+ * value goes, not which group renders it.
7653
+ */
7654
+ addressOf(field) {
7655
+ return this.pathFor(field) ?? field.id;
7247
7656
  }
7248
7657
  /** The field's declared path, or the one its position implies when it carries none. */
7249
7658
  pathFor(field) {
7250
- const entry = collectFieldScopes(this.config).find(e => e.field === field);
7659
+ const entry = this.scopeEntryFor(field);
7251
7660
  return entry ? fieldRefFor(entry.scope, field.id) : null;
7252
7661
  }
7662
+ /**
7663
+ * Every field in the config with the scope its value is stored under, cached per config.
7664
+ *
7665
+ * `collectFieldScopes` walks the whole tree. It was called once per field from `pathFor`,
7666
+ * which `namesField` calls, which the render filter calls for every field on the active
7667
+ * tab — a quadratic walk on every change-detection pass. Keyed by config identity, so a
7668
+ * new config invalidates it without an explicit lifecycle hook to forget.
7669
+ */
7670
+ fieldScopes() {
7671
+ if (this.scopeCache?.config !== this.config) {
7672
+ const entries = collectFieldScopes(this.config);
7673
+ this.scopeCache = {
7674
+ config: this.config,
7675
+ entries,
7676
+ byField: new Map(entries.map(entry => [entry.field, entry])),
7677
+ };
7678
+ }
7679
+ return this.scopeCache.entries;
7680
+ }
7681
+ scopeEntryFor(field) {
7682
+ this.fieldScopes();
7683
+ return this.scopeCache?.byField.get(field);
7684
+ }
7685
+ scopeCache;
7686
+ /**
7687
+ * The control a scope entry addresses, named by path rather than by bare id.
7688
+ *
7689
+ * `getControl(field.id)` with no tab falls through to a first-match recursive search of the
7690
+ * whole form, so with `address` on two tabs, hiding one disabled the other. The control
7691
+ * tree mirrors the scope path exactly — `buildTabControls` nests by tab id and
7692
+ * `buildFieldControl` nests a `group` under its own id — so a `[path]` ref resolves through
7693
+ * `form.get()` on `getControl`'s first branch and names exactly one control.
7694
+ *
7695
+ * A field inside an `array` has no static path: its controls live in `FormArray` rows built
7696
+ * per row. `form.get()` returns null for those and they are left alone, which is correct —
7697
+ * a row's controls are created and destroyed with the row.
7698
+ */
7699
+ controlForEntry(entry) {
7700
+ return this.getControl(toRefToken(fieldRefFor(entry.scope, entry.field.id)));
7701
+ }
7253
7702
  flattenFormValues() {
7254
7703
  const out = {};
7255
7704
  for (const entry of collectFieldScopes(this.config)) {
@@ -7264,18 +7713,6 @@ class DynamicFormComponent {
7264
7713
  }
7265
7714
  return out;
7266
7715
  }
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
7716
  // ─── autoPatch / patchOnTrue ──────────────────────────────────────────────
7280
7717
  /**
7281
7718
  * `autoPatch` — copy mapped fields out of the record the user just selected in an
@@ -7317,65 +7754,48 @@ class DynamicFormComponent {
7317
7754
  }
7318
7755
  }
7319
7756
  }
7320
- /** Prefer a control declared on the configured target tab; fall back to a top-level control. */
7757
+ /**
7758
+ * Prefer a control declared on the configured target tab; fall back to a top-level control.
7759
+ *
7760
+ * "Declared on the tab" now means anywhere under it, sub-tabs and `group` children
7761
+ * included. It meant the tab's own top-level fields only, so an `autoPatch` mapping onto a
7762
+ * field inside a group resolved to `null` and copied nothing, silently.
7763
+ */
7321
7764
  resolveTargetControl(autoPatch, targetId) {
7322
7765
  const tab = findTab(this.tabs, autoPatch.targetTab);
7323
- const inTargetTab = (tab?.fields ?? []).some(f => f.id === targetId);
7324
- if (tab && !inTargetTab)
7766
+ if (!tab)
7767
+ return this.getControl(targetId, autoPatch.targetTab);
7768
+ const entry = fieldsUnderTab(this.config, autoPatch.targetTab).find(e => {
7769
+ if (!e.field?.id)
7770
+ return false;
7771
+ const parsed = parseFieldRef(targetId);
7772
+ return parsed.kind === 'ref'
7773
+ ? fieldRefFor(e.scope, e.field.id) === parsed.value
7774
+ : e.field.id === parsed.value;
7775
+ });
7776
+ if (!entry)
7325
7777
  return null;
7326
- return this.getControl(targetId, autoPatch.targetTab);
7778
+ return this.controlForEntry(entry);
7327
7779
  }
7328
7780
  // ─── Helpers ──────────────────────────────────────────────────────────────
7329
7781
  /**
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.
7782
+ * Keep a hidden field's control out of form validity — see
7783
+ * `RulesEvaluationService.syncHiddenFieldState`, which owns the rule and the reasoning.
7347
7784
  *
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.
7351
- *
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.
7785
+ * This supplies the three things it cannot know: which fields exist and in what order
7786
+ * (`collectFieldScopes`, parent-first), what each one is called, and which control each
7787
+ * one addresses.
7361
7788
  */
7362
7789
  syncHiddenFieldState(values) {
7363
7790
  if (this.preview)
7364
7791
  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
- }
7792
+ this.rulesEvaluation.syncHiddenFieldState(this.fieldScopes(), {
7793
+ result: this.ruleResult(),
7794
+ values,
7795
+ namesOf: field => this.namesOf(field),
7796
+ addressOf: field => this.addressOf(field),
7797
+ controlFor: entry => this.controlForEntry(entry),
7798
+ });
7379
7799
  }
7380
7800
  /** Every field in the config, including `group`/`array` children. */
7381
7801
  allFields() {
@@ -7403,51 +7823,31 @@ class DynamicFormComponent {
7403
7823
  return supplied;
7404
7824
  return this.sessionBaseline();
7405
7825
  }
7826
+ /**
7827
+ * Whether a critical field still holds what it held when the session started.
7828
+ *
7829
+ * The same question `VALUE_CHANGED` asks, so it is answered by the same comparator rather
7830
+ * than by a third one. The local version compared objects through `JSON.stringify`, which
7831
+ * is key-order sensitive — two serialisers writing the same option in a different key
7832
+ * order made the field read as edited — and had its own idea of which values count as
7833
+ * empty. `valuesEqual` is order-independent and treats `null` and `undefined` as the same
7834
+ * absence; the empty-string case is kept here, because a cleared text input is not an edit.
7835
+ */
7406
7836
  sameValue(a, b) {
7407
- if (a === b)
7408
- return true;
7409
7837
  const aEmpty = a === null || a === undefined || a === '';
7410
7838
  const bEmpty = b === null || b === undefined || b === '';
7411
7839
  if (aEmpty && bEmpty)
7412
7840
  return true;
7413
- if (typeof a === 'object' || typeof b === 'object') {
7414
- return JSON.stringify(a ?? null) === JSON.stringify(b ?? null);
7415
- }
7416
- return false;
7841
+ return valuesEqual(a, b);
7417
7842
  }
7843
+ /**
7844
+ * Find a control by a bracketed path or a bare field id.
7845
+ *
7846
+ * Public API, so it stays on the component and delegates — see `FormStructureService`,
7847
+ * which owns the resolution rule and is where the behaviour is documented.
7848
+ */
7418
7849
  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);
7850
+ return this.structure.getControl(this.form, this.config, fieldId, currentTabId);
7451
7851
  }
7452
7852
  /**
7453
7853
  * Move focus into the newly shown panel.
@@ -7547,11 +7947,11 @@ class DynamicFormComponent {
7547
7947
  this.formReset.emit();
7548
7948
  }
7549
7949
  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 });
7950
+ 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__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{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
7951
  }
7552
7952
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.22", ngImport: i0, type: DynamicFormComponent, decorators: [{
7553
7953
  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"] }]
7954
+ 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__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{grid-template-columns:minmax(0,1fr)}.ngx-form__field-slot{grid-column:1 / -1!important}}\n"] }]
7555
7955
  }], propDecorators: { config: [{
7556
7956
  type: Input
7557
7957
  }], rules: [{