ngx-dynamic-entity 1.13.0 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,8 +1,8 @@
1
1
  import * as i0 from '@angular/core';
2
2
  import { InjectionToken, makeEnvironmentProviders, inject, Injectable, signal, Input, ChangeDetectionStrategy, Component, ChangeDetectorRef, DestroyRef, Injector, isDevMode, ViewContainerRef, ViewChild, forwardRef, Optional, Inject, EventEmitter, ElementRef, computed, afterNextRender, HostListener, ViewChildren, Output } from '@angular/core';
3
3
  import * as i1 from '@angular/forms';
4
- import { ReactiveFormsModule, Validators, FormBuilder, FormArray, FormGroup } 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 } 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';
@@ -139,6 +139,46 @@ const COMMON_MODULES_REGISTRY = new InjectionToken('COMMON_MODULES_REGISTRY');
139
139
  * { provide: UPLOAD_HANDLER, useFactory: () => (file: File) => myUploadService.upload(file) }
140
140
  */
141
141
  const UPLOAD_HANDLER = new InjectionToken('UPLOAD_HANDLER');
142
+ /**
143
+ * Reads an uploaded spreadsheet into headers and positional rows, for the import wizard.
144
+ *
145
+ * Unregistered, the wizard reads CSV with the dependency-free parser in
146
+ * `@dynamic-entity/core`, so it works with nothing provided. Register one to accept `.xlsx`
147
+ * — which spreadsheet library that uses stays the consumer's choice, and this package keeps
148
+ * having no runtime dependency.
149
+ *
150
+ * Rows come back **positional** (`string[][]`) rather than keyed by header, because a real
151
+ * sheet has duplicate headers and blank ones; a header-keyed row silently loses the second
152
+ * of two columns both called "Notes".
153
+ *
154
+ * @example
155
+ * provideNgxDynamicEntity({
156
+ * sheetParser: async file => {
157
+ * const wb = XLSX.read(await file.arrayBuffer());
158
+ * const rows = XLSX.utils.sheet_to_json(wb.Sheets[wb.SheetNames[0]], {
159
+ * header: 1, raw: false, defval: '',
160
+ * }) as string[][];
161
+ * return { headers: rows[0] ?? [], rows: rows.slice(1) };
162
+ * },
163
+ * })
164
+ */
165
+ const SHEET_PARSER = new InjectionToken('SHEET_PARSER');
166
+ /**
167
+ * Where an import's work happens.
168
+ *
169
+ * Unregistered, everything runs in the browser through `@dynamic-entity/core` and nothing
170
+ * touches the network — the wizard is fully usable with no backend. Register one that posts
171
+ * to a server and the same components drive that instead, over the same pure mapping engine,
172
+ * which is what keeps a client-side import and a server-side one from disagreeing about what
173
+ * they produced.
174
+ *
175
+ * The reason to register one is size: the default reads the whole file into memory, which is
176
+ * right for a sheet somebody assembled and wrong for a fifty-thousand-row export.
177
+ *
178
+ * @example
179
+ * { provide: IMPORT_TRANSPORT, useClass: HttpImportTransport }
180
+ */
181
+ const IMPORT_TRANSPORT = new InjectionToken('IMPORT_TRANSPORT');
142
182
  /**
143
183
  * Backing store for the entity-reference options cache. Defaults to in-memory.
144
184
  * Provide your own to make the cache outlive a page refresh.
@@ -252,6 +292,10 @@ const provideNgxDynamicEntity = (config = {}) => makeEnvironmentProviders([
252
292
  { provide: RECORD_MIGRATIONS, useValue: config.migrations ?? [] },
253
293
  { provide: VALIDATION_MESSAGES, useValue: config.validationMessages ?? {} },
254
294
  { provide: UI_TEXT, useValue: config.uiText ?? {} },
295
+ // Both optional and both null when unset: the wizard falls back to the built-in CSV
296
+ // parser and the in-browser transport, so it works with nothing registered.
297
+ { provide: SHEET_PARSER, useValue: config.sheetParser ?? null },
298
+ { provide: IMPORT_TRANSPORT, useValue: config.importTransport ?? null },
255
299
  ]);
256
300
 
257
301
  /** The built-in English messages. Every key here can be overridden individually. */
@@ -399,7 +443,7 @@ function fieldDomId(field, instance, suffix = '') {
399
443
  * is read first. `null` rather than an empty string when there is nothing to describe — an
400
444
  * empty `aria-describedby` is a dangling reference, not an absent one.
401
445
  *
402
- * 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
403
447
  * the same everywhere and getting it wrong is invisible until somebody uses a screen reader.
404
448
  */
405
449
  function fieldDescribedBy(domId, parts) {
@@ -837,6 +881,49 @@ const DEFAULT_UI_TEXT = {
837
881
  criticalFieldUnlockHint: 'Critical field — click to unlock for editing',
838
882
  dismissBanner: 'Dismiss: {message}',
839
883
  jumpToField: 'Click to jump to {field}',
884
+ // Import wizard — the steps, in order
885
+ importUpload: 'Choose a file',
886
+ importMap: 'Match columns',
887
+ importReview: 'Review',
888
+ importDone: 'Done',
889
+ // Import wizard — choosing a file
890
+ importChooseFile: 'Choose a spreadsheet',
891
+ importFileChosen: '{name} — {rows} row(s)',
892
+ importReading: 'Reading the file…',
893
+ importNeedFile: 'Choose a file to continue.',
894
+ // Import wizard — the template a user can start from
895
+ importTemplateHeading: 'Start from a template',
896
+ importTemplateExplain: 'Download a sheet with the right columns, fill it in, and upload it.',
897
+ importTemplateDownload: 'Download template',
898
+ importTemplateSelectAll: 'Select all',
899
+ importTemplateSelectNone: 'Select none',
900
+ importTemplateUnsupported: '{count} field(s) cannot be carried by a spreadsheet.',
901
+ // Import wizard — matching columns
902
+ importColumnInFile: 'Column in your file',
903
+ importTargetField: 'Field',
904
+ importUnmapped: 'Not imported',
905
+ importGuessed: 'Guessed',
906
+ importMappedCount: '{mapped} of {total} field(s) matched.',
907
+ importRequiredUnmapped: '{count} required field(s) still need a column.',
908
+ // Import wizard — review and result
909
+ importPreviewHeading: 'First {count} row(s), as they will be saved',
910
+ importRowRejected: 'This row will not be imported: {reason}',
911
+ importRunImport: 'Import {count} row(s)',
912
+ importImporting: 'Importing…',
913
+ importSucceeded: 'Imported {count} record(s).',
914
+ importSkippedRows: '{count} blank row(s) skipped.',
915
+ importFailedRows: '{count} row(s) could not be imported.',
916
+ // A streaming transport caps what it retains — fifty thousand error objects is the same
917
+ // unbounded growth as holding the file — so the count and the sample are different numbers
918
+ // and the user is told which they are looking at.
919
+ importErrorsTruncated: 'Showing the first {shown} of {count} problem(s).',
920
+ importStartOver: 'Import another file',
921
+ // Import wizard — what went wrong
922
+ importErrorsHeading: 'Rows that need attention',
923
+ importErrorRow: 'Row {row}',
924
+ importErrorField: 'Field',
925
+ importErrorReason: 'Reason',
926
+ importPlanProblem: 'This mapping cannot be used: {message}',
840
927
  // Banners and whole-record states
841
928
  entityLabel: 'Entity: {entity}',
842
929
  criticalFieldChanged: '🔒 Critical field changed: {fields} — this differs from the value at the start of this session.',
@@ -1013,17 +1100,29 @@ class MarkdownFieldComponent {
1013
1100
  * A renderer is consumer code and may throw on input it does not like. Letting that
1014
1101
  * escape would take down the whole form over one malformed field, so it falls back to the
1015
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.
1016
1109
  */
1110
+ cached;
1017
1111
  rendered() {
1018
1112
  const source = this.control?.value;
1019
1113
  if (!this.render || typeof source !== 'string' || source === '')
1020
1114
  return null;
1115
+ if (this.cached?.source === source)
1116
+ return this.cached.html;
1117
+ let html;
1021
1118
  try {
1022
- return this.render(source);
1119
+ html = this.render(source);
1023
1120
  }
1024
1121
  catch {
1025
- return null;
1122
+ html = null;
1026
1123
  }
1124
+ this.cached = { source, html };
1125
+ return html;
1027
1126
  }
1028
1127
  /**
1029
1128
  * Resolved through `ValidationMessagesService`, so `provideNgxDynamicEntity({
@@ -3491,8 +3590,7 @@ class DropdownFieldComponent {
3491
3590
  options = signal([], ...(ngDevMode ? [{ debugName: "options" }] : /* istanbul ignore next */ []));
3492
3591
  compareFn = (o1, o2) => valuesMatch(o1, o2, this.language);
3493
3592
  isObjectVal(option) {
3494
- const val = getOptionStoredValue(option);
3495
- return typeof val === 'object' && val !== null;
3593
+ return typeof option === 'object' && option !== null;
3496
3594
  }
3497
3595
  get label() {
3498
3596
  return resolveLabel(this.field?.label, this.language);
@@ -3500,8 +3598,17 @@ class DropdownFieldComponent {
3500
3598
  get placeholder() {
3501
3599
  return resolveLabel(this.field?.placeholder, this.language);
3502
3600
  }
3601
+ /**
3602
+ * The value this option stores — the option object itself.
3603
+ *
3604
+ * Kept as a method rather than binding `option` straight into the template, because it is
3605
+ * the one place that says *why* the two are the same thing. It used to delegate to
3606
+ * `getOptionStoredValue`, an exported identity function whose two branches returned their
3607
+ * argument unchanged; naming a transformation that does not happen is worse than naming
3608
+ * nothing, because a reader has to go and check.
3609
+ */
3503
3610
  getOptStoredVal(option) {
3504
- return getOptionStoredValue(option);
3611
+ return option;
3505
3612
  }
3506
3613
  getOptLabel(option) {
3507
3614
  return resolveOptionLabel(option, this.language);
@@ -3514,7 +3621,7 @@ class DropdownFieldComponent {
3514
3621
  getLabel(value) {
3515
3622
  if (value == null || value === '')
3516
3623
  return '—';
3517
- const option = this.options().find(o => valuesMatch(getOptionStoredValue(o), value, this.language));
3624
+ const option = this.options().find(o => valuesMatch(o, value, this.language));
3518
3625
  if (option)
3519
3626
  return this.getOptLabel(option);
3520
3627
  const cached = this.lookups.labelFor(this.field?.listName, value, this.language);
@@ -3736,8 +3843,17 @@ class RadioFieldComponent {
3736
3843
  get label() {
3737
3844
  return resolveLabel(this.field?.label, this.language);
3738
3845
  }
3846
+ /**
3847
+ * The value this option stores — the option object itself.
3848
+ *
3849
+ * Kept as a method rather than binding `option` straight into the template, because it is
3850
+ * the one place that says *why* the two are the same thing. It used to delegate to
3851
+ * `getOptionStoredValue`, an exported identity function whose two branches returned their
3852
+ * argument unchanged; naming a transformation that does not happen is worse than naming
3853
+ * nothing, because a reader has to go and check.
3854
+ */
3739
3855
  getOptStoredVal(option) {
3740
- return getOptionStoredValue(option);
3856
+ return option;
3741
3857
  }
3742
3858
  /**
3743
3859
  * Stable DOM id for one radio, so the <label for> association works.
@@ -3772,7 +3888,7 @@ class RadioFieldComponent {
3772
3888
  // `resolveLabel(null)` and rendered as blank — dropdown and multiSelect both show an em dash.
3773
3889
  if (value === null || value === undefined || value === '')
3774
3890
  return '—';
3775
- const selected = this.options().find(o => valuesMatch(getOptionStoredValue(o), value, this.language));
3891
+ const selected = this.options().find(o => valuesMatch(o, value, this.language));
3776
3892
  if (selected)
3777
3893
  return this.getOptLabel(selected);
3778
3894
  const cached = this.lookups.labelFor(this.field?.listName, value, this.language);
@@ -3980,14 +4096,22 @@ class MultiSelectFieldComponent {
3980
4096
  options = signal([], ...(ngDevMode ? [{ debugName: "options" }] : /* istanbul ignore next */ []));
3981
4097
  compareFn = (o1, o2) => valuesMatch(o1, o2, this.language);
3982
4098
  isObjectVal(option) {
3983
- const val = getOptionStoredValue(option);
3984
- return typeof val === 'object' && val !== null;
4099
+ return typeof option === 'object' && option !== null;
3985
4100
  }
3986
4101
  get label() {
3987
4102
  return resolveLabel(this.field?.label, this.language);
3988
4103
  }
4104
+ /**
4105
+ * The value this option stores — the option object itself.
4106
+ *
4107
+ * Kept as a method rather than binding `option` straight into the template, because it is
4108
+ * the one place that says *why* the two are the same thing. It used to delegate to
4109
+ * `getOptionStoredValue`, an exported identity function whose two branches returned their
4110
+ * argument unchanged; naming a transformation that does not happen is worse than naming
4111
+ * nothing, because a reader has to go and check.
4112
+ */
3989
4113
  getOptStoredVal(option) {
3990
- return getOptionStoredValue(option);
4114
+ return option;
3991
4115
  }
3992
4116
  getOptLabel(option) {
3993
4117
  return resolveOptionLabel(option, this.language);
@@ -3998,7 +4122,7 @@ class MultiSelectFieldComponent {
3998
4122
  return '—';
3999
4123
  return values
4000
4124
  .map(v => {
4001
- const opt = this.options().find(o => valuesMatch(getOptionStoredValue(o), v, this.language));
4125
+ const opt = this.options().find(o => valuesMatch(o, v, this.language));
4002
4126
  if (opt)
4003
4127
  return this.getOptLabel(opt);
4004
4128
  const cached = this.lookups.labelFor(this.field?.listName, v, this.language);
@@ -4790,7 +4914,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.22", ngImpo
4790
4914
  *
4791
4915
  * **Tree-shaking contract:** this service holds *no* static imports of field components.
4792
4916
  * Nothing is bundled until something registers it, which is what makes the built-ins
4793
- * opt-in. Register them with `provideBuiltInFieldTypes()` (all 19 keys) or a narrower
4917
+ * opt-in. Register them with `provideBuiltInFieldTypes()` (all 21 keys) or a narrower
4794
4918
  * subset via `provideFieldTypes({ ... })` — see `providers/provide-field-types.ts`.
4795
4919
  *
4796
4920
  * Resolution order: consumer token registry → runtime registrations → nothing.
@@ -5199,8 +5323,20 @@ class ValidatorRegistryService {
5199
5323
  fnList.push(Validators.minLength(config.minLength));
5200
5324
  if (config.maxLength !== undefined)
5201
5325
  fnList.push(Validators.maxLength(config.maxLength));
5202
- if (config.pattern)
5203
- fnList.push(Validators.pattern(config.pattern));
5326
+ if (config.pattern) {
5327
+ // `Validators.pattern` compiles the string, so an unparseable one threw here — during
5328
+ // control construction, which takes the whole form down rather than one field's format
5329
+ // check. `validateConfig` reports it as an error; at runtime the field degrades to no
5330
+ // pattern validation, which is exactly what the server-side import path already does.
5331
+ try {
5332
+ fnList.push(Validators.pattern(config.pattern));
5333
+ }
5334
+ catch (err) {
5335
+ this.warnOnce(`pattern:${config.pattern}`, `The pattern "${config.pattern}" is not a valid regular expression ` +
5336
+ `(${err instanceof Error ? err.message : String(err)}), so no format check is ` +
5337
+ `applied to this field. Run "dynamic-entity validate" over the config to find it.`);
5338
+ }
5339
+ }
5204
5340
  if (config.email)
5205
5341
  fnList.push(Validators.email);
5206
5342
  // Named validators from the consumer registry. This branch previously hardcoded the
@@ -5211,6 +5347,8 @@ class ValidatorRegistryService {
5211
5347
  const fn = this.resolve(key);
5212
5348
  if (fn)
5213
5349
  fnList.push(fn);
5350
+ else
5351
+ this.warnUnresolved('custom', key);
5214
5352
  }
5215
5353
  return fnList;
5216
5354
  }
@@ -5232,9 +5370,35 @@ class ValidatorRegistryService {
5232
5370
  const fn = this.asyncRegistry.get(key);
5233
5371
  if (fn)
5234
5372
  out.push(fn);
5373
+ else
5374
+ this.warnUnresolved('customAsync', key);
5235
5375
  }
5236
5376
  return out;
5237
5377
  }
5378
+ /**
5379
+ * Say when a named validator resolved to nothing.
5380
+ *
5381
+ * It used to be dropped in silence, which is the worst failure mode this library has: the
5382
+ * schema says the field is checked, the field is not checked, the form saves, and nothing
5383
+ * anywhere said so. For `customAsync` that is typically a uniqueness check, so the duplicate
5384
+ * lands in the database.
5385
+ *
5386
+ * Dev only, once per name — this runs while controls are built, which happens on every
5387
+ * config change. `validateConfig` with `knownValidators` is the build-time version of the
5388
+ * same check, and it fails CI rather than a console.
5389
+ */
5390
+ warnUnresolved(kind, key) {
5391
+ this.warnOnce(`${kind}:${key}`, `No validator named "${key}" is registered, so validators.${kind} drops it and it never ` +
5392
+ `runs. Register it with provideNgxDynamicEntity({ ${kind === 'customAsync' ? 'asyncValidators' : 'validators'}: { '${key}': ... } }).`);
5393
+ }
5394
+ /** Per service instance, which is per injector — so a test gets a clean slate. */
5395
+ warned = new Set();
5396
+ warnOnce(id, message) {
5397
+ if (!isDevMode() || this.warned.has(id))
5398
+ return;
5399
+ this.warned.add(id);
5400
+ console.warn(`[ngx-dynamic-entity] ${message}`);
5401
+ }
5238
5402
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.22", ngImport: i0, type: ValidatorRegistryService, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
5239
5403
  static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "21.2.22", ngImport: i0, type: ValidatorRegistryService, providedIn: 'root' });
5240
5404
  }
@@ -6072,7 +6236,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.22", ngImpo
6072
6236
  *
6073
6237
  * `FieldRegistryService` imports no components. Whatever you register here is what gets
6074
6238
  * bundled, so an app that only uses text/number/dropdown pays for three components and
6075
- * their imports, not nineteen.
6239
+ * their imports, not twenty-one.
6076
6240
  *
6077
6241
  * ```ts
6078
6242
  * // Everything (convenient; bundles all built-ins):
@@ -6116,7 +6280,7 @@ function provideFieldTypes(types) {
6116
6280
  { provide: FIELD_TYPE_SETS, useValue: types, multi: true },
6117
6281
  ]);
6118
6282
  }
6119
- /** Register all 19 built-in field type keys. Convenience for apps that use most of them. */
6283
+ /** Register all 21 built-in field type keys. Convenience for apps that use most of them. */
6120
6284
  function provideBuiltInFieldTypes() {
6121
6285
  return provideFieldTypes(builtInFieldTypes());
6122
6286
  }
@@ -6145,14 +6309,24 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.22", ngImpo
6145
6309
  }] });
6146
6310
 
6147
6311
  /**
6148
- * RulesEvaluationService — evaluates dynamic form rules against active reactive form state.
6312
+ * RulesEvaluationService — evaluates rules, and owns what a result *means*.
6313
+ *
6314
+ * It used to be two passthrough methods, with the interpretation spread across the component:
6315
+ * the render filter decided one way what a rule result did to a field, and the validity sync
6316
+ * decided again forty lines further down. They disagreed about what "every field" meant, and
6317
+ * the consequence was a required field inside a `group` that a rule had hidden holding
6318
+ * `form.invalid` true forever with nothing on screen to explain it.
6319
+ *
6320
+ * Precedence lives here now, in one method that both callers go through. Still stateless —
6321
+ * every method takes the result it is interpreting, so there is nothing to go stale against a
6322
+ * rebuilt form and nothing to reset between configs.
6149
6323
  */
6150
6324
  class RulesEvaluationService {
6151
6325
  /**
6152
6326
  * Evaluate rules against form values and optional original baseline values.
6153
6327
  */
6154
- evaluate(rules, values, originalValues) {
6155
- return evaluateFormRules(rules, values, originalValues);
6328
+ evaluate(rules, values, originalValues, options) {
6329
+ return evaluateFormRules(rules, values, originalValues, options);
6156
6330
  }
6157
6331
  /**
6158
6332
  * Filter rules pertinent to a single tab.
@@ -6160,6 +6334,89 @@ class RulesEvaluationService {
6160
6334
  filterForTab(rules, tabId, config) {
6161
6335
  return filterRulesForTab(rules, tabId, config);
6162
6336
  }
6337
+ /**
6338
+ * Whether a field renders, under the precedence documented on `RuleEvaluationResult`.
6339
+ *
6340
+ * An explicit hide beats everything. A show beats static hiding — `visibility: false`, or a
6341
+ * `showWhen` that does not currently hold — because a rule that could only hide what the
6342
+ * config already hides would have nothing to say, and `{ visibility: true }` was a
6343
+ * documented no-op until it did. Otherwise the config's own visibility decides.
6344
+ */
6345
+ isFieldVisible(result, field, names, values) {
6346
+ const sets = this.nameSets(result);
6347
+ if (names.some(name => sets.hidden.has(name)))
6348
+ return false;
6349
+ if (names.some(name => sets.shown.has(name)))
6350
+ return true;
6351
+ return evaluateFieldVisibility(field, values);
6352
+ }
6353
+ /** The same precedence for a tab. */
6354
+ isTabVisible(result, tab) {
6355
+ if (result.hiddenTabs.includes(tab.id))
6356
+ return false;
6357
+ return tab.visibility !== false || result.shownTabs.includes(tab.id);
6358
+ }
6359
+ /**
6360
+ * Keep a hidden field's control out of form validity.
6361
+ *
6362
+ * A field hidden by a rule or a `showWhen` is filtered out of the render, but its
6363
+ * validators stay attached — so a hidden *required* field holds `form.invalid` true
6364
+ * forever, permanently disabling Save with nothing on screen to explain why.
6365
+ *
6366
+ * Disabling is the fix rather than stripping validators: Angular excludes disabled controls
6367
+ * from validity, and the field's own validators survive intact for when it comes back.
6368
+ * Values are untouched — a disabled control still carries its value, so rule evaluation and
6369
+ * the emitted record see exactly what they saw before. That also keeps this from feeding
6370
+ * back on itself: hiding a field cannot change the values the rules are evaluated over.
6371
+ *
6372
+ * The predicate is `isFieldVisible`, the same one the render filter uses, so what is on
6373
+ * screen and what counts toward validity cannot drift apart.
6374
+ *
6375
+ * Entries must arrive parent-first, which is what `collectFieldScopes` yields: a container's
6376
+ * children are skipped once the container itself is hidden, because Angular's `disable()`
6377
+ * cascades to descendants and a child's `enable()` walks back up recalculating ancestors.
6378
+ * Toggling a child inside a hidden group would fight its parent.
6379
+ */
6380
+ syncHiddenFieldState(entries, ctx) {
6381
+ const hiddenContainers = [];
6382
+ for (const entry of entries) {
6383
+ const field = entry.field;
6384
+ if (!field?.id)
6385
+ continue;
6386
+ const address = ctx.addressOf(field);
6387
+ if (hiddenContainers.some(prefix => address.startsWith(`${prefix}.`)))
6388
+ continue;
6389
+ const hidden = !this.isFieldVisible(ctx.result, field, ctx.namesOf(field), ctx.values);
6390
+ if (hidden && (field.type === 'group' || field.type === 'array'))
6391
+ hiddenContainers.push(address);
6392
+ const ctrl = ctx.controlFor(entry);
6393
+ if (!ctrl)
6394
+ continue;
6395
+ if (hidden) {
6396
+ if (ctrl.enabled)
6397
+ ctrl.disable({ emitEvent: false });
6398
+ }
6399
+ else if (ctrl.disabled && !field.disabled) {
6400
+ ctrl.enable({ emitEvent: false });
6401
+ }
6402
+ }
6403
+ }
6404
+ /**
6405
+ * The hidden and shown names as sets, memoised against the result they came from.
6406
+ *
6407
+ * `isFieldVisible` runs once per field per change-detection pass; building two sets from
6408
+ * two arrays each time turns a rule result into allocation proportional to the form. A
6409
+ * `WeakMap` keyed by the result means it is built once per evaluation and collected with it.
6410
+ */
6411
+ nameSets(result) {
6412
+ let sets = this.nameSetCache.get(result);
6413
+ if (!sets) {
6414
+ sets = { hidden: new Set(result.hiddenFields), shown: new Set(result.shownFields) };
6415
+ this.nameSetCache.set(result, sets);
6416
+ }
6417
+ return sets;
6418
+ }
6419
+ nameSetCache = new WeakMap();
6163
6420
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.22", ngImport: i0, type: RulesEvaluationService, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
6164
6421
  static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "21.2.22", ngImport: i0, type: RulesEvaluationService, providedIn: 'root' });
6165
6422
  }
@@ -6231,6 +6488,255 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.22", ngImpo
6231
6488
  args: [CONFIG_SOURCE]
6232
6489
  }] }] });
6233
6490
 
6491
+ /**
6492
+ * FormStructureService — config ⇄ `FormGroup`, and nothing else.
6493
+ *
6494
+ * The one question this answers is *where a value lives*: which control a field owns, how
6495
+ * the tab tree nests into groups, how a record is read out of the tree and patched back in.
6496
+ * It was half of `DynamicFormComponent`, mixed in with tab state, focus management, rule
6497
+ * evaluation and RBAC — and the cost of that was not length but ownership: the bug Phase 2
6498
+ * fixed was two methods forty lines apart disagreeing about what "every field" meant.
6499
+ *
6500
+ * Deliberately stateless. Every method takes the form and the config it is operating on, so
6501
+ * there is no cached `this.form` to go stale against a rebuilt one, and every method is
6502
+ * testable without a component or a TestBed.
6503
+ *
6504
+ * Provided at the component, not the root: `FormBuilder` and the validator registry are the
6505
+ * only things injected, but keeping the service per form leaves room for that to change
6506
+ * without turning into shared mutable state.
6507
+ */
6508
+ class FormStructureService {
6509
+ fb = inject(FormBuilder);
6510
+ validatorRegistry = inject(ValidatorRegistryService);
6511
+ /**
6512
+ * Build the control tree for a config.
6513
+ *
6514
+ * The tree mirrors the record's shape exactly: a tab opens a `FormGroup` under its id, a
6515
+ * `flatData` tab merges into its parent's, and a `group` field opens one for its children.
6516
+ * That correspondence is what lets a `[path]` ref resolve through `form.get()` and name
6517
+ * exactly one control — see `getControl`.
6518
+ */
6519
+ buildForm(config) {
6520
+ const group = {};
6521
+ const buildTabControls = (tabs, parentGroup) => {
6522
+ for (const tab of tabs) {
6523
+ if (tab.flatData) {
6524
+ for (const field of tab.fields || []) {
6525
+ this.buildFieldControl(field, parentGroup);
6526
+ }
6527
+ if (tab.children)
6528
+ buildTabControls(tab.children, parentGroup);
6529
+ }
6530
+ else {
6531
+ const tabGroup = {};
6532
+ for (const field of tab.fields || []) {
6533
+ this.buildFieldControl(field, tabGroup);
6534
+ }
6535
+ if (tab.children)
6536
+ buildTabControls(tab.children, tabGroup);
6537
+ parentGroup[tab.id] = this.fb.group(tabGroup);
6538
+ }
6539
+ }
6540
+ };
6541
+ buildTabControls(config.tabs || [], group);
6542
+ return this.fb.group(group);
6543
+ }
6544
+ /** One field's control: a nested group, an empty array, or a leaf carrying its validators. */
6545
+ buildFieldControl(field, group) {
6546
+ if (field.type === 'group') {
6547
+ const subGroup = {};
6548
+ for (const child of field.children || []) {
6549
+ this.buildFieldControl(child, subGroup);
6550
+ }
6551
+ group[field.id] = this.fb.group(subGroup);
6552
+ }
6553
+ else if (field.type === 'array') {
6554
+ group[field.id] = this.fb.array([]);
6555
+ }
6556
+ else {
6557
+ const validators = this.validatorRegistry.resolveFromConfig(field.validators);
6558
+ const asyncValidators = this.validatorRegistry.resolveAsyncFromConfig(field.validators);
6559
+ group[field.id] = this.fb.control({ value: field.defaultValue ?? null, disabled: field.disabled ?? false }, validators, asyncValidators);
6560
+ }
6561
+ }
6562
+ /** One row of an `array` field: a FormGroup when the field declares columns, else a bare control. */
6563
+ buildArrayRow(field, item) {
6564
+ if (!field?.children?.length)
6565
+ return this.fb.control(item);
6566
+ const rowGroup = {};
6567
+ for (const child of field.children) {
6568
+ this.buildFieldControl(child, rowGroup);
6569
+ }
6570
+ const group = this.fb.group(rowGroup);
6571
+ if (item && typeof item === 'object')
6572
+ group.patchValue(item);
6573
+ return group;
6574
+ }
6575
+ /** The `FormGroup` a tab's fields live in, honouring `flatData`. */
6576
+ getTabGroup(form, config, tabId) {
6577
+ if (!form)
6578
+ return null;
6579
+ const path = getTabPath(config?.tabs, tabId);
6580
+ if (!path || path.length === 0)
6581
+ return form;
6582
+ let curr = form;
6583
+ for (const p of path) {
6584
+ if (!curr || !(curr instanceof FormGroup))
6585
+ return null;
6586
+ curr = curr.get(p);
6587
+ }
6588
+ return curr instanceof FormGroup ? curr : null;
6589
+ }
6590
+ /**
6591
+ * Find a control by reference: a bracketed path, or a bare field id.
6592
+ *
6593
+ * A bracketed path names exactly one control, because the tree nests exactly as the path
6594
+ * does — so `form.get('work.address.city')` walks straight to it. A bare id is what every
6595
+ * config written before paths existed uses: it is looked for on `currentTabId` first, then
6596
+ * at the root, then by a recursive search whose result is whichever the walk reaches first.
6597
+ * That last branch is the ambiguity refs exist to remove, and callers that can name a path
6598
+ * should.
6599
+ */
6600
+ getControl(form, config, fieldId, currentTabId) {
6601
+ if (!form)
6602
+ return null;
6603
+ const parsed = parseFieldRef(fieldId);
6604
+ if (parsed.kind === 'ref')
6605
+ return form.get(parsed.value);
6606
+ if (currentTabId) {
6607
+ const tabGrp = this.getTabGroup(form, config, currentTabId);
6608
+ const ctrl = tabGrp?.get(fieldId);
6609
+ if (ctrl)
6610
+ return ctrl;
6611
+ }
6612
+ const rootCtrl = form.get(fieldId);
6613
+ if (rootCtrl)
6614
+ return rootCtrl;
6615
+ const findInGroup = (group) => {
6616
+ for (const key of Object.keys(group.controls)) {
6617
+ const c = group.controls[key];
6618
+ if (key === fieldId)
6619
+ return c;
6620
+ if (c instanceof FormGroup) {
6621
+ const found = findInGroup(c);
6622
+ if (found)
6623
+ return found;
6624
+ }
6625
+ }
6626
+ return null;
6627
+ };
6628
+ return findInGroup(form);
6629
+ }
6630
+ /** Assemble the full nested record from per-tab groups, respecting flatData, refererField & arrays. */
6631
+ extractRecord(form, config) {
6632
+ if (!form || !config)
6633
+ return {};
6634
+ let record = {};
6635
+ const walkTabs = (tabs) => {
6636
+ for (const tab of tabs) {
6637
+ const fieldValBag = {};
6638
+ for (const field of tab.fields || []) {
6639
+ const ctrl = this.getControl(form, config, field.id, tab.id);
6640
+ if (ctrl)
6641
+ fieldValBag[field.id] = ctrl.value;
6642
+ }
6643
+ setTabData(record, tab.id, fieldValBag, config);
6644
+ for (const field of tab.fields || []) {
6645
+ if (field.refererField) {
6646
+ const ctrl = this.getControl(form, config, field.id, tab.id);
6647
+ if (ctrl)
6648
+ setValueByPath(record, field.refererField, ctrl.value);
6649
+ }
6650
+ }
6651
+ if (tab.children)
6652
+ walkTabs(tab.children);
6653
+ }
6654
+ };
6655
+ walkTabs(config.tabs || []);
6656
+ record = normalizeArrayStructures(record, config);
6657
+ return record;
6658
+ }
6659
+ /**
6660
+ * Write a record into the control tree.
6661
+ *
6662
+ * Returns the top-level keys that named a known field and were *not* consumed, so the
6663
+ * caller can say so — see `DynamicFormComponent.warnUnconsumedInitialData`. Reporting
6664
+ * rather than logging keeps the "warn once per form" bookkeeping with the form, and keeps
6665
+ * this service free of console output.
6666
+ */
6667
+ patchForm(form, config, data, fieldsById) {
6668
+ if (!data || !form || !config)
6669
+ return [];
6670
+ const patchedFieldIds = new Set();
6671
+ const walkTabs = (tabs) => {
6672
+ for (const tab of tabs) {
6673
+ const tabData = getTabData(tab.id, data, config);
6674
+ for (const field of tab.fields || []) {
6675
+ let val = tabData && typeof tabData === 'object' ? tabData[field.id] : undefined;
6676
+ if (field.refererField) {
6677
+ const refVal = getValueByPath(data, field.refererField);
6678
+ if (refVal !== undefined)
6679
+ val = refVal;
6680
+ }
6681
+ if (val === undefined)
6682
+ continue;
6683
+ const ctrl = this.getControl(form, config, field.id, tab.id);
6684
+ if (!ctrl)
6685
+ continue;
6686
+ if (ctrl instanceof FormArray && Array.isArray(val)) {
6687
+ ctrl.clear();
6688
+ for (const item of val) {
6689
+ ctrl.push(this.buildArrayRow(fieldsById.get(field.id), item));
6690
+ }
6691
+ }
6692
+ else {
6693
+ ctrl.patchValue(val, { emitEvent: false });
6694
+ }
6695
+ patchedFieldIds.add(field.id);
6696
+ }
6697
+ if (tab.children)
6698
+ walkTabs(tab.children);
6699
+ }
6700
+ };
6701
+ walkTabs(config.tabs || []);
6702
+ return this.unconsumedKeys(config, data, fieldsById, patchedFieldIds);
6703
+ }
6704
+ /**
6705
+ * Top-level keys that name a field but reached no control.
6706
+ *
6707
+ * A record is nested by tab id (`{ tabId: { fieldId: value } }`) unless the tab sets
6708
+ * `flatData: true`. Handing a flat record to a nested tab finds nothing, so the fields stay
6709
+ * empty — with no error and no clue.
6710
+ *
6711
+ * Only keys matching a known field id count: anything else is the consumer's own record
6712
+ * metadata (ids, timestamps, `_configVersion`) and is not our business.
6713
+ */
6714
+ unconsumedKeys(config, data, fieldsById, patchedFieldIds) {
6715
+ if (!isDevMode())
6716
+ return [];
6717
+ const tabIds = new Set();
6718
+ const collectTabIds = (tabs) => {
6719
+ for (const tab of tabs) {
6720
+ tabIds.add(tab.id);
6721
+ if (tab.children)
6722
+ collectTabIds(tab.children);
6723
+ }
6724
+ };
6725
+ collectTabIds(config.tabs || []);
6726
+ return Object.keys(data).filter(key =>
6727
+ // A tab id at the root is the nested container, not a stray field.
6728
+ !tabIds.has(key) &&
6729
+ data[key] !== undefined &&
6730
+ fieldsById.has(key) &&
6731
+ !patchedFieldIds.has(key));
6732
+ }
6733
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.22", ngImport: i0, type: FormStructureService, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
6734
+ static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "21.2.22", ngImport: i0, type: FormStructureService });
6735
+ }
6736
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.22", ngImport: i0, type: FormStructureService, decorators: [{
6737
+ type: Injectable
6738
+ }] });
6739
+
6234
6740
  /**
6235
6741
  * Grid width per field type, used only under `layout="auto"` and only where the field itself
6236
6742
  * names no `colSpan`.
@@ -6350,6 +6856,8 @@ class DynamicFormComponent {
6350
6856
  hookRegistry = inject(HookRegistryService);
6351
6857
  rbacService = inject(RbacService);
6352
6858
  rulesEvaluation = inject(RulesEvaluationService);
6859
+ /** config ⇄ FormGroup: where a value lives. See `FormStructureService`. */
6860
+ structure = inject(FormStructureService);
6353
6861
  /** Resolves what is wrong with a field, for the error summary — see `resolveForField`. */
6354
6862
  messages = inject(ValidationMessagesService);
6355
6863
  entityRefSelection = inject(EntityRefSelectionService);
@@ -6388,12 +6896,29 @@ class DynamicFormComponent {
6388
6896
  // invalid form is what produces the error summary. Guarding here instead meant Ctrl+S
6389
6897
  // on an incomplete form did nothing at all — and unlike the button, a shortcut cannot
6390
6898
  // even look disabled.
6899
+ // `void`, not an `await`: a `@HostListener` cannot be async without changing what
6900
+ // Angular does with its return value, and `submit()` reports its own failures through
6901
+ // `saveRejected`. Nothing here would have anywhere to put a rejection.
6391
6902
  if (!this.readonly && this.canSubmit)
6392
- this.submit();
6903
+ void this.submit();
6393
6904
  }
6394
6905
  }
6395
6906
  // ─── Computed ─────────────────────────────────────────────────────────────
6396
- ruleResult = computed(() => this.rulesEvaluation.evaluate(this.rules, this.formValues(), this.baseline()), ...(ngDevMode ? [{ debugName: "ruleResult" }] : /* istanbul ignore next */ []));
6907
+ ruleResult = computed(() => this.rulesEvaluation.evaluate(this.rules, this.formValues(), this.baseline(), {
6908
+ // The engine reports rather than logs, because it also runs on a server. This is the
6909
+ // client's answer: say it once per distinct problem, in dev only. A rule the author
6910
+ // half-finished used to throw out of change detection; now it is skipped, and a
6911
+ // silently skipped rule is exactly as hard to diagnose as a thrown one.
6912
+ onProblem: message => this.warnOnce(message),
6913
+ }), ...(ngDevMode ? [{ debugName: "ruleResult" }] : /* istanbul ignore next */ []));
6914
+ /** Warned-about rule problems, per component instance — see `warnedAmbiguousIds`. */
6915
+ warnedProblems = new Set();
6916
+ warnOnce(message) {
6917
+ if (!isDevMode() || this.warnedProblems.has(message))
6918
+ return;
6919
+ this.warnedProblems.add(message);
6920
+ console.warn(`[ngx-dynamic-entity] ${message}`);
6921
+ }
6397
6922
  /**
6398
6923
  * Critical fields whose value differs from the session baseline.
6399
6924
  * These drive the deferred `VALUE_CHANGED` banner — a critical edit is announced once,
@@ -6410,8 +6935,10 @@ class DynamicFormComponent {
6410
6935
  return this.config?.tabs || [];
6411
6936
  }
6412
6937
  get visibleTabs() {
6413
- const hidden = this.ruleResult().hiddenTabs;
6414
- return this.tabs.filter(tab => tab.visibility !== false && !hidden.includes(tab.id));
6938
+ return this.tabs.filter(tab => this.isTabVisible(tab));
6939
+ }
6940
+ isTabVisible(tab) {
6941
+ return this.rulesEvaluation.isTabVisible(this.ruleResult(), tab);
6415
6942
  }
6416
6943
  get activeTabConfig() {
6417
6944
  const tabId = this.activeTab();
@@ -6423,8 +6950,7 @@ class DynamicFormComponent {
6423
6950
  const active = this.activeTabConfig;
6424
6951
  if (!active?.children || active.children.length === 0)
6425
6952
  return [];
6426
- const hidden = this.ruleResult().hiddenTabs;
6427
- return active.children.filter(tab => tab.visibility !== false && !hidden.includes(tab.id));
6953
+ return active.children.filter(tab => this.isTabVisible(tab));
6428
6954
  }
6429
6955
  get activeSubTabConfig() {
6430
6956
  const subTabs = this.visibleSubTabs;
@@ -6474,8 +7000,15 @@ class DynamicFormComponent {
6474
7000
  return [];
6475
7001
  const rawFields = active ? active.fields || [] : (this.config?.tabs || []).flatMap(t => t.fields || []);
6476
7002
  const currentValues = this.formValues();
6477
- const hiddenFields = new Set(this.ruleResult().hiddenFields);
6478
- return rawFields.filter(field => evaluateFieldVisibility(field, currentValues) && !this.namesField(hiddenFields, field));
7003
+ return rawFields.filter(field => this.isFieldVisible(field, currentValues));
7004
+ }
7005
+ /**
7006
+ * Whether a field renders. Precedence lives in `RulesEvaluationService`, which is also
7007
+ * what `syncHiddenFieldState` asks — so what is on screen and what counts toward validity
7008
+ * cannot drift apart.
7009
+ */
7010
+ isFieldVisible(field, values) {
7011
+ return this.rulesEvaluation.isFieldVisible(this.ruleResult(), field, this.namesOf(field), values);
6479
7012
  }
6480
7013
  /**
6481
7014
  * Entity-level permissions for the current roles.
@@ -6701,19 +7234,36 @@ class DynamicFormComponent {
6701
7234
  if (this.invalidFieldsCache)
6702
7235
  return this.invalidFieldsCache;
6703
7236
  const out = [];
6704
- const collect = (tab, parentId) => {
6705
- for (const field of tab.fields ?? []) {
6706
- const control = this.getControl(field.id, tab.id);
6707
- if (!control || control.disabled || control.valid || control.pending)
6708
- continue;
6709
- // The same message the field renders under itself — see `resolveForField`. Saying
6710
- // only *which* field is wrong leaves the user to go and look at each one; saying what
6711
- // is wrong with it is usually enough to fix it without leaving the summary.
6712
- const message = this.messages.resolveForField(control.errors, this.language, field.type);
6713
- out.push(parentId
6714
- ? { field, message, tabId: parentId, subTabId: tab.id }
6715
- : { field, message, tabId: tab.id });
6716
- }
7237
+ /*
7238
+ * Descends into `group` children rather than stopping at the container.
7239
+ *
7240
+ * A `FormGroup` is invalid whenever any descendant is, so reporting the container said
7241
+ * "Contacts is invalid" and left the user to open it and hunt. The offending leaf is
7242
+ * both the useful answer and the one `jumpToField` can actually move focus to.
7243
+ *
7244
+ * An `array` is reported as itself: its rows are built and destroyed at runtime, so
7245
+ * there is no configured child to name and nothing stable to jump to.
7246
+ */
7247
+ const collectField = (field, tab, parentId) => {
7248
+ const control = this.getControl(field.id, tab.id);
7249
+ if (!control || control.disabled || control.valid || control.pending)
7250
+ return;
7251
+ if (field.type === 'group' && field.children?.length) {
7252
+ for (const child of field.children)
7253
+ collectField(child, tab, parentId);
7254
+ return;
7255
+ }
7256
+ // The same message the field renders under itself — see `resolveForField`. Saying
7257
+ // only *which* field is wrong leaves the user to go and look at each one; saying what
7258
+ // is wrong with it is usually enough to fix it without leaving the summary.
7259
+ const message = this.messages.resolveForField(control.errors, this.language, field.type);
7260
+ out.push(parentId
7261
+ ? { field, message, tabId: parentId, subTabId: tab.id }
7262
+ : { field, message, tabId: tab.id });
7263
+ };
7264
+ const collect = (tab, parentId) => {
7265
+ for (const field of tab.fields ?? [])
7266
+ collectField(field, tab, parentId);
6717
7267
  for (const child of tab.children ?? [])
6718
7268
  collect(child, parentId ?? tab.id);
6719
7269
  };
@@ -6765,16 +7315,31 @@ class DynamicFormComponent {
6765
7315
  el.focus();
6766
7316
  }, { injector: this.injector });
6767
7317
  }
7318
+ /**
7319
+ * Which tab (and sub-tab) holds a field.
7320
+ *
7321
+ * Descends into `group`/`array` children: this compared top-level fields only, so "jump to
7322
+ * the first invalid field" found nothing for a nested one and silently did nothing. Only
7323
+ * sub-tabs one level deep are named, because that is what the tab strip can select —
7324
+ * anything deeper resolves to the ancestor it renders under.
7325
+ */
6768
7326
  locateField(fieldId) {
6769
- for (const tab of this.tabs) {
6770
- if ((tab.fields ?? []).some(f => f.id === fieldId))
6771
- return { tabId: tab.id };
6772
- for (const child of tab.children ?? []) {
6773
- if ((child.fields ?? []).some(f => f.id === fieldId))
6774
- return { tabId: tab.id, subTabId: child.id };
7327
+ const holds = (fields) => (fields ?? []).some(f => f?.id === fieldId || holds(f?.children));
7328
+ const search = (tabs, tabId, subTabId) => {
7329
+ for (const tab of tabs ?? []) {
7330
+ if (!tab?.id)
7331
+ continue;
7332
+ const at = tabId ?? tab.id;
7333
+ const sub = tabId ? (subTabId ?? tab.id) : undefined;
7334
+ if (holds(tab.fields))
7335
+ return sub ? { tabId: at, subTabId: sub } : { tabId: at };
7336
+ const found = search(tab.children, at, sub);
7337
+ if (found)
7338
+ return found;
6775
7339
  }
6776
- }
6777
- return null;
7340
+ return null;
7341
+ };
7342
+ return search(this.tabs);
6778
7343
  }
6779
7344
  // ─── criticalField locking ────────────────────────────────────────────────
6780
7345
  /** A critical field stays read-only until the user deliberately unlocks it. */
@@ -6834,29 +7399,9 @@ class DynamicFormComponent {
6834
7399
  buildForm() {
6835
7400
  if (!this.config)
6836
7401
  return;
6837
- const group = {};
6838
- const buildTabControls = (tabs, parentGroup) => {
6839
- for (const tab of tabs) {
6840
- if (tab.flatData) {
6841
- for (const field of tab.fields || []) {
6842
- this.buildFieldControl(field, parentGroup);
6843
- }
6844
- if (tab.children)
6845
- buildTabControls(tab.children, parentGroup);
6846
- }
6847
- else {
6848
- const tabGroup = {};
6849
- for (const field of tab.fields || []) {
6850
- this.buildFieldControl(field, tabGroup);
6851
- }
6852
- if (tab.children)
6853
- buildTabControls(tab.children, tabGroup);
6854
- parentGroup[tab.id] = this.fb.group(tabGroup);
6855
- }
6856
- }
6857
- };
6858
- buildTabControls(this.config.tabs || [], group);
6859
- this.form = this.fb.group(group);
7402
+ this.form = this.structure.buildForm(this.config);
7403
+ // A rebuild replaces every control, and the scope index is keyed by config identity.
7404
+ this.scopeCache = undefined;
6860
7405
  this.warnAmbiguousRuleReferences();
6861
7406
  if (this.initialData) {
6862
7407
  this.patchForm(this.upgradeRecord(this.initialData));
@@ -6902,24 +7447,6 @@ class DynamicFormComponent {
6902
7447
  this.formChange.emit(this.extractRecord());
6903
7448
  });
6904
7449
  }
6905
- buildFieldControl(field, group) {
6906
- if (field.type === 'group') {
6907
- const subGroup = {};
6908
- for (const child of field.children || []) {
6909
- this.buildFieldControl(child, subGroup);
6910
- }
6911
- group[field.id] = this.fb.group(subGroup);
6912
- }
6913
- else if (field.type === 'array') {
6914
- group[field.id] = this.fb.array([]);
6915
- }
6916
- else {
6917
- const validators = this.validatorRegistry.resolveFromConfig(field.validators);
6918
- const asyncValidators = this.validatorRegistry.resolveAsyncFromConfig(field.validators);
6919
- group[field.id] = this.fb.control({ value: field.defaultValue ?? null, disabled: field.disabled ?? false }, validators, asyncValidators);
6920
- }
6921
- }
6922
- /** One row of an `array` field: a FormGroup when the field declares columns, else a bare control. */
6923
7450
  /**
6924
7451
  * The `FormArray` behind an `array` field, for callers that manage rows themselves —
6925
7452
  * the record editor's inline row drawer.
@@ -6936,16 +7463,7 @@ class DynamicFormComponent {
6936
7463
  return this.buildArrayRow(field, value);
6937
7464
  }
6938
7465
  buildArrayRow(field, item) {
6939
- if (!field?.children?.length)
6940
- return this.fb.control(item);
6941
- const rowGroup = {};
6942
- for (const child of field.children) {
6943
- this.buildFieldControl(child, rowGroup);
6944
- }
6945
- const group = this.fb.group(rowGroup);
6946
- if (item && typeof item === 'object')
6947
- group.patchValue(item);
6948
- return group;
7466
+ return this.structure.buildArrayRow(field, item);
6949
7467
  }
6950
7468
  /**
6951
7469
  * Mark every control touched, then re-check the field components.
@@ -6978,42 +7496,11 @@ class DynamicFormComponent {
6978
7496
  return result.record;
6979
7497
  }
6980
7498
  patchForm(data) {
6981
- if (!data || !this.form)
7499
+ if (!data || !this.form || !this.config)
6982
7500
  return;
6983
7501
  const fieldsById = new Map(this.allFields().map(f => [f.id, f]));
6984
- const patchedFieldIds = new Set();
6985
- const walkTabs = (tabs) => {
6986
- for (const tab of tabs) {
6987
- const tabData = getTabData(tab.id, data, this.config);
6988
- for (const field of tab.fields || []) {
6989
- let val = tabData && typeof tabData === 'object' ? tabData[field.id] : undefined;
6990
- if (field.refererField) {
6991
- const refVal = getValueByPath(data, field.refererField);
6992
- if (refVal !== undefined)
6993
- val = refVal;
6994
- }
6995
- if (val === undefined)
6996
- continue;
6997
- const ctrl = this.getControl(field.id, tab.id);
6998
- if (!ctrl)
6999
- continue;
7000
- if (ctrl instanceof FormArray && Array.isArray(val)) {
7001
- ctrl.clear();
7002
- for (const item of val) {
7003
- ctrl.push(this.buildArrayRow(fieldsById.get(field.id), item));
7004
- }
7005
- }
7006
- else {
7007
- ctrl.patchValue(val, { emitEvent: false });
7008
- }
7009
- patchedFieldIds.add(field.id);
7010
- }
7011
- if (tab.children)
7012
- walkTabs(tab.children);
7013
- }
7014
- };
7015
- walkTabs(this.config.tabs || []);
7016
- this.warnUnconsumedInitialData(data, fieldsById, patchedFieldIds);
7502
+ const unconsumed = this.structure.patchForm(this.form, this.config, data, fieldsById);
7503
+ this.warnUnconsumedInitialData(unconsumed);
7017
7504
  }
7018
7505
  /**
7019
7506
  * A record is nested by tab id (`{ tabId: { fieldId: value } }`) unless the tab sets
@@ -7021,34 +7508,18 @@ class DynamicFormComponent {
7021
7508
  * stay empty — with no error and no clue. That silent miss is the most expensive way to
7022
7509
  * lose an hour with this library, so name it in dev builds.
7023
7510
  *
7024
- * Only top-level keys that match a known field id are reported: anything else is assumed
7025
- * to be the consumer's own record metadata (ids, timestamps, `_configVersion`) and is not
7026
- * our business.
7511
+ * Which keys were missed is `FormStructureService`'s answer; warning once per key per form
7512
+ * is this component's, because it is the thing that lives as long as the form does.
7027
7513
  */
7028
- warnUnconsumedInitialData(data, fieldsById, patchedFieldIds) {
7029
- if (!isDevMode() || !this.config)
7030
- return;
7031
- const tabIds = new Set();
7032
- const collectTabIds = (tabs) => {
7033
- for (const tab of tabs) {
7034
- tabIds.add(tab.id);
7035
- if (tab.children)
7036
- collectTabIds(tab.children);
7037
- }
7038
- };
7039
- collectTabIds(this.config.tabs || []);
7040
- const unconsumed = Object.keys(data).filter(key => !tabIds.has(key) && // a tab id at the root is the nested container, not a stray field
7041
- data[key] !== undefined &&
7042
- fieldsById.has(key) &&
7043
- !patchedFieldIds.has(key) &&
7044
- !this.warnedUnconsumedKeys.has(key));
7045
- if (!unconsumed.length)
7514
+ warnUnconsumedInitialData(keys) {
7515
+ const fresh = keys.filter(key => !this.warnedUnconsumedKeys.has(key));
7516
+ if (!fresh.length)
7046
7517
  return;
7047
- for (const key of unconsumed)
7518
+ for (const key of fresh)
7048
7519
  this.warnedUnconsumedKeys.add(key);
7049
- const shown = unconsumed.slice(0, 5).join(', ');
7050
- const more = unconsumed.length > 5 ? ` (+${unconsumed.length - 5} more)` : '';
7051
- const plural = unconsumed.length === 1;
7520
+ const shown = fresh.slice(0, 5).join(', ');
7521
+ const more = fresh.length > 5 ? ` (+${fresh.length - 5} more)` : '';
7522
+ const plural = fresh.length === 1;
7052
7523
  console.warn(`[ngx-dynamic-entity] initialData has top-level ${plural ? 'key' : 'keys'} matching ` +
7053
7524
  `${plural ? 'a field that was' : 'fields that were'} not populated: ${shown}${more}. ` +
7054
7525
  `A record is nested by tab id ({ tabId: { fieldId: value } }) unless the tab sets ` +
@@ -7059,34 +7530,7 @@ class DynamicFormComponent {
7059
7530
  warnedUnconsumedKeys = new Set();
7060
7531
  /** Assemble full nested record from per-tab FormGroups, respecting flatData, refererField & arrays. */
7061
7532
  extractRecord() {
7062
- if (!this.form || !this.config)
7063
- return {};
7064
- let record = {};
7065
- const walkTabs = (tabs) => {
7066
- for (const tab of tabs) {
7067
- const fieldValBag = {};
7068
- for (const field of tab.fields || []) {
7069
- const ctrl = this.getControl(field.id, tab.id);
7070
- if (ctrl) {
7071
- fieldValBag[field.id] = ctrl.value;
7072
- }
7073
- }
7074
- setTabData(record, tab.id, fieldValBag, this.config);
7075
- for (const field of tab.fields || []) {
7076
- if (field.refererField) {
7077
- const ctrl = this.getControl(field.id, tab.id);
7078
- if (ctrl) {
7079
- setValueByPath(record, field.refererField, ctrl.value);
7080
- }
7081
- }
7082
- }
7083
- if (tab.children)
7084
- walkTabs(tab.children);
7085
- }
7086
- };
7087
- walkTabs(this.config.tabs || []);
7088
- record = normalizeArrayStructures(record, this.config);
7089
- return record;
7533
+ return this.structure.extractRecord(this.form, this.config);
7090
7534
  }
7091
7535
  /**
7092
7536
  * Warns when a rule names a field id that two scopes define.
@@ -7124,15 +7568,24 @@ class DynamicFormComponent {
7124
7568
  continue;
7125
7569
  const id = parsed.value;
7126
7570
  const scopes = ambiguous.get(id);
7127
- if (!scopes || DynamicFormComponent.warnedAmbiguousIds.has(id))
7571
+ if (!scopes || this.warnedAmbiguousIds.has(id))
7128
7572
  continue;
7129
- DynamicFormComponent.warnedAmbiguousIds.add(id);
7573
+ this.warnedAmbiguousIds.add(id);
7130
7574
  console.warn(`[ngx-dynamic-entity] A rule references field "${id}", which is defined in ${scopes.join(' and ')}. ` +
7131
7575
  `A bare id cannot say which one is meant, so the rule will read whichever the form finds first. ` +
7132
7576
  `Name it by path instead, as [${scopes[0]}.${id}].`);
7133
7577
  }
7134
7578
  }
7135
- static warnedAmbiguousIds = new Set();
7579
+ /**
7580
+ * Ids already warned about, per component instance.
7581
+ *
7582
+ * This was `private static`, so it was module-global mutable state that nothing ever
7583
+ * cleared: under SSR every render of every form in the process accumulated into one set
7584
+ * that lived as long as the server did, and the first request to render a config
7585
+ * suppressed the warning for every request after it. Per instance, the warning is once per
7586
+ * form — which is what "warn once" was meant to mean.
7587
+ */
7588
+ warnedAmbiguousIds = new Set();
7136
7589
  /**
7137
7590
  * The value map rules and `showWhen` are evaluated against.
7138
7591
  *
@@ -7146,23 +7599,75 @@ class DynamicFormComponent {
7146
7599
  * unknown>` and needs no knowledge of refs at all — the extra keys simply resolve.
7147
7600
  */
7148
7601
  /**
7149
- * Whether a rule result names this field.
7602
+ * Every name a rule may address this field by.
7150
7603
  *
7151
7604
  * A rule target is either a bare field id — how every config so far addresses a field — or
7152
- * a bracketed path, which is the only way to name one of two fields that share an id.
7153
- * Both have to match, or a rule written either way would fail to hide what it targeted.
7605
+ * a bracketed path, which is the only way to name one of two fields that share an id. Both
7606
+ * have to match, or a rule written either way would fail to hide what it targeted.
7607
+ *
7608
+ * The path comes last, and `syncHiddenFieldState` relies on that: it uses the last entry
7609
+ * as the field's address when deciding whether a hidden container already covers it.
7154
7610
  */
7155
- namesField(names, field) {
7156
- if (names.has(field.id))
7157
- return true;
7158
- const ref = field.refererField ?? this.pathFor(field);
7159
- return ref ? names.has(toRefToken(ref)) : false;
7611
+ namesOf(field) {
7612
+ const path = field.refererField ?? this.pathFor(field);
7613
+ return path ? [field.id, toRefToken(path)] : [field.id];
7614
+ }
7615
+ /**
7616
+ * The field's dotted address, unbracketed.
7617
+ *
7618
+ * The positional path rather than an authored `refererField`: this is compared as a prefix
7619
+ * to decide whether a hidden container already covers a field, and containment is a fact
7620
+ * about where the field *is*. A `refererField` is a binding override — it says where the
7621
+ * value goes, not which group renders it.
7622
+ */
7623
+ addressOf(field) {
7624
+ return this.pathFor(field) ?? field.id;
7160
7625
  }
7161
7626
  /** The field's declared path, or the one its position implies when it carries none. */
7162
7627
  pathFor(field) {
7163
- const entry = collectFieldScopes(this.config).find(e => e.field === field);
7628
+ const entry = this.scopeEntryFor(field);
7164
7629
  return entry ? fieldRefFor(entry.scope, field.id) : null;
7165
7630
  }
7631
+ /**
7632
+ * Every field in the config with the scope its value is stored under, cached per config.
7633
+ *
7634
+ * `collectFieldScopes` walks the whole tree. It was called once per field from `pathFor`,
7635
+ * which `namesField` calls, which the render filter calls for every field on the active
7636
+ * tab — a quadratic walk on every change-detection pass. Keyed by config identity, so a
7637
+ * new config invalidates it without an explicit lifecycle hook to forget.
7638
+ */
7639
+ fieldScopes() {
7640
+ if (this.scopeCache?.config !== this.config) {
7641
+ const entries = collectFieldScopes(this.config);
7642
+ this.scopeCache = {
7643
+ config: this.config,
7644
+ entries,
7645
+ byField: new Map(entries.map(entry => [entry.field, entry])),
7646
+ };
7647
+ }
7648
+ return this.scopeCache.entries;
7649
+ }
7650
+ scopeEntryFor(field) {
7651
+ this.fieldScopes();
7652
+ return this.scopeCache?.byField.get(field);
7653
+ }
7654
+ scopeCache;
7655
+ /**
7656
+ * The control a scope entry addresses, named by path rather than by bare id.
7657
+ *
7658
+ * `getControl(field.id)` with no tab falls through to a first-match recursive search of the
7659
+ * whole form, so with `address` on two tabs, hiding one disabled the other. The control
7660
+ * tree mirrors the scope path exactly — `buildTabControls` nests by tab id and
7661
+ * `buildFieldControl` nests a `group` under its own id — so a `[path]` ref resolves through
7662
+ * `form.get()` on `getControl`'s first branch and names exactly one control.
7663
+ *
7664
+ * A field inside an `array` has no static path: its controls live in `FormArray` rows built
7665
+ * per row. `form.get()` returns null for those and they are left alone, which is correct —
7666
+ * a row's controls are created and destroyed with the row.
7667
+ */
7668
+ controlForEntry(entry) {
7669
+ return this.getControl(toRefToken(fieldRefFor(entry.scope, entry.field.id)));
7670
+ }
7166
7671
  flattenFormValues() {
7167
7672
  const out = {};
7168
7673
  for (const entry of collectFieldScopes(this.config)) {
@@ -7177,18 +7682,6 @@ class DynamicFormComponent {
7177
7682
  }
7178
7683
  return out;
7179
7684
  }
7180
- getTabGroup(tabId) {
7181
- const path = getTabPath(this.config?.tabs, tabId);
7182
- if (!path || path.length === 0)
7183
- return this.form;
7184
- let curr = this.form;
7185
- for (const p of path) {
7186
- if (!curr || !(curr instanceof FormGroup))
7187
- return null;
7188
- curr = curr.get(p);
7189
- }
7190
- return curr instanceof FormGroup ? curr : null;
7191
- }
7192
7685
  // ─── autoPatch / patchOnTrue ──────────────────────────────────────────────
7193
7686
  /**
7194
7687
  * `autoPatch` — copy mapped fields out of the record the user just selected in an
@@ -7230,65 +7723,48 @@ class DynamicFormComponent {
7230
7723
  }
7231
7724
  }
7232
7725
  }
7233
- /** Prefer a control declared on the configured target tab; fall back to a top-level control. */
7726
+ /**
7727
+ * Prefer a control declared on the configured target tab; fall back to a top-level control.
7728
+ *
7729
+ * "Declared on the tab" now means anywhere under it, sub-tabs and `group` children
7730
+ * included. It meant the tab's own top-level fields only, so an `autoPatch` mapping onto a
7731
+ * field inside a group resolved to `null` and copied nothing, silently.
7732
+ */
7234
7733
  resolveTargetControl(autoPatch, targetId) {
7235
7734
  const tab = findTab(this.tabs, autoPatch.targetTab);
7236
- const inTargetTab = (tab?.fields ?? []).some(f => f.id === targetId);
7237
- if (tab && !inTargetTab)
7735
+ if (!tab)
7736
+ return this.getControl(targetId, autoPatch.targetTab);
7737
+ const entry = fieldsUnderTab(this.config, autoPatch.targetTab).find(e => {
7738
+ if (!e.field?.id)
7739
+ return false;
7740
+ const parsed = parseFieldRef(targetId);
7741
+ return parsed.kind === 'ref'
7742
+ ? fieldRefFor(e.scope, e.field.id) === parsed.value
7743
+ : e.field.id === parsed.value;
7744
+ });
7745
+ if (!entry)
7238
7746
  return null;
7239
- return this.getControl(targetId, autoPatch.targetTab);
7747
+ return this.controlForEntry(entry);
7240
7748
  }
7241
7749
  // ─── Helpers ──────────────────────────────────────────────────────────────
7242
7750
  /**
7243
- * The fields the render filter operates on — a tab's own fields, at every tab depth.
7244
- * Deliberately not `allFields()`: `group` and `array` children are shown or hidden with
7245
- * their parent, and toggling a child's state independently would fight the parent's.
7246
- */
7247
- tabFields() {
7248
- const out = [];
7249
- const walk = (tabs) => {
7250
- for (const tab of tabs ?? []) {
7251
- out.push(...(tab.fields ?? []));
7252
- walk(tab.children);
7253
- }
7254
- };
7255
- walk(this.config?.tabs);
7256
- return out;
7257
- }
7258
- /**
7259
- * Keep a hidden field's control out of form validity.
7260
- *
7261
- * A field hidden by a rule or a `showWhen` condition is filtered out of the render, but
7262
- * its validators stay attached — so a hidden *required* field holds `form.invalid` true
7263
- * forever, permanently disabling Save with nothing on screen to explain why.
7751
+ * Keep a hidden field's control out of form validity — see
7752
+ * `RulesEvaluationService.syncHiddenFieldState`, which owns the rule and the reasoning.
7264
7753
  *
7265
- * Disabling is the fix rather than stripping validators: Angular excludes disabled
7266
- * controls from validity, and the field's own validators survive intact for when it
7267
- * comes back. Values are untouched — `flattenFormValues` and `extractRecord` both read
7268
- * `control.value`, which a disabled control still carries, so rule evaluation and the
7269
- * emitted record see exactly what they saw before. That also keeps this from feeding
7270
- * back on itself: hiding a field cannot change the values the rules are evaluated over.
7271
- *
7272
- * The visibility predicate is the same one `fieldsForActiveTab` filters with, so what is
7273
- * rendered and what counts toward validity cannot drift apart.
7754
+ * This supplies the three things it cannot know: which fields exist and in what order
7755
+ * (`collectFieldScopes`, parent-first), what each one is called, and which control each
7756
+ * one addresses.
7274
7757
  */
7275
7758
  syncHiddenFieldState(values) {
7276
7759
  if (this.preview)
7277
7760
  return; // preview freezes the whole form on purpose
7278
- const hiddenByRule = new Set(this.ruleResult().hiddenFields);
7279
- for (const field of this.tabFields()) {
7280
- const ctrl = this.getControl(field.id);
7281
- if (!ctrl)
7282
- continue;
7283
- const hidden = this.namesField(hiddenByRule, field) || !evaluateFieldVisibility(field, values);
7284
- if (hidden) {
7285
- if (ctrl.enabled)
7286
- ctrl.disable({ emitEvent: false });
7287
- }
7288
- else if (ctrl.disabled && !field.disabled) {
7289
- ctrl.enable({ emitEvent: false });
7290
- }
7291
- }
7761
+ this.rulesEvaluation.syncHiddenFieldState(this.fieldScopes(), {
7762
+ result: this.ruleResult(),
7763
+ values,
7764
+ namesOf: field => this.namesOf(field),
7765
+ addressOf: field => this.addressOf(field),
7766
+ controlFor: entry => this.controlForEntry(entry),
7767
+ });
7292
7768
  }
7293
7769
  /** Every field in the config, including `group`/`array` children. */
7294
7770
  allFields() {
@@ -7316,51 +7792,31 @@ class DynamicFormComponent {
7316
7792
  return supplied;
7317
7793
  return this.sessionBaseline();
7318
7794
  }
7795
+ /**
7796
+ * Whether a critical field still holds what it held when the session started.
7797
+ *
7798
+ * The same question `VALUE_CHANGED` asks, so it is answered by the same comparator rather
7799
+ * than by a third one. The local version compared objects through `JSON.stringify`, which
7800
+ * is key-order sensitive — two serialisers writing the same option in a different key
7801
+ * order made the field read as edited — and had its own idea of which values count as
7802
+ * empty. `valuesEqual` is order-independent and treats `null` and `undefined` as the same
7803
+ * absence; the empty-string case is kept here, because a cleared text input is not an edit.
7804
+ */
7319
7805
  sameValue(a, b) {
7320
- if (a === b)
7321
- return true;
7322
7806
  const aEmpty = a === null || a === undefined || a === '';
7323
7807
  const bEmpty = b === null || b === undefined || b === '';
7324
7808
  if (aEmpty && bEmpty)
7325
7809
  return true;
7326
- if (typeof a === 'object' || typeof b === 'object') {
7327
- return JSON.stringify(a ?? null) === JSON.stringify(b ?? null);
7328
- }
7329
- return false;
7810
+ return valuesEqual(a, b);
7330
7811
  }
7812
+ /**
7813
+ * Find a control by a bracketed path or a bare field id.
7814
+ *
7815
+ * Public API, so it stays on the component and delegates — see `FormStructureService`,
7816
+ * which owns the resolution rule and is where the behaviour is documented.
7817
+ */
7331
7818
  getControl(fieldId, currentTabId) {
7332
- if (!this.form)
7333
- return null;
7334
- // A bracketed path names exactly one control, and the form nests by tab exactly as the
7335
- // path does — so `form.get('work.address')` walks straight to it. Anything that names a
7336
- // field can therefore use a path: `showWhen` keys, `patchOnTrue` mappings and `autoPatch`
7337
- // targets, not only rules.
7338
- const parsed = parseFieldRef(fieldId);
7339
- if (parsed.kind === 'ref')
7340
- return this.form.get(parsed.value);
7341
- if (currentTabId) {
7342
- const tabGrp = this.getTabGroup(currentTabId);
7343
- const ctrl = tabGrp?.get(fieldId);
7344
- if (ctrl)
7345
- return ctrl;
7346
- }
7347
- const rootCtrl = this.form.get(fieldId);
7348
- if (rootCtrl)
7349
- return rootCtrl;
7350
- const findInGroup = (group) => {
7351
- for (const key of Object.keys(group.controls)) {
7352
- const c = group.controls[key];
7353
- if (key === fieldId)
7354
- return c;
7355
- if (c instanceof FormGroup) {
7356
- const found = findInGroup(c);
7357
- if (found)
7358
- return found;
7359
- }
7360
- }
7361
- return null;
7362
- };
7363
- return findInGroup(this.form);
7819
+ return this.structure.getControl(this.form, this.config, fieldId, currentTabId);
7364
7820
  }
7365
7821
  /**
7366
7822
  * Move focus into the newly shown panel.
@@ -7460,11 +7916,11 @@ class DynamicFormComponent {
7460
7916
  this.formReset.emit();
7461
7917
  }
7462
7918
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.22", ngImport: i0, type: DynamicFormComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
7463
- static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.22", type: DynamicFormComponent, isStandalone: true, selector: "ngx-dynamic-form", inputs: { config: "config", rules: "rules", initialData: "initialData", originalData: "originalData", userRoles: "userRoles", language: "language", readonly: "readonly", readOnlyFields: "readOnlyFields", showInfoBanners: "showInfoBanners", loading: "loading", error: "error", changeDebounceMs: "changeDebounceMs", preview: "preview", layout: "layout" }, outputs: { formSubmit: "formSubmit", formChange: "formChange", formReset: "formReset", activeTabChange: "activeTabChange", saveRejected: "saveRejected" }, host: { listeners: { "window:keydown": "handleKeyboardEvent($event)" } }, providers: [EntityRefSelectionService], viewQueries: [{ propertyName: "formPanel", first: true, predicate: ["formPanel"], descendants: true }, { propertyName: "fieldHosts", predicate: DynamicFieldComponent, descendants: true }], usesOnChanges: true, ngImport: i0, template: "@if (canView) {\n <form [formGroup]=\"form\" (ngSubmit)=\"submit()\" class=\"ngx-form\" [class.ngx-form--readonly]=\"readonly\" novalidate>\n <!-- Error message banner -->\n @if (error) {\n <div class=\"ngx-form__error-banner\" role=\"alert\" data-testid=\"form-error\">{{ error }}</div>\n }\n\n <!--\n Why the last Save did nothing.\n Only after a refused submit \u2014 see `submitAttempted`. Each entry jumps to its field,\n switching tabs on the way, because the field is usually not the one on screen.\n -->\n @if (submitAttempted() && invalidFields().length > 0) {\n <div class=\"ngx-form__error-summary\" role=\"alert\" data-testid=\"error-summary\" tabindex=\"-1\">\n <!--\n `error-summary-heading`, not `-title`: the links below are `error-summary-<fieldId>`,\n and a field whose id happens to be `title` would then share a test id with this line.\n The `extensions` entity has exactly such a field.\n -->\n <p class=\"ngx-form__error-summary-title\" data-testid=\"error-summary-heading\">\n {{ ui.text('errorSummaryTitle', language, { count: invalidFields().length }) }}\n </p>\n <ul class=\"ngx-form__error-summary-list\">\n @for (entry of invalidFields(); track entry.field.id) {\n <li>\n <!--\n The field, and what is wrong with it \u2014 the same sentence the field renders\n under itself, so the summary is not a second opinion. Naming only the field\n left the user to visit each one to find out what it wanted.\n -->\n <button\n type=\"button\"\n class=\"ngx-form__error-summary-link\"\n [attr.data-testid]=\"'error-summary-' + entry.field.id\"\n (click)=\"jumpToField(entry.field.id)\"\n >\n <span class=\"ngx-form__error-summary-field\">{{ resolveFieldLabel(entry.field) }}</span>\n @if (entry.message) {\n <span class=\"ngx-form__error-summary-reason\">{{ entry.message }}</span>\n }\n </button>\n </li>\n }\n </ul>\n </div>\n }\n\n <!-- Rules Info Banners -->\n @for (infoKey of showInfoBanners ? Object.keys(ruleResult().infoBanners) : []; track infoKey) {\n <div class=\"ngx-form__info-banner\" role=\"status\" [attr.data-testid]=\"'info-banner-' + infoKey\">\n \u2139\uFE0F {{ ruleResult().infoBanners[infoKey] }}\n </div>\n }\n\n <!-- Rules Validation Errors & Warnings -->\n @for (errKey of Object.keys(ruleResult().validationErrors); track errKey) {\n <div class=\"ngx-form__error-banner\" role=\"alert\" [attr.data-testid]=\"'rule-error-' + errKey\">\n \u26A0\uFE0F {{ ruleResult().validationErrors[errKey] }}\n </div>\n }\n @for (warnKey of Object.keys(ruleResult().validationWarnings); track warnKey) {\n <div class=\"ngx-form__warning-banner\" role=\"status\" [attr.data-testid]=\"'rule-warning-' + warnKey\">\n \u26A1 {{ ruleResult().validationWarnings[warnKey] }}\n </div>\n }\n\n <!-- Deferred VALUE_CHANGED notice for criticalField edits (vs session baseline) -->\n @if (changedCriticalFields().length > 0) {\n <div class=\"ngx-form__warning-banner\" role=\"status\" data-testid=\"critical-change-banner\">\n {{ ui.text('criticalFieldChanged', language, { fields: changedCriticalFieldLabels() }) }}\n </div>\n }\n\n <!-- Primary Tab bar \u2014 only if visible tabs defined -->\n @if (visibleTabs.length > 0) {\n <div class=\"ngx-form__tabs\" role=\"tablist\" data-testid=\"tab-strip\">\n @for (tab of visibleTabs; track tab.id) {\n <button\n type=\"button\"\n class=\"ngx-form__tab\"\n [attr.data-testid]=\"'tab-' + tab.id\"\n [class.ngx-form__tab--active]=\"activeTab() === tab.id\"\n (click)=\"setActiveTab(tab.id)\"\n role=\"tab\"\n [attr.aria-selected]=\"activeTab() === tab.id\"\n >\n {{ resolveTabLabel(tab) }}\n <!--\n How many fields on this tab are stopping the save.\n The count is in the accessible name rather than the badge, because \"3\" read out\n after a tab name says nothing about what it counts.\n -->\n @if (tabErrorCount(tab); as errors) {\n <span\n class=\"ngx-form__tab-badge\"\n [attr.data-testid]=\"'tab-errors-' + tab.id\"\n [attr.aria-label]=\"ui.text('tabErrorCount', language, { count: errors })\"\n >{{ errors }}</span\n >\n }\n </button>\n }\n </div>\n }\n\n <!-- Sub-tab bar \u2014 only if active tab has sub-tabs -->\n @if (visibleSubTabs.length > 0) {\n <div\n class=\"ngx-form__tabs ngx-form__tabs--sub\"\n role=\"tablist\"\n data-testid=\"subtab-strip\"\n style=\"margin-top: -12px; margin-bottom: 12px; font-size: 13px\"\n >\n @for (subTab of visibleSubTabs; track subTab.id) {\n <button\n type=\"button\"\n class=\"ngx-form__tab ngx-form__tab--sub\"\n [attr.data-testid]=\"'subtab-' + subTab.id\"\n [class.ngx-form__tab--active]=\"activeSubTabConfig?.id === subTab.id\"\n (click)=\"setActiveSubTab(subTab.id)\"\n role=\"tab\"\n [attr.aria-selected]=\"activeSubTabConfig?.id === subTab.id\"\n >\n {{ resolveTabLabel(subTab) }}\n @if (subTabErrorCount(subTab); as errors) {\n <span\n class=\"ngx-form__tab-badge\"\n [attr.data-testid]=\"'subtab-errors-' + subTab.id\"\n [attr.aria-label]=\"ui.text('tabErrorCount', language, { count: errors })\"\n >{{ errors }}</span\n >\n }\n </button>\n }\n </div>\n }\n\n <!--\n One tabpanel per tab, wrapping both branches. Focus moves here on a tab switch, and a\n module tab is still a tab \u2014 putting the panel semantics on only the fields branch left\n a module tab with nowhere for focus to land.\n -->\n <div role=\"tabpanel\" tabindex=\"-1\" #formPanel class=\"ngx-form__tabpanel\" [attr.aria-label]=\"activeTabLabel\">\n @if (activeTabModuleComponent) {\n <div class=\"ngx-form__module-panel\" data-testid=\"module-panel\">\n <ng-container\n *ngComponentOutlet=\"activeTabModuleComponent; inputs: (activeSubTabConfig || activeTabConfig)?.moduleInputs\"\n ></ng-container>\n </div>\n } @else {\n <!-- Fields for active tab (Responsive 12-column grid) -->\n <div class=\"ngx-form__panel\" data-testid=\"form-panel\">\n @for (field of fieldsForActiveTab; track field.id) {\n <div\n class=\"ngx-form__field-slot\"\n [style.gridColumn]=\"getFieldSpan(field)\"\n [attr.id]=\"'field-container-' + field.id\"\n tabindex=\"-1\"\n >\n <ngx-dynamic-field\n [field]=\"field\"\n [control]=\"getControl(field.id, (activeSubTabConfig || activeTabConfig)?.id)!\"\n [config]=\"config\"\n [currentTabId]=\"(activeSubTabConfig || activeTabConfig)?.id\"\n [language]=\"language\"\n [readonly]=\"isFieldReadonly(field)\"\n [userRoles]=\"userRoles\"\n />\n @if (field.hasDrift) {\n <!--\n Drift was previously visible only in the builder, so a referenced field whose\n source had changed looked completely normal to the person filling the form in.\n role=\"status\" rather than \"alert\": it is information, not an error.\n -->\n <span\n class=\"ngx-field__drift\"\n role=\"status\"\n [attr.data-testid]=\"'field-' + field.id + '-drift'\"\n [title]=\"driftHint(field)\"\n >\u26A0 {{ driftHint(field) }}</span\n >\n }\n @if (field.criticalField && !readonly) {\n <button\n type=\"button\"\n class=\"ngx-form__lock\"\n [attr.data-testid]=\"'lock-' + field.id\"\n [attr.aria-pressed]=\"!isFieldLocked(field)\"\n [attr.aria-label]=\"\n ui.text(isFieldLocked(field) ? 'unlockField' : 'lockField', language, {\n field: resolveFieldLabel(field),\n })\n \"\n [title]=\"ui.text(isFieldLocked(field) ? 'criticalFieldUnlockHint' : 'criticalFieldLockHint', language)\"\n (click)=\"toggleFieldLock(field)\"\n >\n {{ isFieldLocked(field) ? '\uD83D\uDD12' : '\uD83D\uDD13' }}\n </button>\n }\n </div>\n }\n </div>\n }\n </div>\n\n <!-- Submit button \u2014 gated by RBAC and isSaving lock -->\n @if (!readonly && permissions.canEdit) {\n <div class=\"ngx-form__actions\" data-testid=\"form-actions\">\n <button type=\"button\" class=\"ngx-form__reset\" data-testid=\"form-reset\" (click)=\"reset()\" [disabled]=\"isSaving()\">\n {{ ui.text('reset', language) }}\n </button>\n <!--\n Two different \"unavailable\" states, and they are not the same thing.\n\n `disabled` is for what a retry cannot fix \u2014 a save in flight, an async check still\n outstanding. `data-blocked` is for validation: the button stays fully operable,\n because pressing it is how the user gets the error summary, but it should not look\n ready either. The stylesheet mutes it without taking the click away.\n\n Deliberately not `aria-disabled`. That announces a control as present-but-inoperable,\n and this one is operable and useful \u2014 pressing it raises a `role=\"alert\"` naming\n every field at fault, which is more than a disabled button could ever say. Tooling\n agrees: Playwright refuses to click through `aria-disabled`, and it would be right\n to, because the attribute would be claiming something untrue.\n -->\n <button\n type=\"submit\"\n class=\"ngx-form__submit\"\n data-testid=\"form-submit\"\n [disabled]=\"submitDisabled\"\n [attr.data-blocked]=\"submitBlocked ? '' : null\"\n [title]=\"ui.text('saveShortcutHint', language)\"\n >\n @if (isSaving() || loading) {\n {{ ui.text('saving', language) }}\n } @else {\n {{ ui.text('save', language) }}\n }\n </button>\n </div>\n }\n </form>\n} @else {\n <!-- permissions.view denied: render nothing of the record.\n Presentational only \u2014 the server must not send data this user may not see. -->\n <div class=\"ngx-form__denied\" role=\"status\" data-testid=\"form-access-denied\">\n {{ ui.text('accessDenied', language) }}\n </div>\n}\n", styles: [".ngx-form__panel{display:grid;grid-template-columns:repeat(12,minmax(0,1fr));gap:var(--ngx-gap, 18px);align-items:start}.ngx-form--readonly .ngx-form__panel{grid-template-columns:repeat(auto-fit,minmax(230px,1fr));gap:2px var(--ngx-gap, 18px)}.ngx-form--readonly .ngx-form__field-slot{grid-column:auto}.ngx-form__field-slot{display:flex;align-items:flex-start;gap:6px;min-width:0}.ngx-form__field-slot ngx-dynamic-field{flex:1;min-width:0}.ngx-form__lock{flex:0 0 auto;margin-top:22px;background:none;border:1px solid transparent;border-radius:var(--ngx-radius-sm, 6px);cursor:pointer;font-size:14px;line-height:1;padding:4px 6px}.ngx-form__lock:hover{border-color:var(--ngx-color-border, #d1d5db);background:var(--ngx-color-surface-alt, #f9fafb)}@media(max-width:640px){.ngx-form__panel,.ngx-form--readonly .ngx-form__panel{grid-template-columns:minmax(0,1fr)}.ngx-form__field-slot{grid-column:1 / -1!important}}\n"], dependencies: [{ kind: "ngmodule", type: ReactiveFormsModule }, { kind: "directive", type: i1.ɵNgNoValidate, selector: "form:not([ngNoForm]):not([ngNativeValidate])" }, { kind: "directive", type: i1.NgControlStatusGroup, selector: "[formGroupName],[formArrayName],[ngModelGroup],[formGroup],[formArray],form:not([ngNoForm]),[ngForm]" }, { kind: "directive", type: i1.FormGroupDirective, selector: "[formGroup]", inputs: ["formGroup"], outputs: ["ngSubmit"], exportAs: ["ngForm"] }, { kind: "component", type: DynamicFieldComponent, selector: "ngx-dynamic-field", inputs: ["field", "control", "config", "currentTabId", "language", "readonly", "userRoles"] }, { kind: "directive", type: NgComponentOutlet, selector: "[ngComponentOutlet]", inputs: ["ngComponentOutlet", "ngComponentOutletInputs", "ngComponentOutletInjector", "ngComponentOutletEnvironmentInjector", "ngComponentOutletContent", "ngComponentOutletNgModule"], exportAs: ["ngComponentOutlet"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
7919
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.22", type: DynamicFormComponent, isStandalone: true, selector: "ngx-dynamic-form", inputs: { config: "config", rules: "rules", initialData: "initialData", originalData: "originalData", userRoles: "userRoles", language: "language", readonly: "readonly", readOnlyFields: "readOnlyFields", showInfoBanners: "showInfoBanners", loading: "loading", error: "error", changeDebounceMs: "changeDebounceMs", preview: "preview", layout: "layout" }, outputs: { formSubmit: "formSubmit", formChange: "formChange", formReset: "formReset", activeTabChange: "activeTabChange", saveRejected: "saveRejected" }, host: { listeners: { "window:keydown": "handleKeyboardEvent($event)" } }, providers: [EntityRefSelectionService, FormStructureService], viewQueries: [{ propertyName: "formPanel", first: true, predicate: ["formPanel"], descendants: true }, { propertyName: "fieldHosts", predicate: DynamicFieldComponent, descendants: true }], usesOnChanges: true, ngImport: i0, template: "@if (canView) {\n <form [formGroup]=\"form\" (ngSubmit)=\"submit()\" class=\"ngx-form\" [class.ngx-form--readonly]=\"readonly\" novalidate>\n <!-- Error message banner -->\n @if (error) {\n <div class=\"ngx-form__error-banner\" role=\"alert\" data-testid=\"form-error\">{{ error }}</div>\n }\n\n <!--\n Why the last Save did nothing.\n Only after a refused submit \u2014 see `submitAttempted`. Each entry jumps to its field,\n switching tabs on the way, because the field is usually not the one on screen.\n -->\n @if (submitAttempted() && invalidFields().length > 0) {\n <div class=\"ngx-form__error-summary\" role=\"alert\" data-testid=\"error-summary\" tabindex=\"-1\">\n <!--\n `error-summary-heading`, not `-title`: the links below are `error-summary-<fieldId>`,\n and a field whose id happens to be `title` would then share a test id with this line.\n The `extensions` entity has exactly such a field.\n -->\n <p class=\"ngx-form__error-summary-title\" data-testid=\"error-summary-heading\">\n {{ ui.text('errorSummaryTitle', language, { count: invalidFields().length }) }}\n </p>\n <ul class=\"ngx-form__error-summary-list\">\n @for (entry of invalidFields(); track entry.field.id) {\n <li>\n <!--\n The field, and what is wrong with it \u2014 the same sentence the field renders\n under itself, so the summary is not a second opinion. Naming only the field\n left the user to visit each one to find out what it wanted.\n -->\n <button\n type=\"button\"\n class=\"ngx-form__error-summary-link\"\n [attr.data-testid]=\"'error-summary-' + entry.field.id\"\n (click)=\"jumpToField(entry.field.id)\"\n >\n <span class=\"ngx-form__error-summary-field\">{{ resolveFieldLabel(entry.field) }}</span>\n @if (entry.message) {\n <span class=\"ngx-form__error-summary-reason\">{{ entry.message }}</span>\n }\n </button>\n </li>\n }\n </ul>\n </div>\n }\n\n <!-- Rules Info Banners -->\n @for (infoKey of showInfoBanners ? Object.keys(ruleResult().infoBanners) : []; track infoKey) {\n <div class=\"ngx-form__info-banner\" role=\"status\" [attr.data-testid]=\"'info-banner-' + infoKey\">\n \u2139\uFE0F {{ ruleResult().infoBanners[infoKey] }}\n </div>\n }\n\n <!-- Rules Validation Errors & Warnings -->\n @for (errKey of Object.keys(ruleResult().validationErrors); track errKey) {\n <div class=\"ngx-form__error-banner\" role=\"alert\" [attr.data-testid]=\"'rule-error-' + errKey\">\n \u26A0\uFE0F {{ ruleResult().validationErrors[errKey] }}\n </div>\n }\n @for (warnKey of Object.keys(ruleResult().validationWarnings); track warnKey) {\n <div class=\"ngx-form__warning-banner\" role=\"status\" [attr.data-testid]=\"'rule-warning-' + warnKey\">\n \u26A1 {{ ruleResult().validationWarnings[warnKey] }}\n </div>\n }\n\n <!-- Deferred VALUE_CHANGED notice for criticalField edits (vs session baseline) -->\n @if (changedCriticalFields().length > 0) {\n <div class=\"ngx-form__warning-banner\" role=\"status\" data-testid=\"critical-change-banner\">\n {{ ui.text('criticalFieldChanged', language, { fields: changedCriticalFieldLabels() }) }}\n </div>\n }\n\n <!-- Primary Tab bar \u2014 only if visible tabs defined -->\n @if (visibleTabs.length > 0) {\n <div class=\"ngx-form__tabs\" role=\"tablist\" data-testid=\"tab-strip\">\n @for (tab of visibleTabs; track tab.id) {\n <button\n type=\"button\"\n class=\"ngx-form__tab\"\n [attr.data-testid]=\"'tab-' + tab.id\"\n [class.ngx-form__tab--active]=\"activeTab() === tab.id\"\n (click)=\"setActiveTab(tab.id)\"\n role=\"tab\"\n [attr.aria-selected]=\"activeTab() === tab.id\"\n >\n {{ resolveTabLabel(tab) }}\n <!--\n How many fields on this tab are stopping the save.\n The count is in the accessible name rather than the badge, because \"3\" read out\n after a tab name says nothing about what it counts.\n -->\n @if (tabErrorCount(tab); as errors) {\n <span\n class=\"ngx-form__tab-badge\"\n [attr.data-testid]=\"'tab-errors-' + tab.id\"\n [attr.aria-label]=\"ui.text('tabErrorCount', language, { count: errors })\"\n >{{ errors }}</span\n >\n }\n </button>\n }\n </div>\n }\n\n <!-- Sub-tab bar \u2014 only if active tab has sub-tabs -->\n @if (visibleSubTabs.length > 0) {\n <div\n class=\"ngx-form__tabs ngx-form__tabs--sub\"\n role=\"tablist\"\n data-testid=\"subtab-strip\"\n style=\"margin-top: -12px; margin-bottom: 12px; font-size: 13px\"\n >\n @for (subTab of visibleSubTabs; track subTab.id) {\n <button\n type=\"button\"\n class=\"ngx-form__tab ngx-form__tab--sub\"\n [attr.data-testid]=\"'subtab-' + subTab.id\"\n [class.ngx-form__tab--active]=\"activeSubTabConfig?.id === subTab.id\"\n (click)=\"setActiveSubTab(subTab.id)\"\n role=\"tab\"\n [attr.aria-selected]=\"activeSubTabConfig?.id === subTab.id\"\n >\n {{ resolveTabLabel(subTab) }}\n @if (subTabErrorCount(subTab); as errors) {\n <span\n class=\"ngx-form__tab-badge\"\n [attr.data-testid]=\"'subtab-errors-' + subTab.id\"\n [attr.aria-label]=\"ui.text('tabErrorCount', language, { count: errors })\"\n >{{ errors }}</span\n >\n }\n </button>\n }\n </div>\n }\n\n <!--\n One tabpanel per tab, wrapping both branches. Focus moves here on a tab switch, and a\n module tab is still a tab \u2014 putting the panel semantics on only the fields branch left\n a module tab with nowhere for focus to land.\n -->\n <div role=\"tabpanel\" tabindex=\"-1\" #formPanel class=\"ngx-form__tabpanel\" [attr.aria-label]=\"activeTabLabel\">\n @if (activeTabModuleComponent) {\n <div class=\"ngx-form__module-panel\" data-testid=\"module-panel\">\n <ng-container\n *ngComponentOutlet=\"activeTabModuleComponent; inputs: (activeSubTabConfig || activeTabConfig)?.moduleInputs\"\n ></ng-container>\n </div>\n } @else {\n <!-- Fields for active tab (Responsive 12-column grid) -->\n <div class=\"ngx-form__panel\" data-testid=\"form-panel\">\n @for (field of fieldsForActiveTab; track field.id) {\n <div\n class=\"ngx-form__field-slot\"\n [style.gridColumn]=\"getFieldSpan(field)\"\n [attr.id]=\"'field-container-' + field.id\"\n tabindex=\"-1\"\n >\n <ngx-dynamic-field\n [field]=\"field\"\n [control]=\"getControl(field.id, (activeSubTabConfig || activeTabConfig)?.id)!\"\n [config]=\"config\"\n [currentTabId]=\"(activeSubTabConfig || activeTabConfig)?.id\"\n [language]=\"language\"\n [readonly]=\"isFieldReadonly(field)\"\n [userRoles]=\"userRoles\"\n />\n @if (field.hasDrift) {\n <!--\n Drift was previously visible only in the builder, so a referenced field whose\n source had changed looked completely normal to the person filling the form in.\n role=\"status\" rather than \"alert\": it is information, not an error.\n -->\n <span\n class=\"ngx-field__drift\"\n role=\"status\"\n [attr.data-testid]=\"'field-' + field.id + '-drift'\"\n [title]=\"driftHint(field)\"\n >\u26A0 {{ driftHint(field) }}</span\n >\n }\n @if (field.criticalField && !readonly) {\n <button\n type=\"button\"\n class=\"ngx-form__lock\"\n [attr.data-testid]=\"'lock-' + field.id\"\n [attr.aria-pressed]=\"!isFieldLocked(field)\"\n [attr.aria-label]=\"\n ui.text(isFieldLocked(field) ? 'unlockField' : 'lockField', language, {\n field: resolveFieldLabel(field),\n })\n \"\n [title]=\"ui.text(isFieldLocked(field) ? 'criticalFieldUnlockHint' : 'criticalFieldLockHint', language)\"\n (click)=\"toggleFieldLock(field)\"\n >\n {{ isFieldLocked(field) ? '\uD83D\uDD12' : '\uD83D\uDD13' }}\n </button>\n }\n </div>\n }\n </div>\n }\n </div>\n\n <!-- Submit button \u2014 gated by RBAC and isSaving lock -->\n @if (!readonly && permissions.canEdit) {\n <div class=\"ngx-form__actions\" data-testid=\"form-actions\">\n <button type=\"button\" class=\"ngx-form__reset\" data-testid=\"form-reset\" (click)=\"reset()\" [disabled]=\"isSaving()\">\n {{ ui.text('reset', language) }}\n </button>\n <!--\n Two different \"unavailable\" states, and they are not the same thing.\n\n `disabled` is for what a retry cannot fix \u2014 a save in flight, an async check still\n outstanding. `data-blocked` is for validation: the button stays fully operable,\n because pressing it is how the user gets the error summary, but it should not look\n ready either. The stylesheet mutes it without taking the click away.\n\n Deliberately not `aria-disabled`. That announces a control as present-but-inoperable,\n and this one is operable and useful \u2014 pressing it raises a `role=\"alert\"` naming\n every field at fault, which is more than a disabled button could ever say. Tooling\n agrees: Playwright refuses to click through `aria-disabled`, and it would be right\n to, because the attribute would be claiming something untrue.\n -->\n <button\n type=\"submit\"\n class=\"ngx-form__submit\"\n data-testid=\"form-submit\"\n [disabled]=\"submitDisabled\"\n [attr.data-blocked]=\"submitBlocked ? '' : null\"\n [title]=\"ui.text('saveShortcutHint', language)\"\n >\n @if (isSaving() || loading) {\n {{ ui.text('saving', language) }}\n } @else {\n {{ ui.text('save', language) }}\n }\n </button>\n </div>\n }\n </form>\n} @else {\n <!-- permissions.view denied: render nothing of the record.\n Presentational only \u2014 the server must not send data this user may not see. -->\n <div class=\"ngx-form__denied\" role=\"status\" data-testid=\"form-access-denied\">\n {{ ui.text('accessDenied', language) }}\n </div>\n}\n", styles: [".ngx-form__panel{display:grid;grid-template-columns:repeat(12,minmax(0,1fr));gap:var(--ngx-gap, 18px);align-items:start}.ngx-form--readonly .ngx-form__panel{grid-template-columns:repeat(auto-fit,minmax(230px,1fr));gap:2px var(--ngx-gap, 18px)}.ngx-form--readonly .ngx-form__field-slot{grid-column:auto}.ngx-form__field-slot{display:flex;align-items:flex-start;gap:6px;min-width:0}.ngx-form__field-slot ngx-dynamic-field{flex:1;min-width:0}.ngx-form__lock{flex:0 0 auto;margin-top:22px;background:none;border:1px solid transparent;border-radius:var(--ngx-radius-sm, 6px);cursor:pointer;font-size:14px;line-height:1;padding:4px 6px}.ngx-form__lock:hover{border-color:var(--ngx-color-border, #d1d5db);background:var(--ngx-color-surface-alt, #f9fafb)}@media(max-width:640px){.ngx-form__panel,.ngx-form--readonly .ngx-form__panel{grid-template-columns:minmax(0,1fr)}.ngx-form__field-slot{grid-column:1 / -1!important}}\n"], dependencies: [{ kind: "ngmodule", type: ReactiveFormsModule }, { kind: "directive", type: i1.ɵNgNoValidate, selector: "form:not([ngNoForm]):not([ngNativeValidate])" }, { kind: "directive", type: i1.NgControlStatusGroup, selector: "[formGroupName],[formArrayName],[ngModelGroup],[formGroup],[formArray],form:not([ngNoForm]),[ngForm]" }, { kind: "directive", type: i1.FormGroupDirective, selector: "[formGroup]", inputs: ["formGroup"], outputs: ["ngSubmit"], exportAs: ["ngForm"] }, { kind: "component", type: DynamicFieldComponent, selector: "ngx-dynamic-field", inputs: ["field", "control", "config", "currentTabId", "language", "readonly", "userRoles"] }, { kind: "directive", type: NgComponentOutlet, selector: "[ngComponentOutlet]", inputs: ["ngComponentOutlet", "ngComponentOutletInputs", "ngComponentOutletInjector", "ngComponentOutletEnvironmentInjector", "ngComponentOutletContent", "ngComponentOutletNgModule"], exportAs: ["ngComponentOutlet"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
7464
7920
  }
7465
7921
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.22", ngImport: i0, type: DynamicFormComponent, decorators: [{
7466
7922
  type: Component,
7467
- args: [{ changeDetection: ChangeDetectionStrategy.OnPush, selector: 'ngx-dynamic-form', standalone: true, imports: [ReactiveFormsModule, DynamicFieldComponent, NgComponentOutlet], providers: [EntityRefSelectionService], template: "@if (canView) {\n <form [formGroup]=\"form\" (ngSubmit)=\"submit()\" class=\"ngx-form\" [class.ngx-form--readonly]=\"readonly\" novalidate>\n <!-- Error message banner -->\n @if (error) {\n <div class=\"ngx-form__error-banner\" role=\"alert\" data-testid=\"form-error\">{{ error }}</div>\n }\n\n <!--\n Why the last Save did nothing.\n Only after a refused submit \u2014 see `submitAttempted`. Each entry jumps to its field,\n switching tabs on the way, because the field is usually not the one on screen.\n -->\n @if (submitAttempted() && invalidFields().length > 0) {\n <div class=\"ngx-form__error-summary\" role=\"alert\" data-testid=\"error-summary\" tabindex=\"-1\">\n <!--\n `error-summary-heading`, not `-title`: the links below are `error-summary-<fieldId>`,\n and a field whose id happens to be `title` would then share a test id with this line.\n The `extensions` entity has exactly such a field.\n -->\n <p class=\"ngx-form__error-summary-title\" data-testid=\"error-summary-heading\">\n {{ ui.text('errorSummaryTitle', language, { count: invalidFields().length }) }}\n </p>\n <ul class=\"ngx-form__error-summary-list\">\n @for (entry of invalidFields(); track entry.field.id) {\n <li>\n <!--\n The field, and what is wrong with it \u2014 the same sentence the field renders\n under itself, so the summary is not a second opinion. Naming only the field\n left the user to visit each one to find out what it wanted.\n -->\n <button\n type=\"button\"\n class=\"ngx-form__error-summary-link\"\n [attr.data-testid]=\"'error-summary-' + entry.field.id\"\n (click)=\"jumpToField(entry.field.id)\"\n >\n <span class=\"ngx-form__error-summary-field\">{{ resolveFieldLabel(entry.field) }}</span>\n @if (entry.message) {\n <span class=\"ngx-form__error-summary-reason\">{{ entry.message }}</span>\n }\n </button>\n </li>\n }\n </ul>\n </div>\n }\n\n <!-- Rules Info Banners -->\n @for (infoKey of showInfoBanners ? Object.keys(ruleResult().infoBanners) : []; track infoKey) {\n <div class=\"ngx-form__info-banner\" role=\"status\" [attr.data-testid]=\"'info-banner-' + infoKey\">\n \u2139\uFE0F {{ ruleResult().infoBanners[infoKey] }}\n </div>\n }\n\n <!-- Rules Validation Errors & Warnings -->\n @for (errKey of Object.keys(ruleResult().validationErrors); track errKey) {\n <div class=\"ngx-form__error-banner\" role=\"alert\" [attr.data-testid]=\"'rule-error-' + errKey\">\n \u26A0\uFE0F {{ ruleResult().validationErrors[errKey] }}\n </div>\n }\n @for (warnKey of Object.keys(ruleResult().validationWarnings); track warnKey) {\n <div class=\"ngx-form__warning-banner\" role=\"status\" [attr.data-testid]=\"'rule-warning-' + warnKey\">\n \u26A1 {{ ruleResult().validationWarnings[warnKey] }}\n </div>\n }\n\n <!-- Deferred VALUE_CHANGED notice for criticalField edits (vs session baseline) -->\n @if (changedCriticalFields().length > 0) {\n <div class=\"ngx-form__warning-banner\" role=\"status\" data-testid=\"critical-change-banner\">\n {{ ui.text('criticalFieldChanged', language, { fields: changedCriticalFieldLabels() }) }}\n </div>\n }\n\n <!-- Primary Tab bar \u2014 only if visible tabs defined -->\n @if (visibleTabs.length > 0) {\n <div class=\"ngx-form__tabs\" role=\"tablist\" data-testid=\"tab-strip\">\n @for (tab of visibleTabs; track tab.id) {\n <button\n type=\"button\"\n class=\"ngx-form__tab\"\n [attr.data-testid]=\"'tab-' + tab.id\"\n [class.ngx-form__tab--active]=\"activeTab() === tab.id\"\n (click)=\"setActiveTab(tab.id)\"\n role=\"tab\"\n [attr.aria-selected]=\"activeTab() === tab.id\"\n >\n {{ resolveTabLabel(tab) }}\n <!--\n How many fields on this tab are stopping the save.\n The count is in the accessible name rather than the badge, because \"3\" read out\n after a tab name says nothing about what it counts.\n -->\n @if (tabErrorCount(tab); as errors) {\n <span\n class=\"ngx-form__tab-badge\"\n [attr.data-testid]=\"'tab-errors-' + tab.id\"\n [attr.aria-label]=\"ui.text('tabErrorCount', language, { count: errors })\"\n >{{ errors }}</span\n >\n }\n </button>\n }\n </div>\n }\n\n <!-- Sub-tab bar \u2014 only if active tab has sub-tabs -->\n @if (visibleSubTabs.length > 0) {\n <div\n class=\"ngx-form__tabs ngx-form__tabs--sub\"\n role=\"tablist\"\n data-testid=\"subtab-strip\"\n style=\"margin-top: -12px; margin-bottom: 12px; font-size: 13px\"\n >\n @for (subTab of visibleSubTabs; track subTab.id) {\n <button\n type=\"button\"\n class=\"ngx-form__tab ngx-form__tab--sub\"\n [attr.data-testid]=\"'subtab-' + subTab.id\"\n [class.ngx-form__tab--active]=\"activeSubTabConfig?.id === subTab.id\"\n (click)=\"setActiveSubTab(subTab.id)\"\n role=\"tab\"\n [attr.aria-selected]=\"activeSubTabConfig?.id === subTab.id\"\n >\n {{ resolveTabLabel(subTab) }}\n @if (subTabErrorCount(subTab); as errors) {\n <span\n class=\"ngx-form__tab-badge\"\n [attr.data-testid]=\"'subtab-errors-' + subTab.id\"\n [attr.aria-label]=\"ui.text('tabErrorCount', language, { count: errors })\"\n >{{ errors }}</span\n >\n }\n </button>\n }\n </div>\n }\n\n <!--\n One tabpanel per tab, wrapping both branches. Focus moves here on a tab switch, and a\n module tab is still a tab \u2014 putting the panel semantics on only the fields branch left\n a module tab with nowhere for focus to land.\n -->\n <div role=\"tabpanel\" tabindex=\"-1\" #formPanel class=\"ngx-form__tabpanel\" [attr.aria-label]=\"activeTabLabel\">\n @if (activeTabModuleComponent) {\n <div class=\"ngx-form__module-panel\" data-testid=\"module-panel\">\n <ng-container\n *ngComponentOutlet=\"activeTabModuleComponent; inputs: (activeSubTabConfig || activeTabConfig)?.moduleInputs\"\n ></ng-container>\n </div>\n } @else {\n <!-- Fields for active tab (Responsive 12-column grid) -->\n <div class=\"ngx-form__panel\" data-testid=\"form-panel\">\n @for (field of fieldsForActiveTab; track field.id) {\n <div\n class=\"ngx-form__field-slot\"\n [style.gridColumn]=\"getFieldSpan(field)\"\n [attr.id]=\"'field-container-' + field.id\"\n tabindex=\"-1\"\n >\n <ngx-dynamic-field\n [field]=\"field\"\n [control]=\"getControl(field.id, (activeSubTabConfig || activeTabConfig)?.id)!\"\n [config]=\"config\"\n [currentTabId]=\"(activeSubTabConfig || activeTabConfig)?.id\"\n [language]=\"language\"\n [readonly]=\"isFieldReadonly(field)\"\n [userRoles]=\"userRoles\"\n />\n @if (field.hasDrift) {\n <!--\n Drift was previously visible only in the builder, so a referenced field whose\n source had changed looked completely normal to the person filling the form in.\n role=\"status\" rather than \"alert\": it is information, not an error.\n -->\n <span\n class=\"ngx-field__drift\"\n role=\"status\"\n [attr.data-testid]=\"'field-' + field.id + '-drift'\"\n [title]=\"driftHint(field)\"\n >\u26A0 {{ driftHint(field) }}</span\n >\n }\n @if (field.criticalField && !readonly) {\n <button\n type=\"button\"\n class=\"ngx-form__lock\"\n [attr.data-testid]=\"'lock-' + field.id\"\n [attr.aria-pressed]=\"!isFieldLocked(field)\"\n [attr.aria-label]=\"\n ui.text(isFieldLocked(field) ? 'unlockField' : 'lockField', language, {\n field: resolveFieldLabel(field),\n })\n \"\n [title]=\"ui.text(isFieldLocked(field) ? 'criticalFieldUnlockHint' : 'criticalFieldLockHint', language)\"\n (click)=\"toggleFieldLock(field)\"\n >\n {{ isFieldLocked(field) ? '\uD83D\uDD12' : '\uD83D\uDD13' }}\n </button>\n }\n </div>\n }\n </div>\n }\n </div>\n\n <!-- Submit button \u2014 gated by RBAC and isSaving lock -->\n @if (!readonly && permissions.canEdit) {\n <div class=\"ngx-form__actions\" data-testid=\"form-actions\">\n <button type=\"button\" class=\"ngx-form__reset\" data-testid=\"form-reset\" (click)=\"reset()\" [disabled]=\"isSaving()\">\n {{ ui.text('reset', language) }}\n </button>\n <!--\n Two different \"unavailable\" states, and they are not the same thing.\n\n `disabled` is for what a retry cannot fix \u2014 a save in flight, an async check still\n outstanding. `data-blocked` is for validation: the button stays fully operable,\n because pressing it is how the user gets the error summary, but it should not look\n ready either. The stylesheet mutes it without taking the click away.\n\n Deliberately not `aria-disabled`. That announces a control as present-but-inoperable,\n and this one is operable and useful \u2014 pressing it raises a `role=\"alert\"` naming\n every field at fault, which is more than a disabled button could ever say. Tooling\n agrees: Playwright refuses to click through `aria-disabled`, and it would be right\n to, because the attribute would be claiming something untrue.\n -->\n <button\n type=\"submit\"\n class=\"ngx-form__submit\"\n data-testid=\"form-submit\"\n [disabled]=\"submitDisabled\"\n [attr.data-blocked]=\"submitBlocked ? '' : null\"\n [title]=\"ui.text('saveShortcutHint', language)\"\n >\n @if (isSaving() || loading) {\n {{ ui.text('saving', language) }}\n } @else {\n {{ ui.text('save', language) }}\n }\n </button>\n </div>\n }\n </form>\n} @else {\n <!-- permissions.view denied: render nothing of the record.\n Presentational only \u2014 the server must not send data this user may not see. -->\n <div class=\"ngx-form__denied\" role=\"status\" data-testid=\"form-access-denied\">\n {{ ui.text('accessDenied', language) }}\n </div>\n}\n", styles: [".ngx-form__panel{display:grid;grid-template-columns:repeat(12,minmax(0,1fr));gap:var(--ngx-gap, 18px);align-items:start}.ngx-form--readonly .ngx-form__panel{grid-template-columns:repeat(auto-fit,minmax(230px,1fr));gap:2px var(--ngx-gap, 18px)}.ngx-form--readonly .ngx-form__field-slot{grid-column:auto}.ngx-form__field-slot{display:flex;align-items:flex-start;gap:6px;min-width:0}.ngx-form__field-slot ngx-dynamic-field{flex:1;min-width:0}.ngx-form__lock{flex:0 0 auto;margin-top:22px;background:none;border:1px solid transparent;border-radius:var(--ngx-radius-sm, 6px);cursor:pointer;font-size:14px;line-height:1;padding:4px 6px}.ngx-form__lock:hover{border-color:var(--ngx-color-border, #d1d5db);background:var(--ngx-color-surface-alt, #f9fafb)}@media(max-width:640px){.ngx-form__panel,.ngx-form--readonly .ngx-form__panel{grid-template-columns:minmax(0,1fr)}.ngx-form__field-slot{grid-column:1 / -1!important}}\n"] }]
7923
+ args: [{ changeDetection: ChangeDetectionStrategy.OnPush, selector: 'ngx-dynamic-form', standalone: true, imports: [ReactiveFormsModule, DynamicFieldComponent, NgComponentOutlet], providers: [EntityRefSelectionService, FormStructureService], template: "@if (canView) {\n <form [formGroup]=\"form\" (ngSubmit)=\"submit()\" class=\"ngx-form\" [class.ngx-form--readonly]=\"readonly\" novalidate>\n <!-- Error message banner -->\n @if (error) {\n <div class=\"ngx-form__error-banner\" role=\"alert\" data-testid=\"form-error\">{{ error }}</div>\n }\n\n <!--\n Why the last Save did nothing.\n Only after a refused submit \u2014 see `submitAttempted`. Each entry jumps to its field,\n switching tabs on the way, because the field is usually not the one on screen.\n -->\n @if (submitAttempted() && invalidFields().length > 0) {\n <div class=\"ngx-form__error-summary\" role=\"alert\" data-testid=\"error-summary\" tabindex=\"-1\">\n <!--\n `error-summary-heading`, not `-title`: the links below are `error-summary-<fieldId>`,\n and a field whose id happens to be `title` would then share a test id with this line.\n The `extensions` entity has exactly such a field.\n -->\n <p class=\"ngx-form__error-summary-title\" data-testid=\"error-summary-heading\">\n {{ ui.text('errorSummaryTitle', language, { count: invalidFields().length }) }}\n </p>\n <ul class=\"ngx-form__error-summary-list\">\n @for (entry of invalidFields(); track entry.field.id) {\n <li>\n <!--\n The field, and what is wrong with it \u2014 the same sentence the field renders\n under itself, so the summary is not a second opinion. Naming only the field\n left the user to visit each one to find out what it wanted.\n -->\n <button\n type=\"button\"\n class=\"ngx-form__error-summary-link\"\n [attr.data-testid]=\"'error-summary-' + entry.field.id\"\n (click)=\"jumpToField(entry.field.id)\"\n >\n <span class=\"ngx-form__error-summary-field\">{{ resolveFieldLabel(entry.field) }}</span>\n @if (entry.message) {\n <span class=\"ngx-form__error-summary-reason\">{{ entry.message }}</span>\n }\n </button>\n </li>\n }\n </ul>\n </div>\n }\n\n <!-- Rules Info Banners -->\n @for (infoKey of showInfoBanners ? Object.keys(ruleResult().infoBanners) : []; track infoKey) {\n <div class=\"ngx-form__info-banner\" role=\"status\" [attr.data-testid]=\"'info-banner-' + infoKey\">\n \u2139\uFE0F {{ ruleResult().infoBanners[infoKey] }}\n </div>\n }\n\n <!-- Rules Validation Errors & Warnings -->\n @for (errKey of Object.keys(ruleResult().validationErrors); track errKey) {\n <div class=\"ngx-form__error-banner\" role=\"alert\" [attr.data-testid]=\"'rule-error-' + errKey\">\n \u26A0\uFE0F {{ ruleResult().validationErrors[errKey] }}\n </div>\n }\n @for (warnKey of Object.keys(ruleResult().validationWarnings); track warnKey) {\n <div class=\"ngx-form__warning-banner\" role=\"status\" [attr.data-testid]=\"'rule-warning-' + warnKey\">\n \u26A1 {{ ruleResult().validationWarnings[warnKey] }}\n </div>\n }\n\n <!-- Deferred VALUE_CHANGED notice for criticalField edits (vs session baseline) -->\n @if (changedCriticalFields().length > 0) {\n <div class=\"ngx-form__warning-banner\" role=\"status\" data-testid=\"critical-change-banner\">\n {{ ui.text('criticalFieldChanged', language, { fields: changedCriticalFieldLabels() }) }}\n </div>\n }\n\n <!-- Primary Tab bar \u2014 only if visible tabs defined -->\n @if (visibleTabs.length > 0) {\n <div class=\"ngx-form__tabs\" role=\"tablist\" data-testid=\"tab-strip\">\n @for (tab of visibleTabs; track tab.id) {\n <button\n type=\"button\"\n class=\"ngx-form__tab\"\n [attr.data-testid]=\"'tab-' + tab.id\"\n [class.ngx-form__tab--active]=\"activeTab() === tab.id\"\n (click)=\"setActiveTab(tab.id)\"\n role=\"tab\"\n [attr.aria-selected]=\"activeTab() === tab.id\"\n >\n {{ resolveTabLabel(tab) }}\n <!--\n How many fields on this tab are stopping the save.\n The count is in the accessible name rather than the badge, because \"3\" read out\n after a tab name says nothing about what it counts.\n -->\n @if (tabErrorCount(tab); as errors) {\n <span\n class=\"ngx-form__tab-badge\"\n [attr.data-testid]=\"'tab-errors-' + tab.id\"\n [attr.aria-label]=\"ui.text('tabErrorCount', language, { count: errors })\"\n >{{ errors }}</span\n >\n }\n </button>\n }\n </div>\n }\n\n <!-- Sub-tab bar \u2014 only if active tab has sub-tabs -->\n @if (visibleSubTabs.length > 0) {\n <div\n class=\"ngx-form__tabs ngx-form__tabs--sub\"\n role=\"tablist\"\n data-testid=\"subtab-strip\"\n style=\"margin-top: -12px; margin-bottom: 12px; font-size: 13px\"\n >\n @for (subTab of visibleSubTabs; track subTab.id) {\n <button\n type=\"button\"\n class=\"ngx-form__tab ngx-form__tab--sub\"\n [attr.data-testid]=\"'subtab-' + subTab.id\"\n [class.ngx-form__tab--active]=\"activeSubTabConfig?.id === subTab.id\"\n (click)=\"setActiveSubTab(subTab.id)\"\n role=\"tab\"\n [attr.aria-selected]=\"activeSubTabConfig?.id === subTab.id\"\n >\n {{ resolveTabLabel(subTab) }}\n @if (subTabErrorCount(subTab); as errors) {\n <span\n class=\"ngx-form__tab-badge\"\n [attr.data-testid]=\"'subtab-errors-' + subTab.id\"\n [attr.aria-label]=\"ui.text('tabErrorCount', language, { count: errors })\"\n >{{ errors }}</span\n >\n }\n </button>\n }\n </div>\n }\n\n <!--\n One tabpanel per tab, wrapping both branches. Focus moves here on a tab switch, and a\n module tab is still a tab \u2014 putting the panel semantics on only the fields branch left\n a module tab with nowhere for focus to land.\n -->\n <div role=\"tabpanel\" tabindex=\"-1\" #formPanel class=\"ngx-form__tabpanel\" [attr.aria-label]=\"activeTabLabel\">\n @if (activeTabModuleComponent) {\n <div class=\"ngx-form__module-panel\" data-testid=\"module-panel\">\n <ng-container\n *ngComponentOutlet=\"activeTabModuleComponent; inputs: (activeSubTabConfig || activeTabConfig)?.moduleInputs\"\n ></ng-container>\n </div>\n } @else {\n <!-- Fields for active tab (Responsive 12-column grid) -->\n <div class=\"ngx-form__panel\" data-testid=\"form-panel\">\n @for (field of fieldsForActiveTab; track field.id) {\n <div\n class=\"ngx-form__field-slot\"\n [style.gridColumn]=\"getFieldSpan(field)\"\n [attr.id]=\"'field-container-' + field.id\"\n tabindex=\"-1\"\n >\n <ngx-dynamic-field\n [field]=\"field\"\n [control]=\"getControl(field.id, (activeSubTabConfig || activeTabConfig)?.id)!\"\n [config]=\"config\"\n [currentTabId]=\"(activeSubTabConfig || activeTabConfig)?.id\"\n [language]=\"language\"\n [readonly]=\"isFieldReadonly(field)\"\n [userRoles]=\"userRoles\"\n />\n @if (field.hasDrift) {\n <!--\n Drift was previously visible only in the builder, so a referenced field whose\n source had changed looked completely normal to the person filling the form in.\n role=\"status\" rather than \"alert\": it is information, not an error.\n -->\n <span\n class=\"ngx-field__drift\"\n role=\"status\"\n [attr.data-testid]=\"'field-' + field.id + '-drift'\"\n [title]=\"driftHint(field)\"\n >\u26A0 {{ driftHint(field) }}</span\n >\n }\n @if (field.criticalField && !readonly) {\n <button\n type=\"button\"\n class=\"ngx-form__lock\"\n [attr.data-testid]=\"'lock-' + field.id\"\n [attr.aria-pressed]=\"!isFieldLocked(field)\"\n [attr.aria-label]=\"\n ui.text(isFieldLocked(field) ? 'unlockField' : 'lockField', language, {\n field: resolveFieldLabel(field),\n })\n \"\n [title]=\"ui.text(isFieldLocked(field) ? 'criticalFieldUnlockHint' : 'criticalFieldLockHint', language)\"\n (click)=\"toggleFieldLock(field)\"\n >\n {{ isFieldLocked(field) ? '\uD83D\uDD12' : '\uD83D\uDD13' }}\n </button>\n }\n </div>\n }\n </div>\n }\n </div>\n\n <!-- Submit button \u2014 gated by RBAC and isSaving lock -->\n @if (!readonly && permissions.canEdit) {\n <div class=\"ngx-form__actions\" data-testid=\"form-actions\">\n <button type=\"button\" class=\"ngx-form__reset\" data-testid=\"form-reset\" (click)=\"reset()\" [disabled]=\"isSaving()\">\n {{ ui.text('reset', language) }}\n </button>\n <!--\n Two different \"unavailable\" states, and they are not the same thing.\n\n `disabled` is for what a retry cannot fix \u2014 a save in flight, an async check still\n outstanding. `data-blocked` is for validation: the button stays fully operable,\n because pressing it is how the user gets the error summary, but it should not look\n ready either. The stylesheet mutes it without taking the click away.\n\n Deliberately not `aria-disabled`. That announces a control as present-but-inoperable,\n and this one is operable and useful \u2014 pressing it raises a `role=\"alert\"` naming\n every field at fault, which is more than a disabled button could ever say. Tooling\n agrees: Playwright refuses to click through `aria-disabled`, and it would be right\n to, because the attribute would be claiming something untrue.\n -->\n <button\n type=\"submit\"\n class=\"ngx-form__submit\"\n data-testid=\"form-submit\"\n [disabled]=\"submitDisabled\"\n [attr.data-blocked]=\"submitBlocked ? '' : null\"\n [title]=\"ui.text('saveShortcutHint', language)\"\n >\n @if (isSaving() || loading) {\n {{ ui.text('saving', language) }}\n } @else {\n {{ ui.text('save', language) }}\n }\n </button>\n </div>\n }\n </form>\n} @else {\n <!-- permissions.view denied: render nothing of the record.\n Presentational only \u2014 the server must not send data this user may not see. -->\n <div class=\"ngx-form__denied\" role=\"status\" data-testid=\"form-access-denied\">\n {{ ui.text('accessDenied', language) }}\n </div>\n}\n", styles: [".ngx-form__panel{display:grid;grid-template-columns:repeat(12,minmax(0,1fr));gap:var(--ngx-gap, 18px);align-items:start}.ngx-form--readonly .ngx-form__panel{grid-template-columns:repeat(auto-fit,minmax(230px,1fr));gap:2px var(--ngx-gap, 18px)}.ngx-form--readonly .ngx-form__field-slot{grid-column:auto}.ngx-form__field-slot{display:flex;align-items:flex-start;gap:6px;min-width:0}.ngx-form__field-slot ngx-dynamic-field{flex:1;min-width:0}.ngx-form__lock{flex:0 0 auto;margin-top:22px;background:none;border:1px solid transparent;border-radius:var(--ngx-radius-sm, 6px);cursor:pointer;font-size:14px;line-height:1;padding:4px 6px}.ngx-form__lock:hover{border-color:var(--ngx-color-border, #d1d5db);background:var(--ngx-color-surface-alt, #f9fafb)}@media(max-width:640px){.ngx-form__panel,.ngx-form--readonly .ngx-form__panel{grid-template-columns:minmax(0,1fr)}.ngx-form__field-slot{grid-column:1 / -1!important}}\n"] }]
7468
7924
  }], propDecorators: { config: [{
7469
7925
  type: Input
7470
7926
  }], rules: [{
@@ -8127,11 +8583,1476 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.22", ngImpo
8127
8583
  args: [DynamicFormComponent]
8128
8584
  }] } });
8129
8585
 
8586
+ /**
8587
+ * import-contracts.ts — the two seams the import wizard hangs on.
8588
+ *
8589
+ * Both follow the pattern the rest of this package already uses for anything the library
8590
+ * cannot decide for a consumer — `FileUploadHandler`, `EntityReferenceLoader`,
8591
+ * `LookupListLoader`: a function type here, a token to register it against, and a default
8592
+ * that works without registering anything.
8593
+ *
8594
+ * **Why two rather than one.** Reading a file and deciding where the work happens are
8595
+ * different questions with different answers. A consumer may want `.xlsx` support in a
8596
+ * wizard that still runs entirely in the browser, or plain CSV that is nonetheless processed
8597
+ * on a server because their files have fifty thousand rows. Collapsing them into one option
8598
+ * would make each answer imply the other.
8599
+ */
8600
+
8601
+ /**
8602
+ * sheet-parser.ts — the built-in CSV reader, and the reason it is the only one.
8603
+ *
8604
+ * CSV is what every spreadsheet tool exports and what a text editor can produce, and
8605
+ * `@dynamic-entity/core` parses it with no dependency. Shipping that as the default means the
8606
+ * import wizard works on a fresh `npm install` with nothing registered — `SHEET_PARSER` then
8607
+ * exists for the consumer who *also* wants `.xlsx`, rather than for the consumer who wants
8608
+ * the feature to work at all.
8609
+ *
8610
+ * The alternative was a hard dependency on a spreadsheet library in a package that currently
8611
+ * has none, for a file format many consumers will never see.
8612
+ */
8613
+ /** Extensions the built-in parser will attempt. */
8614
+ const TEXT_EXTENSIONS = /\.(csv|tsv|txt)$/i;
8615
+ /**
8616
+ * Read a text file as CSV.
8617
+ *
8618
+ * A binary workbook is refused by name rather than attempted: `.xlsx` is a zip, so reading it
8619
+ * as text yields a header row of mojibake and a mapping screen full of nonsense, which is a
8620
+ * much harder thing to diagnose than being told the format is not supported.
8621
+ */
8622
+ const defaultSheetParser = async (file) => {
8623
+ if (file?.name && !TEXT_EXTENSIONS.test(file.name)) {
8624
+ throw new Error(`Cannot read "${file.name}". The built-in parser handles CSV only — register a ` +
8625
+ 'sheetParser with provideNgxDynamicEntity({ sheetParser }) to accept other formats.');
8626
+ }
8627
+ return parseCsv(await file.text());
8628
+ };
8629
+
8630
+ /**
8631
+ * local-import-transport.ts — the whole import, in the browser, with no server.
8632
+ *
8633
+ * This is the default `IMPORT_TRANSPORT`, and it is a thin arrangement of pure functions
8634
+ * from `@dynamic-entity/core` rather than an implementation of anything. That matters: a
8635
+ * consumer who later registers a server transport is not switching to a second set of
8636
+ * mapping rules, they are moving the same ones to a different machine.
8637
+ *
8638
+ * Its limit is the one every browser has — the file is read into memory whole. That is fine
8639
+ * for the sizes a person assembles by hand and wrong for a fifty-thousand-row export, which
8640
+ * is what a streaming server transport is for.
8641
+ */
8642
+ /** How many rows the mapping screen shows under each column. */
8643
+ const SAMPLE_ROWS = 5;
8644
+ class LocalImportTransport {
8645
+ registered = inject(SHEET_PARSER, { optional: true });
8646
+ /**
8647
+ * One parse per file, not one per call.
8648
+ *
8649
+ * A wizard run reads the same file twice — once to show its headers, once to import it —
8650
+ * and this is the transport whose stated limit is that it holds the file in memory. Doing
8651
+ * the work twice doubled both the parse and the peak memory of the one implementation that
8652
+ * can least afford it.
8653
+ *
8654
+ * A `WeakMap` keyed on the `File` itself: the entry lives exactly as long as something
8655
+ * still holds the file, so choosing a different file drops the old sheet without anything
8656
+ * having to remember to. Keying on a name or a size would collide between two files a user
8657
+ * picked in sequence.
8658
+ */
8659
+ parsed = new WeakMap();
8660
+ /** The registered parser, or the built-in CSV one. */
8661
+ read(file) {
8662
+ const cached = this.parsed.get(file);
8663
+ if (cached)
8664
+ return cached;
8665
+ // The *promise* is cached, not its result, so two calls that overlap share one parse
8666
+ // rather than starting a second before the first resolves.
8667
+ const pending = Promise.resolve((this.registered ?? defaultSheetParser)(file));
8668
+ this.parsed.set(file, pending);
8669
+ // A failed parse must not be remembered: the user may fix the file and pick it again.
8670
+ pending.catch(() => this.parsed.delete(file));
8671
+ return pending;
8672
+ }
8673
+ async preview(file, context) {
8674
+ const sheet = await this.read(file);
8675
+ const { columns } = deriveImportColumns(context.config, { lang: context.lang });
8676
+ return {
8677
+ headers: sheet.headers,
8678
+ sample: sheet.rows.slice(0, SAMPLE_ROWS),
8679
+ rowCount: sheet.rows.length,
8680
+ suggestion: suggestMapping(sheet.headers, columns, context.config?.entity ?? ''),
8681
+ };
8682
+ }
8683
+ async commit(file, plan, context) {
8684
+ const sheet = await this.read(file);
8685
+ return applyMapping(sheet.rows, plan, context.config, {
8686
+ lang: context.lang,
8687
+ rules: context.rules,
8688
+ lookups: context.lookups,
8689
+ });
8690
+ }
8691
+ async template(spec, format) {
8692
+ if (format === 'xlsx') {
8693
+ // Deliberately not a silent fallback to CSV. A file named `.xlsx` that is really CSV
8694
+ // opens with a warning in Excel and imports wrongly elsewhere, and the consumer would
8695
+ // have no idea why — where this message says exactly which seam to fill.
8696
+ throw new Error('This build can only write CSV templates. Register an IMPORT_TRANSPORT that writes ' +
8697
+ 'xlsx — see provideNgxDynamicEntity({ importTransport }) — or request csv.');
8698
+ }
8699
+ // Headers only, and `spec.notes` deliberately left out of the file.
8700
+ //
8701
+ // A guidance row under the headers reads well and breaks the round trip: CSV has one
8702
+ // header row and no concept of a second, so re-importing the template the user filled in
8703
+ // would treat the guidance as a record and report a failed row 2 on every single import.
8704
+ // The notes are shown beside each column in the picker instead, where they can be read
8705
+ // without being parsed. A format with somewhere to put them — an xlsx cell comment, a
8706
+ // second sheet — can carry them; CSV has nowhere that is not data.
8707
+ const csv = toCsv(spec.columns.map(column => column.header));
8708
+ return new Blob([csv], { type: 'text/csv;charset=utf-8' });
8709
+ }
8710
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.22", ngImport: i0, type: LocalImportTransport, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
8711
+ static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "21.2.22", ngImport: i0, type: LocalImportTransport, providedIn: 'root' });
8712
+ }
8713
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.22", ngImport: i0, type: LocalImportTransport, decorators: [{
8714
+ type: Injectable,
8715
+ args: [{ providedIn: 'root' }]
8716
+ }] });
8717
+
8718
+ /**
8719
+ * http-import-transport.ts — the same wizard, with the work happening on a server.
8720
+ *
8721
+ * The components do not change at all: they already talk to an `ImportTransport`, which was
8722
+ * the point of the seam. Registering this one moves the mapping, coercion and validation from
8723
+ * the tab to a server that runs the *same* `@dynamic-entity/core` functions — so the reason to
8724
+ * do it is size, not behaviour.
8725
+ *
8726
+ * **Built on `fetch`, not `HttpClient`.** This package has no `@angular/common/http` dependency
8727
+ * today, and four requests is not the reason to acquire one — a consumer who wants interceptors
8728
+ * passes their own `fetch`, which is a smaller seam than a dependency.
8729
+ */
8730
+ /** `Content-Type` is deliberately not settable: `fetch` must set the multipart boundary. */
8731
+ function resolveHeaders(options) {
8732
+ const headers = typeof options.headers === 'function' ? options.headers() : options.headers ?? {};
8733
+ const out = {};
8734
+ for (const [name, value] of Object.entries(headers)) {
8735
+ if (name.toLowerCase() === 'content-type')
8736
+ continue;
8737
+ out[name] = value;
8738
+ }
8739
+ return out;
8740
+ }
8741
+ const trimSlash = (url) => String(url ?? '').replace(/\/+$/, '');
8742
+ /** Same major and minor. Two engines a patch apart are the same engine. */
8743
+ function sameLine(a, b) {
8744
+ const line = (version) => version.split('.').slice(0, 2).join('.');
8745
+ return line(a) === line(b);
8746
+ }
8747
+ /**
8748
+ * Turn a failed response into an `Error` the wizard can show.
8749
+ *
8750
+ * The server's envelope carries a code and a message it wrote itself; anything else — a proxy's
8751
+ * HTML error page, a network failure — has no message worth showing, so a status line is what
8752
+ * the user gets rather than a page of markup.
8753
+ */
8754
+ async function failureOf(response) {
8755
+ let body;
8756
+ try {
8757
+ body = (await response.json());
8758
+ }
8759
+ catch {
8760
+ /* not our envelope */
8761
+ }
8762
+ const message = body?.error?.message;
8763
+ return new Error(message || `The import server answered ${response.status}.`);
8764
+ }
8765
+ /**
8766
+ * The field selection, as short as it can be said without changing what it means.
8767
+ *
8768
+ * Two reductions, and both are exact rather than approximate:
8769
+ *
8770
+ * - **Row numbers come off.** `buildTemplateSpec` matches a column by its ref *or* by its ref
8771
+ * with indices stripped, and the picker that produced this selection worked in stripped refs
8772
+ * to begin with — so `contacts.0.email, contacts.1.email, contacts.2.email` is the expansion
8773
+ * of one choice, and collapsing it back recovers exactly what the user ticked.
8774
+ * - **A full selection is sent as no selection at all**, because omitting `fields` already
8775
+ * means every column.
8776
+ *
8777
+ * Without them a wide config produced a query string of ten kilobytes or more — past the eight
8778
+ * nginx and Node default to — and the request most likely to hit it is "give me a template with
8779
+ * everything", which is the one this now sends as a bare `?format=`.
8780
+ */
8781
+ function selectionOf(spec, context, fields) {
8782
+ // The caller's own selection when it gave one — the wizard does — and the expanded spec
8783
+ // reduced back to it otherwise, for a host calling `template` directly.
8784
+ const chosen = new Set(fields ?? spec.columns.map(column => stripIndices(column.ref)));
8785
+ const all = new Set(deriveImportColumns(context?.config, { lang: context?.lang }).columns.map(column => stripIndices(column.ref)));
8786
+ if (all.size && chosen.size === all.size && [...all].every(ref => chosen.has(ref)))
8787
+ return '';
8788
+ return [...chosen].join(',');
8789
+ }
8790
+ /**
8791
+ * Not `@Injectable`, and registered by value rather than by class.
8792
+ *
8793
+ * Its constructor takes options rather than dependencies, so there is nothing for an injector
8794
+ * to resolve — and a consumer who wants two of them, pointed at two backends, gets that for
8795
+ * free.
8796
+ */
8797
+ class HttpImportTransport {
8798
+ options;
8799
+ constructor(options) {
8800
+ this.options = options;
8801
+ }
8802
+ get base() {
8803
+ return trimSlash(this.options.baseUrl);
8804
+ }
8805
+ get send() {
8806
+ // Bound rather than referenced: a bare `globalThis.fetch` passed around as a value throws
8807
+ // "Illegal invocation" in a browser, because it needs `window` as its receiver.
8808
+ return this.options.fetch ?? globalThis.fetch.bind(globalThis);
8809
+ }
8810
+ entityOf(context) {
8811
+ return encodeURIComponent(context?.config?.entity ?? '');
8812
+ }
8813
+ /**
8814
+ * Warn when the server's engine is not the one this bundle was built against.
8815
+ *
8816
+ * The two halves are separately deployed and may differ. `MappingPlan.configVersion` catches
8817
+ * *config* drift and says nothing about *engine* drift, so "one engine, both sides" is only
8818
+ * true if something checks — and this is the something. A warning rather than a refusal: a
8819
+ * patch release is not a reason to stop somebody importing.
8820
+ */
8821
+ checkEngine(version) {
8822
+ // Compared at major.minor. A patch difference between two deploys is the normal state of
8823
+ // a rolling release and says nothing about whether the engines agree; warning on it puts a
8824
+ // line in the console on every preview and every commit, which is how people learn to
8825
+ // scroll past the warning that does matter.
8826
+ if (!version || sameLine(version, CORE_VERSION))
8827
+ return;
8828
+ const warn = this.options.warn ?? ((message) => console.warn(message));
8829
+ warn(`[ngx-dynamic-entity] The import server runs @dynamic-entity/core ${version}; this app ` +
8830
+ `was built against ${CORE_VERSION}. The two halves of an import are supposed to be the ` +
8831
+ 'same engine — bring them into step before trusting a difference in what they produce.');
8832
+ }
8833
+ async preview(file, context) {
8834
+ const body = new FormData();
8835
+ body.append('file', file, file.name || 'upload');
8836
+ const response = await this.send(`${this.base}/${this.entityOf(context)}/preview`, {
8837
+ method: 'POST',
8838
+ headers: resolveHeaders(this.options),
8839
+ body,
8840
+ });
8841
+ if (!response.ok)
8842
+ throw await failureOf(response);
8843
+ const preview = (await response.json());
8844
+ this.checkEngine(preview.engineVersion);
8845
+ return {
8846
+ headers: preview.headers ?? [],
8847
+ sample: preview.sample ?? [],
8848
+ suggestion: preview.suggestion,
8849
+ rowCount: preview.rowCount ?? 0,
8850
+ };
8851
+ }
8852
+ async commit(file, plan, context) {
8853
+ const body = new FormData();
8854
+ // **The plan first, and the file second.** The server starts importing while the file is
8855
+ // still arriving — that is the whole reason it exists — so the plan has to have arrived
8856
+ // already. `FormData` preserves append order, so this ordering is the contract.
8857
+ body.append('plan', JSON.stringify(plan));
8858
+ body.append('file', file, file.name || 'upload');
8859
+ const response = await this.send(`${this.base}/${this.entityOf(context)}/import`, {
8860
+ method: 'POST',
8861
+ headers: resolveHeaders(this.options),
8862
+ body,
8863
+ });
8864
+ if (!response.ok)
8865
+ throw await failureOf(response);
8866
+ const result = (await response.json());
8867
+ this.checkEngine(result.engineVersion);
8868
+ return {
8869
+ // Empty, and not because nothing was imported. The server streamed the file precisely so
8870
+ // that fifty thousand records never had to exist at once; sending them back would undo
8871
+ // that on the way out. `imported` is the count, and the records are in the consumer's
8872
+ // database.
8873
+ records: [],
8874
+ imported: result.imported ?? 0,
8875
+ skipped: result.skipped ?? 0,
8876
+ failed: result.failed ?? 0,
8877
+ errors: result.errors ?? [],
8878
+ errorCount: result.errorCount ?? result.errors?.length ?? 0,
8879
+ truncated: result.truncated ?? false,
8880
+ planProblems: result.planProblems ?? [],
8881
+ };
8882
+ }
8883
+ async template(spec, format, context, fields) {
8884
+ // The server rebuilds the spec from its own config; only the selection crosses the wire.
8885
+ // Posting a whole spec would let a client choose the headers of a file the server signs
8886
+ // its name to, and the server has the config anyway.
8887
+ const query = new URLSearchParams({ format });
8888
+ const selection = selectionOf(spec, context, fields);
8889
+ if (selection)
8890
+ query.set('fields', selection);
8891
+ const response = await this.send(`${this.base}/${this.entityOf(context)}/template?${query.toString()}`, { method: 'GET', headers: resolveHeaders(this.options) });
8892
+ if (!response.ok)
8893
+ throw await failureOf(response);
8894
+ return response.blob();
8895
+ }
8896
+ }
8897
+ /**
8898
+ * Register the HTTP transport.
8899
+ *
8900
+ * ```ts
8901
+ * provideNgxDynamicEntity({}),
8902
+ * provideHttpImportTransport({ baseUrl: '/api/import' }),
8903
+ * ```
8904
+ *
8905
+ * A plain `Provider` rather than `EnvironmentProviders`, so it composes inside a component's
8906
+ * or a route's `providers` array as well as at the root — which is how a consumer points one
8907
+ * part of an app at a different backend.
8908
+ */
8909
+ function provideHttpImportTransport(options) {
8910
+ return { provide: IMPORT_TRANSPORT, useValue: new HttpImportTransport(options) };
8911
+ }
8912
+
8913
+ /**
8914
+ * The rows an import could not take, and why.
8915
+ *
8916
+ * Grouped by row rather than listed flat: a row with four problems is one row to go and fix,
8917
+ * and four separate lines invite the reader to think there are four. The row number is the
8918
+ * one the spreadsheet shows in its gutter — header counted as row 1 — because that is the
8919
+ * number they will scroll to.
8920
+ */
8921
+ class ImportErrorsComponent {
8922
+ errors = [];
8923
+ language = 'en';
8924
+ ui = inject(UiTextService);
8925
+ /** Row number → its problems, in the order the rows appear in the file. */
8926
+ grouped() {
8927
+ const byRow = new Map();
8928
+ for (const error of this.errors) {
8929
+ const list = byRow.get(error.row) ?? [];
8930
+ list.push(error);
8931
+ byRow.set(error.row, list);
8932
+ }
8933
+ return [...byRow.entries()]
8934
+ .sort((a, b) => a[0] - b[0])
8935
+ .map(([row, problems]) => ({ row, problems }));
8936
+ }
8937
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.22", ngImport: i0, type: ImportErrorsComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
8938
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.22", type: ImportErrorsComponent, isStandalone: true, selector: "ngx-import-errors", inputs: { errors: "errors", language: "language" }, ngImport: i0, template: `
8939
+ @if (errors.length) {
8940
+ <section class="ngx-import-errors" data-testid="import-errors">
8941
+ <h4 class="ngx-import-errors__heading">{{ ui.text('importErrorsHeading', language) }}</h4>
8942
+ <table class="ngx-import-errors__table">
8943
+ <thead>
8944
+ <tr>
8945
+ <th scope="col">{{ ui.text('importErrorRow', language, { row: '#' }) }}</th>
8946
+ <th scope="col">{{ ui.text('importErrorField', language) }}</th>
8947
+ <th scope="col">{{ ui.text('importErrorReason', language) }}</th>
8948
+ </tr>
8949
+ </thead>
8950
+ <tbody>
8951
+ @for (group of grouped(); track group.row) {
8952
+ @for (problem of group.problems; track $index) {
8953
+ <tr [attr.data-testid]="'import-error-row-' + group.row">
8954
+ @if ($first) {
8955
+ <th scope="row" [attr.rowspan]="group.problems.length">
8956
+ {{ ui.text('importErrorRow', language, { row: group.row }) }}
8957
+ </th>
8958
+ }
8959
+ <td>{{ problem.ref }}</td>
8960
+ <td>{{ problem.message }}</td>
8961
+ </tr>
8962
+ }
8963
+ }
8964
+ </tbody>
8965
+ </table>
8966
+ </section>
8967
+ }
8968
+ `, isInline: true, styles: [".ngx-import-errors__heading{margin:0 0 8px;font-size:var(--ngx-font-size, 14px);color:var(--ngx-color-error, #b91c1c)}.ngx-import-errors__table{width:100%;border-collapse:collapse}.ngx-import-errors__table th,.ngx-import-errors__table td{text-align:left;padding:6px 10px;border-bottom:1px solid var(--ngx-color-border, #e5e7eb);vertical-align:top}.ngx-import-errors__table thead th{color:var(--ngx-color-muted, #6b7280)}.ngx-import-errors__table tbody th{white-space:nowrap;color:var(--ngx-color-error, #b91c1c)}\n"], changeDetection: i0.ChangeDetectionStrategy.OnPush });
8969
+ }
8970
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.22", ngImport: i0, type: ImportErrorsComponent, decorators: [{
8971
+ type: Component,
8972
+ args: [{ selector: 'ngx-import-errors', standalone: true, changeDetection: ChangeDetectionStrategy.OnPush, imports: [], template: `
8973
+ @if (errors.length) {
8974
+ <section class="ngx-import-errors" data-testid="import-errors">
8975
+ <h4 class="ngx-import-errors__heading">{{ ui.text('importErrorsHeading', language) }}</h4>
8976
+ <table class="ngx-import-errors__table">
8977
+ <thead>
8978
+ <tr>
8979
+ <th scope="col">{{ ui.text('importErrorRow', language, { row: '#' }) }}</th>
8980
+ <th scope="col">{{ ui.text('importErrorField', language) }}</th>
8981
+ <th scope="col">{{ ui.text('importErrorReason', language) }}</th>
8982
+ </tr>
8983
+ </thead>
8984
+ <tbody>
8985
+ @for (group of grouped(); track group.row) {
8986
+ @for (problem of group.problems; track $index) {
8987
+ <tr [attr.data-testid]="'import-error-row-' + group.row">
8988
+ @if ($first) {
8989
+ <th scope="row" [attr.rowspan]="group.problems.length">
8990
+ {{ ui.text('importErrorRow', language, { row: group.row }) }}
8991
+ </th>
8992
+ }
8993
+ <td>{{ problem.ref }}</td>
8994
+ <td>{{ problem.message }}</td>
8995
+ </tr>
8996
+ }
8997
+ }
8998
+ </tbody>
8999
+ </table>
9000
+ </section>
9001
+ }
9002
+ `, styles: [".ngx-import-errors__heading{margin:0 0 8px;font-size:var(--ngx-font-size, 14px);color:var(--ngx-color-error, #b91c1c)}.ngx-import-errors__table{width:100%;border-collapse:collapse}.ngx-import-errors__table th,.ngx-import-errors__table td{text-align:left;padding:6px 10px;border-bottom:1px solid var(--ngx-color-border, #e5e7eb);vertical-align:top}.ngx-import-errors__table thead th{color:var(--ngx-color-muted, #6b7280)}.ngx-import-errors__table tbody th{white-space:nowrap;color:var(--ngx-color-error, #b91c1c)}\n"] }]
9003
+ }], propDecorators: { errors: [{
9004
+ type: Input
9005
+ }], language: [{
9006
+ type: Input
9007
+ }] } });
9008
+
9009
+ /**
9010
+ * Match the sheet's columns to the config's fields.
9011
+ *
9012
+ * The table is driven by the **target fields**, not by the sheet's columns, which is the
9013
+ * choice that decides how the screen reads. A user is answering "where does Start Date come
9014
+ * from?", which they can do; column-first would ask "what is column G?", which they often
9015
+ * cannot, and would make a sheet with forty irrelevant columns forty questions long.
9016
+ *
9017
+ * A selection is a **column index**, never header text. Two columns headed "Notes" are two
9018
+ * different answers, and text cannot tell them apart.
9019
+ */
9020
+ class ImportMapperComponent {
9021
+ config;
9022
+ headers = [];
9023
+ /** The starting point, normally `suggestMapping`'s output. */
9024
+ plan = null;
9025
+ language = 'en';
9026
+ /** Emitted whenever the user changes a selection. */
9027
+ planChange = new EventEmitter();
9028
+ ui = inject(UiTextService);
9029
+ rows = signal([], ...(ngDevMode ? [{ debugName: "rows" }] : /* istanbul ignore next */ []));
9030
+ /**
9031
+ * How many fields a spreadsheet cannot carry at all.
9032
+ *
9033
+ * Shown here as well as in the template picker, because a user who already had a sheet
9034
+ * skipped that screen — and this is the only place they would otherwise find out that an
9035
+ * attachment column was never going to arrive.
9036
+ */
9037
+ unsupported = signal(0, ...(ngDevMode ? [{ debugName: "unsupported" }] : /* istanbul ignore next */ []));
9038
+ ngOnChanges() {
9039
+ const { columns, unsupported } = deriveImportColumns(this.config, { lang: this.language });
9040
+ this.unsupported.set(unsupported.length);
9041
+ const byRef = new Map((this.plan?.entries ?? []).map(entry => [entry.ref, entry]));
9042
+ this.rows.set(columns.map(column => {
9043
+ const entry = byRef.get(column.ref);
9044
+ return {
9045
+ column,
9046
+ source: entry?.column ?? null,
9047
+ guessed: entry?.confidence === 'guess',
9048
+ };
9049
+ }));
9050
+ }
9051
+ mappedCount() {
9052
+ return this.rows().filter(row => row.source !== null).length;
9053
+ }
9054
+ missingRequired() {
9055
+ return this.rows().filter(row => row.column.required && row.source === null).length;
9056
+ }
9057
+ choose(row, source) {
9058
+ this.rows.update(rows => rows.map(candidate => candidate.column.ref === row.column.ref
9059
+ ? // A hand-made choice is not a guess any more, so the badge goes.
9060
+ { ...candidate, source, guessed: false }
9061
+ : candidate));
9062
+ this.planChange.emit(this.toPlan());
9063
+ }
9064
+ /** The current selections as the plan that will be applied. */
9065
+ toPlan() {
9066
+ const entries = this.rows()
9067
+ .filter(row => row.source !== null)
9068
+ .map(row => ({
9069
+ ref: row.column.ref,
9070
+ column: row.source,
9071
+ header: this.headers[row.source],
9072
+ confidence: row.guessed ? 'guess' : 'exact',
9073
+ }));
9074
+ return {
9075
+ entity: this.config?.entity ?? '',
9076
+ configVersion: this.config?.version,
9077
+ sourceHeaders: [...this.headers],
9078
+ entries,
9079
+ };
9080
+ }
9081
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.22", ngImport: i0, type: ImportMapperComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
9082
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.22", type: ImportMapperComponent, isStandalone: true, selector: "ngx-import-mapper", inputs: { config: "config", headers: "headers", plan: "plan", language: "language" }, outputs: { planChange: "planChange" }, usesOnChanges: true, ngImport: i0, template: `
9083
+ <section class="ngx-import-mapper" data-testid="import-mapper">
9084
+ <p class="ngx-import-mapper__summary" data-testid="import-mapped-count">
9085
+ {{ ui.text('importMappedCount', language, { mapped: mappedCount(), total: rows().length }) }}
9086
+ </p>
9087
+ @if (missingRequired() > 0) {
9088
+ <p class="ngx-import-mapper__warning" data-testid="import-required-unmapped" role="status">
9089
+ {{ ui.text('importRequiredUnmapped', language, { count: missingRequired() }) }}
9090
+ </p>
9091
+ }
9092
+ @if (unsupported() > 0) {
9093
+ <p class="ngx-import-mapper__note" data-testid="import-mapper-unsupported">
9094
+ {{ ui.text('importTemplateUnsupported', language, { count: unsupported() }) }}
9095
+ </p>
9096
+ }
9097
+
9098
+ <table class="ngx-import-mapper__table">
9099
+ <thead>
9100
+ <tr>
9101
+ <th scope="col">{{ ui.text('importTargetField', language) }}</th>
9102
+ <th scope="col">{{ ui.text('importColumnInFile', language) }}</th>
9103
+ </tr>
9104
+ </thead>
9105
+ <tbody>
9106
+ @for (row of rows(); track row.column.ref) {
9107
+ <tr [attr.data-testid]="'import-map-' + row.column.ref">
9108
+ <th scope="row">
9109
+ {{ row.column.header }}
9110
+ @if (row.column.required) {
9111
+ <span class="ngx-import-mapper__required" aria-hidden="true">*</span>
9112
+ }
9113
+ </th>
9114
+ <td>
9115
+ <select
9116
+ class="ngx-import-mapper__select"
9117
+ [attr.data-testid]="'import-select-' + row.column.ref"
9118
+ [attr.aria-label]="row.column.header"
9119
+ [ngModel]="row.source"
9120
+ (ngModelChange)="choose(row, $event)"
9121
+ >
9122
+ <option [ngValue]="null">{{ ui.text('importUnmapped', language) }}</option>
9123
+ @for (header of headers; track $index) {
9124
+ <option [ngValue]="$index">{{ header || '—' }}</option>
9125
+ }
9126
+ </select>
9127
+ @if (row.guessed && row.source !== null) {
9128
+ <span class="ngx-import-mapper__guess" [attr.data-testid]="'import-guess-' + row.column.ref">
9129
+ {{ ui.text('importGuessed', language) }}
9130
+ </span>
9131
+ }
9132
+ </td>
9133
+ </tr>
9134
+ }
9135
+ </tbody>
9136
+ </table>
9137
+ </section>
9138
+ `, isInline: true, styles: [".ngx-import-mapper__summary,.ngx-import-mapper__warning,.ngx-import-mapper__note{margin:0 0 10px}.ngx-import-mapper__warning{color:var(--ngx-color-warning, #b45309);background:var(--ngx-color-warning-soft, #fffbeb);border:1px solid var(--ngx-color-border, #fef3c7);border-radius:var(--ngx-radius-sm, 6px);padding:8px 12px}.ngx-import-mapper__note{color:var(--ngx-color-muted, #6b7280)}.ngx-import-mapper__table{width:100%;border-collapse:collapse}.ngx-import-mapper__table th,.ngx-import-mapper__table td{text-align:left;padding:8px 10px;border-bottom:1px solid var(--ngx-color-border, #e5e7eb);vertical-align:middle}.ngx-import-mapper__table thead th{color:var(--ngx-color-muted, #6b7280);font-size:var(--ngx-font-size, 13px);text-transform:uppercase;letter-spacing:.04em}.ngx-import-mapper__required{color:var(--ngx-color-error, #b91c1c);margin-left:2px}.ngx-import-mapper__select{min-width:220px;max-width:100%;padding:6px 8px;border:1px solid var(--ngx-color-border, #d1d5db);border-radius:var(--ngx-radius-sm, 6px);background:var(--ngx-color-surface, #ffffff);color:inherit;font:inherit}.ngx-import-mapper__guess{margin-left:8px;padding:2px 8px;border-radius:999px;font-size:12px;color:var(--ngx-color-info, #1d4ed8);background:var(--ngx-color-info-soft, #eff6ff)}@media(max-width:640px){.ngx-import-mapper__select{min-width:0;width:100%}}\n"], dependencies: [{ kind: "ngmodule", type: FormsModule }, { kind: "directive", type: i1.NgSelectOption, selector: "option", inputs: ["ngValue", "value"] }, { kind: "directive", type: i1.ɵNgSelectMultipleOption, selector: "option", inputs: ["ngValue", "value"] }, { kind: "directive", type: i1.SelectControlValueAccessor, selector: "select:not([multiple])[formControlName],select:not([multiple])[formControl],select:not([multiple])[ngModel]", inputs: ["compareWith"] }, { kind: "directive", type: i1.NgControlStatus, selector: "[formControlName],[ngModel],[formControl]" }, { kind: "directive", type: i1.NgModel, selector: "[ngModel]:not([formControlName]):not([formControl])", inputs: ["name", "disabled", "ngModel", "ngModelOptions"], outputs: ["ngModelChange"], exportAs: ["ngModel"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
9139
+ }
9140
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.22", ngImport: i0, type: ImportMapperComponent, decorators: [{
9141
+ type: Component,
9142
+ args: [{ selector: 'ngx-import-mapper', standalone: true, changeDetection: ChangeDetectionStrategy.OnPush, imports: [FormsModule], template: `
9143
+ <section class="ngx-import-mapper" data-testid="import-mapper">
9144
+ <p class="ngx-import-mapper__summary" data-testid="import-mapped-count">
9145
+ {{ ui.text('importMappedCount', language, { mapped: mappedCount(), total: rows().length }) }}
9146
+ </p>
9147
+ @if (missingRequired() > 0) {
9148
+ <p class="ngx-import-mapper__warning" data-testid="import-required-unmapped" role="status">
9149
+ {{ ui.text('importRequiredUnmapped', language, { count: missingRequired() }) }}
9150
+ </p>
9151
+ }
9152
+ @if (unsupported() > 0) {
9153
+ <p class="ngx-import-mapper__note" data-testid="import-mapper-unsupported">
9154
+ {{ ui.text('importTemplateUnsupported', language, { count: unsupported() }) }}
9155
+ </p>
9156
+ }
9157
+
9158
+ <table class="ngx-import-mapper__table">
9159
+ <thead>
9160
+ <tr>
9161
+ <th scope="col">{{ ui.text('importTargetField', language) }}</th>
9162
+ <th scope="col">{{ ui.text('importColumnInFile', language) }}</th>
9163
+ </tr>
9164
+ </thead>
9165
+ <tbody>
9166
+ @for (row of rows(); track row.column.ref) {
9167
+ <tr [attr.data-testid]="'import-map-' + row.column.ref">
9168
+ <th scope="row">
9169
+ {{ row.column.header }}
9170
+ @if (row.column.required) {
9171
+ <span class="ngx-import-mapper__required" aria-hidden="true">*</span>
9172
+ }
9173
+ </th>
9174
+ <td>
9175
+ <select
9176
+ class="ngx-import-mapper__select"
9177
+ [attr.data-testid]="'import-select-' + row.column.ref"
9178
+ [attr.aria-label]="row.column.header"
9179
+ [ngModel]="row.source"
9180
+ (ngModelChange)="choose(row, $event)"
9181
+ >
9182
+ <option [ngValue]="null">{{ ui.text('importUnmapped', language) }}</option>
9183
+ @for (header of headers; track $index) {
9184
+ <option [ngValue]="$index">{{ header || '—' }}</option>
9185
+ }
9186
+ </select>
9187
+ @if (row.guessed && row.source !== null) {
9188
+ <span class="ngx-import-mapper__guess" [attr.data-testid]="'import-guess-' + row.column.ref">
9189
+ {{ ui.text('importGuessed', language) }}
9190
+ </span>
9191
+ }
9192
+ </td>
9193
+ </tr>
9194
+ }
9195
+ </tbody>
9196
+ </table>
9197
+ </section>
9198
+ `, styles: [".ngx-import-mapper__summary,.ngx-import-mapper__warning,.ngx-import-mapper__note{margin:0 0 10px}.ngx-import-mapper__warning{color:var(--ngx-color-warning, #b45309);background:var(--ngx-color-warning-soft, #fffbeb);border:1px solid var(--ngx-color-border, #fef3c7);border-radius:var(--ngx-radius-sm, 6px);padding:8px 12px}.ngx-import-mapper__note{color:var(--ngx-color-muted, #6b7280)}.ngx-import-mapper__table{width:100%;border-collapse:collapse}.ngx-import-mapper__table th,.ngx-import-mapper__table td{text-align:left;padding:8px 10px;border-bottom:1px solid var(--ngx-color-border, #e5e7eb);vertical-align:middle}.ngx-import-mapper__table thead th{color:var(--ngx-color-muted, #6b7280);font-size:var(--ngx-font-size, 13px);text-transform:uppercase;letter-spacing:.04em}.ngx-import-mapper__required{color:var(--ngx-color-error, #b91c1c);margin-left:2px}.ngx-import-mapper__select{min-width:220px;max-width:100%;padding:6px 8px;border:1px solid var(--ngx-color-border, #d1d5db);border-radius:var(--ngx-radius-sm, 6px);background:var(--ngx-color-surface, #ffffff);color:inherit;font:inherit}.ngx-import-mapper__guess{margin-left:8px;padding:2px 8px;border-radius:999px;font-size:12px;color:var(--ngx-color-info, #1d4ed8);background:var(--ngx-color-info-soft, #eff6ff)}@media(max-width:640px){.ngx-import-mapper__select{min-width:0;width:100%}}\n"] }]
9199
+ }], propDecorators: { config: [{
9200
+ type: Input,
9201
+ args: [{ required: true }]
9202
+ }], headers: [{
9203
+ type: Input
9204
+ }], plan: [{
9205
+ type: Input
9206
+ }], language: [{
9207
+ type: Input
9208
+ }], planChange: [{
9209
+ type: Output
9210
+ }] } });
9211
+
9212
+ /**
9213
+ * The first few rows as the record will hold them, not as the sheet holds them.
9214
+ *
9215
+ * Every cell goes through the same `coerceCell` the import will use and then through
9216
+ * `formatDisplayValue`, which is what the renderer uses to show a stored value. So a
9217
+ * `dropdown` cell reading `Aktiv` shows as `Active` here if that is the option it resolved
9218
+ * to, and a cell that will fail shows its reason before anyone commits to anything.
9219
+ *
9220
+ * Showing the raw text instead would be easier and would preview the wrong thing: the point
9221
+ * of this screen is to catch a mapping that is subtly wrong, and a column of unchanged text
9222
+ * looks correct whatever field it was pointed at.
9223
+ *
9224
+ * **The rows are also run through the real import**, not just through coercion. `coerceCell`
9225
+ * alone catches a cell that will not parse and says nothing about a row that will be rejected
9226
+ * — a missing required field, a rule that forbids the combination — so a heading promising
9227
+ * "as they will be saved" was showing rows that would never be saved at all, looking exactly
9228
+ * like the ones that would. `applyMapping` over the sample answers that with the same engine
9229
+ * the commit uses, rather than a second opinion about what counts as valid.
9230
+ *
9231
+ * The whole grid is built once per input change rather than from template method calls.
9232
+ * Deriving the config's columns inside a `@for` means re-walking the entire schema for every
9233
+ * cell on every change-detection pass, which is a lot of work to produce five rows.
9234
+ */
9235
+ class ImportPreviewComponent {
9236
+ config;
9237
+ plan;
9238
+ rows = [];
9239
+ lookups;
9240
+ /** The same rules the commit will apply; without them a rule-driven rejection is invisible. */
9241
+ rules;
9242
+ language = 'en';
9243
+ ui = inject(UiTextService);
9244
+ /** The columns this plan fills, in the plan's own order. */
9245
+ columns = signal([], ...(ngDevMode ? [{ debugName: "columns" }] : /* istanbul ignore next */ []));
9246
+ grid = signal([], ...(ngDevMode ? [{ debugName: "grid" }] : /* istanbul ignore next */ []));
9247
+ ngOnChanges() {
9248
+ const byRef = new Map(deriveImportColumns(this.config, { lang: this.language }).columns.map(c => [c.ref, c]));
9249
+ const entries = (this.plan?.entries ?? []).filter(entry => byRef.has(entry.ref));
9250
+ this.columns.set(entries.map(entry => byRef.get(entry.ref)));
9251
+ // The same engine the commit runs, over the sample only. Row 0 of the sample is numbered
9252
+ // 0 here rather than 2, because these row numbers index the preview, not the file.
9253
+ const dryRun = applyMapping(this.rows, this.plan, this.config, {
9254
+ lang: this.language,
9255
+ lookups: this.lookups,
9256
+ rules: this.rules,
9257
+ stamp: false,
9258
+ firstRowNumber: 0,
9259
+ });
9260
+ const rejectionsByRow = new Map();
9261
+ for (const error of dryRun.errors) {
9262
+ rejectionsByRow.set(error.row, [...(rejectionsByRow.get(error.row) ?? []), error.message]);
9263
+ }
9264
+ this.grid.set(this.rows.map((row, index) => ({
9265
+ rejections: rejectionsByRow.get(index) ?? [],
9266
+ cells: entries.map(entry => {
9267
+ const field = byRef.get(entry.ref).field;
9268
+ const raw = entry.column === undefined ? entry.constant : row[entry.column];
9269
+ const outcome = coerceCell(field, raw, {
9270
+ lang: this.language,
9271
+ lookups: this.lookups,
9272
+ });
9273
+ if ('error' in outcome)
9274
+ return { text: outcome.error, failed: true };
9275
+ return {
9276
+ text: formatDisplayValue(field.type, field.options, outcome.value, this.language),
9277
+ failed: false,
9278
+ };
9279
+ }),
9280
+ })));
9281
+ }
9282
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.22", ngImport: i0, type: ImportPreviewComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
9283
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.22", type: ImportPreviewComponent, isStandalone: true, selector: "ngx-import-preview", inputs: { config: "config", plan: "plan", rows: "rows", lookups: "lookups", rules: "rules", language: "language" }, usesOnChanges: true, ngImport: i0, template: `
9284
+ <section class="ngx-import-preview" data-testid="import-preview">
9285
+ <h4 class="ngx-import-preview__heading">
9286
+ {{ ui.text('importPreviewHeading', language, { count: rows.length }) }}
9287
+ </h4>
9288
+ <div class="ngx-import-preview__scroll">
9289
+ <table class="ngx-import-preview__table">
9290
+ <thead>
9291
+ <tr>
9292
+ @for (column of columns(); track column.ref) {
9293
+ <th scope="col">{{ column.header }}</th>
9294
+ }
9295
+ </tr>
9296
+ </thead>
9297
+ <tbody>
9298
+ @for (row of grid(); track $index) {
9299
+ <tr
9300
+ [attr.data-testid]="'import-preview-row-' + $index"
9301
+ [class.ngx-import-preview__row--rejected]="row.rejections.length"
9302
+ >
9303
+ @for (cell of row.cells; track $index) {
9304
+ <td [class.ngx-import-preview__cell--error]="cell.failed">{{ cell.text }}</td>
9305
+ }
9306
+ </tr>
9307
+ @if (row.rejections.length) {
9308
+ <tr class="ngx-import-preview__reason">
9309
+ <td [attr.colspan]="columns().length" [attr.data-testid]="'import-preview-rejected-' + $index">
9310
+ {{ ui.text('importRowRejected', language, { reason: row.rejections.join('; ') }) }}
9311
+ </td>
9312
+ </tr>
9313
+ }
9314
+ }
9315
+ </tbody>
9316
+ </table>
9317
+ </div>
9318
+ </section>
9319
+ `, isInline: true, styles: [".ngx-import-preview__heading{margin:0 0 8px;font-size:var(--ngx-font-size, 14px)}.ngx-import-preview__scroll{overflow-x:auto;border:1px solid var(--ngx-color-border, #e5e7eb);border-radius:var(--ngx-radius-sm, 6px)}.ngx-import-preview__table{width:100%;border-collapse:collapse;white-space:nowrap}.ngx-import-preview__table th,.ngx-import-preview__table td{text-align:left;padding:6px 10px;border-bottom:1px solid var(--ngx-color-border, #f3f4f6)}.ngx-import-preview__table thead th{background:var(--ngx-color-surface-alt, #f9fafb);color:var(--ngx-color-muted, #6b7280)}.ngx-import-preview__cell--error{color:var(--ngx-color-error, #b91c1c);background:var(--ngx-color-error-soft, #fef2f2)}.ngx-import-preview__row--rejected td{opacity:.65}.ngx-import-preview__reason td{white-space:normal;color:var(--ngx-color-error, #b91c1c);background:var(--ngx-color-error-soft, #fef2f2)}\n"], changeDetection: i0.ChangeDetectionStrategy.OnPush });
9320
+ }
9321
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.22", ngImport: i0, type: ImportPreviewComponent, decorators: [{
9322
+ type: Component,
9323
+ args: [{ selector: 'ngx-import-preview', standalone: true, changeDetection: ChangeDetectionStrategy.OnPush, imports: [], template: `
9324
+ <section class="ngx-import-preview" data-testid="import-preview">
9325
+ <h4 class="ngx-import-preview__heading">
9326
+ {{ ui.text('importPreviewHeading', language, { count: rows.length }) }}
9327
+ </h4>
9328
+ <div class="ngx-import-preview__scroll">
9329
+ <table class="ngx-import-preview__table">
9330
+ <thead>
9331
+ <tr>
9332
+ @for (column of columns(); track column.ref) {
9333
+ <th scope="col">{{ column.header }}</th>
9334
+ }
9335
+ </tr>
9336
+ </thead>
9337
+ <tbody>
9338
+ @for (row of grid(); track $index) {
9339
+ <tr
9340
+ [attr.data-testid]="'import-preview-row-' + $index"
9341
+ [class.ngx-import-preview__row--rejected]="row.rejections.length"
9342
+ >
9343
+ @for (cell of row.cells; track $index) {
9344
+ <td [class.ngx-import-preview__cell--error]="cell.failed">{{ cell.text }}</td>
9345
+ }
9346
+ </tr>
9347
+ @if (row.rejections.length) {
9348
+ <tr class="ngx-import-preview__reason">
9349
+ <td [attr.colspan]="columns().length" [attr.data-testid]="'import-preview-rejected-' + $index">
9350
+ {{ ui.text('importRowRejected', language, { reason: row.rejections.join('; ') }) }}
9351
+ </td>
9352
+ </tr>
9353
+ }
9354
+ }
9355
+ </tbody>
9356
+ </table>
9357
+ </div>
9358
+ </section>
9359
+ `, styles: [".ngx-import-preview__heading{margin:0 0 8px;font-size:var(--ngx-font-size, 14px)}.ngx-import-preview__scroll{overflow-x:auto;border:1px solid var(--ngx-color-border, #e5e7eb);border-radius:var(--ngx-radius-sm, 6px)}.ngx-import-preview__table{width:100%;border-collapse:collapse;white-space:nowrap}.ngx-import-preview__table th,.ngx-import-preview__table td{text-align:left;padding:6px 10px;border-bottom:1px solid var(--ngx-color-border, #f3f4f6)}.ngx-import-preview__table thead th{background:var(--ngx-color-surface-alt, #f9fafb);color:var(--ngx-color-muted, #6b7280)}.ngx-import-preview__cell--error{color:var(--ngx-color-error, #b91c1c);background:var(--ngx-color-error-soft, #fef2f2)}.ngx-import-preview__row--rejected td{opacity:.65}.ngx-import-preview__reason td{white-space:normal;color:var(--ngx-color-error, #b91c1c);background:var(--ngx-color-error-soft, #fef2f2)}\n"] }]
9360
+ }], propDecorators: { config: [{
9361
+ type: Input,
9362
+ args: [{ required: true }]
9363
+ }], plan: [{
9364
+ type: Input,
9365
+ args: [{ required: true }]
9366
+ }], rows: [{
9367
+ type: Input
9368
+ }], lookups: [{
9369
+ type: Input
9370
+ }], rules: [{
9371
+ type: Input
9372
+ }], language: [{
9373
+ type: Input
9374
+ }] } });
9375
+
9376
+ /**
9377
+ * Pick the fields a template should carry, then download it.
9378
+ *
9379
+ * The guidance for each field is shown **here**, beside the checkbox, rather than written
9380
+ * into the file as a second header row. CSV has one header row and no concept of a second,
9381
+ * so a guidance row inside the file would come back as a record on re-import and fail row 2
9382
+ * of every import the user ever ran.
9383
+ *
9384
+ * A repeating field is offered once, not once per row number: "Contacts / Name" is a decision,
9385
+ * and "Contacts / Name 1", "Contacts / Name 2", "Contacts / Name 3" is the same decision
9386
+ * asked three times.
9387
+ */
9388
+ class ImportTemplateComponent {
9389
+ config;
9390
+ language = 'en';
9391
+ /** The refs to include, with row numbers stripped — the caller builds the spec. */
9392
+ download = new EventEmitter();
9393
+ ui = inject(UiTextService);
9394
+ choices = signal([], ...(ngDevMode ? [{ debugName: "choices" }] : /* istanbul ignore next */ []));
9395
+ unsupported = signal([], ...(ngDevMode ? [{ debugName: "unsupported" }] : /* istanbul ignore next */ []));
9396
+ ngOnChanges() {
9397
+ const spec = buildTemplateSpec(this.config, { lang: this.language });
9398
+ const derived = deriveImportColumns(this.config, { lang: this.language });
9399
+ // One entry per field, keyed on the ref with its row number removed, so a repeating
9400
+ // field collapses back into the single choice it is.
9401
+ const seen = new Map();
9402
+ spec.columns.forEach((column, i) => {
9403
+ const ref = stripIndices(column.ref);
9404
+ if (seen.has(ref))
9405
+ return;
9406
+ seen.set(ref, {
9407
+ ref,
9408
+ header: column.arrayIndex === undefined ? column.header : stripRowNumber(column.header),
9409
+ note: spec.notes[i] ?? '',
9410
+ selected: true,
9411
+ });
9412
+ });
9413
+ this.choices.set([...seen.values()]);
9414
+ this.unsupported.set(derived.unsupported);
9415
+ }
9416
+ selectedCount() {
9417
+ return this.choices().filter(choice => choice.selected).length;
9418
+ }
9419
+ selectedRefs() {
9420
+ return this.choices()
9421
+ .filter(choice => choice.selected)
9422
+ .map(choice => choice.ref);
9423
+ }
9424
+ toggle(ref) {
9425
+ this.choices.update(choices => choices.map(choice => choice.ref === ref ? { ...choice, selected: !choice.selected } : choice));
9426
+ }
9427
+ selectAll(selected) {
9428
+ this.choices.update(choices => choices.map(choice => ({ ...choice, selected })));
9429
+ }
9430
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.22", ngImport: i0, type: ImportTemplateComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
9431
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.22", type: ImportTemplateComponent, isStandalone: true, selector: "ngx-import-template", inputs: { config: "config", language: "language" }, outputs: { download: "download" }, usesOnChanges: true, ngImport: i0, template: `
9432
+ <section class="ngx-import-template" data-testid="import-template">
9433
+ <h4 class="ngx-import-template__heading">{{ ui.text('importTemplateHeading', language) }}</h4>
9434
+ <p class="ngx-import-template__explain">{{ ui.text('importTemplateExplain', language) }}</p>
9435
+
9436
+ <div class="ngx-import-template__bulk">
9437
+ <button type="button" (click)="selectAll(true)" data-testid="import-template-all">
9438
+ {{ ui.text('importTemplateSelectAll', language) }}
9439
+ </button>
9440
+ <button type="button" (click)="selectAll(false)" data-testid="import-template-none">
9441
+ {{ ui.text('importTemplateSelectNone', language) }}
9442
+ </button>
9443
+ </div>
9444
+
9445
+ <ul class="ngx-import-template__list">
9446
+ @for (choice of choices(); track choice.ref) {
9447
+ <li class="ngx-import-template__item">
9448
+ <label>
9449
+ <input
9450
+ type="checkbox"
9451
+ [checked]="choice.selected"
9452
+ (change)="toggle(choice.ref)"
9453
+ [attr.data-testid]="'import-template-field-' + choice.ref"
9454
+ />
9455
+ <span class="ngx-import-template__label">{{ choice.header }}</span>
9456
+ </label>
9457
+ @if (choice.note) {
9458
+ <span class="ngx-import-template__note">{{ choice.note }}</span>
9459
+ }
9460
+ </li>
9461
+ }
9462
+ </ul>
9463
+
9464
+ @if (unsupported().length) {
9465
+ <p class="ngx-import-template__unsupported" data-testid="import-template-unsupported">
9466
+ {{ ui.text('importTemplateUnsupported', language, { count: unsupported().length }) }}
9467
+ </p>
9468
+ }
9469
+
9470
+ <button
9471
+ type="button"
9472
+ class="ngx-import-template__download"
9473
+ data-testid="import-template-download"
9474
+ [disabled]="!selectedCount()"
9475
+ (click)="download.emit(selectedRefs())"
9476
+ >
9477
+ {{ ui.text('importTemplateDownload', language) }}
9478
+ </button>
9479
+ </section>
9480
+ `, isInline: true, styles: [".ngx-import-template{padding:14px;border:1px solid var(--ngx-color-border, #e5e7eb);border-radius:var(--ngx-radius-sm, 6px);background:var(--ngx-color-surface-alt, #f9fafb)}.ngx-import-template__heading{margin:0 0 4px;font-size:var(--ngx-font-size, 14px)}.ngx-import-template__explain{margin:0 0 10px;color:var(--ngx-color-muted, #6b7280)}.ngx-import-template__bulk{display:flex;gap:8px;margin-bottom:10px}.ngx-import-template__list{list-style:none;margin:0 0 10px;padding:0;max-height:240px;overflow-y:auto;display:grid;gap:4px}.ngx-import-template__item{display:flex;flex-wrap:wrap;align-items:baseline;gap:8px}.ngx-import-template__item label{display:flex;align-items:center;gap:6px;cursor:pointer}.ngx-import-template__note{color:var(--ngx-color-muted, #6b7280);font-size:12px}.ngx-import-template__unsupported{margin:0 0 10px;color:var(--ngx-color-muted, #6b7280)}.ngx-import-template button{padding:6px 12px;border:1px solid var(--ngx-color-border, #d1d5db);border-radius:var(--ngx-radius-sm, 6px);background:var(--ngx-color-surface, #ffffff);color:inherit;font:inherit;cursor:pointer}.ngx-import-template button:disabled{opacity:.55;cursor:not-allowed}\n"], changeDetection: i0.ChangeDetectionStrategy.OnPush });
9481
+ }
9482
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.22", ngImport: i0, type: ImportTemplateComponent, decorators: [{
9483
+ type: Component,
9484
+ args: [{ selector: 'ngx-import-template', standalone: true, changeDetection: ChangeDetectionStrategy.OnPush, imports: [], template: `
9485
+ <section class="ngx-import-template" data-testid="import-template">
9486
+ <h4 class="ngx-import-template__heading">{{ ui.text('importTemplateHeading', language) }}</h4>
9487
+ <p class="ngx-import-template__explain">{{ ui.text('importTemplateExplain', language) }}</p>
9488
+
9489
+ <div class="ngx-import-template__bulk">
9490
+ <button type="button" (click)="selectAll(true)" data-testid="import-template-all">
9491
+ {{ ui.text('importTemplateSelectAll', language) }}
9492
+ </button>
9493
+ <button type="button" (click)="selectAll(false)" data-testid="import-template-none">
9494
+ {{ ui.text('importTemplateSelectNone', language) }}
9495
+ </button>
9496
+ </div>
9497
+
9498
+ <ul class="ngx-import-template__list">
9499
+ @for (choice of choices(); track choice.ref) {
9500
+ <li class="ngx-import-template__item">
9501
+ <label>
9502
+ <input
9503
+ type="checkbox"
9504
+ [checked]="choice.selected"
9505
+ (change)="toggle(choice.ref)"
9506
+ [attr.data-testid]="'import-template-field-' + choice.ref"
9507
+ />
9508
+ <span class="ngx-import-template__label">{{ choice.header }}</span>
9509
+ </label>
9510
+ @if (choice.note) {
9511
+ <span class="ngx-import-template__note">{{ choice.note }}</span>
9512
+ }
9513
+ </li>
9514
+ }
9515
+ </ul>
9516
+
9517
+ @if (unsupported().length) {
9518
+ <p class="ngx-import-template__unsupported" data-testid="import-template-unsupported">
9519
+ {{ ui.text('importTemplateUnsupported', language, { count: unsupported().length }) }}
9520
+ </p>
9521
+ }
9522
+
9523
+ <button
9524
+ type="button"
9525
+ class="ngx-import-template__download"
9526
+ data-testid="import-template-download"
9527
+ [disabled]="!selectedCount()"
9528
+ (click)="download.emit(selectedRefs())"
9529
+ >
9530
+ {{ ui.text('importTemplateDownload', language) }}
9531
+ </button>
9532
+ </section>
9533
+ `, styles: [".ngx-import-template{padding:14px;border:1px solid var(--ngx-color-border, #e5e7eb);border-radius:var(--ngx-radius-sm, 6px);background:var(--ngx-color-surface-alt, #f9fafb)}.ngx-import-template__heading{margin:0 0 4px;font-size:var(--ngx-font-size, 14px)}.ngx-import-template__explain{margin:0 0 10px;color:var(--ngx-color-muted, #6b7280)}.ngx-import-template__bulk{display:flex;gap:8px;margin-bottom:10px}.ngx-import-template__list{list-style:none;margin:0 0 10px;padding:0;max-height:240px;overflow-y:auto;display:grid;gap:4px}.ngx-import-template__item{display:flex;flex-wrap:wrap;align-items:baseline;gap:8px}.ngx-import-template__item label{display:flex;align-items:center;gap:6px;cursor:pointer}.ngx-import-template__note{color:var(--ngx-color-muted, #6b7280);font-size:12px}.ngx-import-template__unsupported{margin:0 0 10px;color:var(--ngx-color-muted, #6b7280)}.ngx-import-template button{padding:6px 12px;border:1px solid var(--ngx-color-border, #d1d5db);border-radius:var(--ngx-radius-sm, 6px);background:var(--ngx-color-surface, #ffffff);color:inherit;font:inherit;cursor:pointer}.ngx-import-template button:disabled{opacity:.55;cursor:not-allowed}\n"] }]
9534
+ }], propDecorators: { config: [{
9535
+ type: Input,
9536
+ args: [{ required: true }]
9537
+ }], language: [{
9538
+ type: Input
9539
+ }], download: [{
9540
+ type: Output
9541
+ }] } });
9542
+ /** "Contacts / Name 1" → "Contacts / Name", for the collapsed choice. */
9543
+ function stripRowNumber(header) {
9544
+ return header.replace(/ \d+$/, '');
9545
+ }
9546
+
9547
+ /**
9548
+ * The import wizard: choose a file, match its columns, look at what that produced, commit.
9549
+ *
9550
+ * It holds no import logic of its own. Everything it does goes through an `ImportTransport`,
9551
+ * which by default is the in-browser one built on `@dynamic-entity/core` — so the wizard
9552
+ * works with no backend, and a consumer who registers a server transport gets the same
9553
+ * screens over the same mapping rules rather than a second implementation of them.
9554
+ *
9555
+ * The review step exists because the expensive mistake here is not a file that fails to
9556
+ * import, it is one that imports *successfully into the wrong fields*. That is invisible in a
9557
+ * summary and obvious in five rendered rows.
9558
+ */
9559
+ class EntityImportComponent {
9560
+ config;
9561
+ /**
9562
+ * The rules the form is rendered with.
9563
+ *
9564
+ * Pass them, or the import checks field validators only — which is not what the form
9565
+ * enforces, and a required field hidden by a rule will fail every row.
9566
+ */
9567
+ rules;
9568
+ /**
9569
+ * Named lists, overriding what the wizard already resolves from `LOOKUP_REGISTRY`.
9570
+ *
9571
+ * Not something a host has to supply: every `listName` a config mentions is loaded from the
9572
+ * registry the renderer is already holding. Pass this only to override one.
9573
+ */
9574
+ lookups;
9575
+ language = 'en';
9576
+ /** Format asked of the transport when a template is downloaded. */
9577
+ templateFormat = 'csv';
9578
+ /** The finished import, once it has run. */
9579
+ importComplete = new EventEmitter();
9580
+ /** A template the transport produced, for a host that would rather save it its own way. */
9581
+ templateReady = new EventEmitter();
9582
+ ui = inject(UiTextService);
9583
+ steps = ['upload', 'map', 'review', 'done'];
9584
+ step = signal('upload', ...(ngDevMode ? [{ debugName: "step" }] : /* istanbul ignore next */ []));
9585
+ busy = signal(false, ...(ngDevMode ? [{ debugName: "busy" }] : /* istanbul ignore next */ []));
9586
+ problem = signal(null, ...(ngDevMode ? [{ debugName: "problem" }] : /* istanbul ignore next */ []));
9587
+ headers = signal([], ...(ngDevMode ? [{ debugName: "headers" }] : /* istanbul ignore next */ []));
9588
+ sample = signal([], ...(ngDevMode ? [{ debugName: "sample" }] : /* istanbul ignore next */ []));
9589
+ rowCount = signal(0, ...(ngDevMode ? [{ debugName: "rowCount" }] : /* istanbul ignore next */ []));
9590
+ fileName = signal('', ...(ngDevMode ? [{ debugName: "fileName" }] : /* istanbul ignore next */ []));
9591
+ plan = signal(null, ...(ngDevMode ? [{ debugName: "plan" }] : /* istanbul ignore next */ []));
9592
+ result = signal(null, ...(ngDevMode ? [{ debugName: "result" }] : /* istanbul ignore next */ []));
9593
+ registered = inject(IMPORT_TRANSPORT, { optional: true });
9594
+ local = inject(LocalImportTransport);
9595
+ lookupRegistry = inject(LookupRegistryService);
9596
+ file = null;
9597
+ /** Named lists resolved from `LOOKUP_REGISTRY`, merged under the `lookups` input. */
9598
+ resolvedLookups = signal({}, ...(ngDevMode ? [{ debugName: "resolvedLookups" }] : /* istanbul ignore next */ []));
9599
+ get transport() {
9600
+ return this.registered ?? this.local;
9601
+ }
9602
+ context() {
9603
+ return {
9604
+ config: this.config,
9605
+ rules: this.rules,
9606
+ lookups: this.resolvedLookups(),
9607
+ lang: this.language,
9608
+ };
9609
+ }
9610
+ /**
9611
+ * Load every named list this config's fields refer to.
9612
+ *
9613
+ * `@dynamic-entity/core` is framework-agnostic and cannot reach `LOOKUP_REGISTRY`, so
9614
+ * `coerceCell` takes the values as an argument. That is a reason for *core* to ask, not a
9615
+ * reason for the host to be asked — this package is holding the registry, and making the
9616
+ * consumer re-supply what the library already has is how a `listName` column ends up
9617
+ * storing the raw text `"Gold"` instead of the option object. Such a record renders
9618
+ * correctly and then matches no rule, which is the exact failure the option-shape contract
9619
+ * exists to prevent.
9620
+ *
9621
+ * The `lookups` input still wins where it names a list, so a host can override one without
9622
+ * having to provide all of them.
9623
+ */
9624
+ async loadLookups() {
9625
+ const fromRegistry = {};
9626
+ const names = new Set();
9627
+ for (const target of collectLeafTargets(this.config)) {
9628
+ if (target.field.listName)
9629
+ names.add(target.field.listName);
9630
+ }
9631
+ for (const listName of names) {
9632
+ // A list that fails to load is not a reason to refuse the import: the field falls back
9633
+ // to passing its text through, which is what happens today for an unregistered list.
9634
+ try {
9635
+ const options = await this.lookupRegistry.resolveOptions({ id: listName, type: 'dropdown', label: {}, listName }, this.language);
9636
+ if (options.length)
9637
+ fromRegistry[listName] = options;
9638
+ }
9639
+ catch {
9640
+ /* left out, so the field passes its text through */
9641
+ }
9642
+ }
9643
+ this.resolvedLookups.set({ ...fromRegistry, ...(this.lookups ?? {}) });
9644
+ }
9645
+ stepLabel(step) {
9646
+ // One key per step rather than an index, so a translator sees words and not positions.
9647
+ const keys = {
9648
+ upload: 'importUpload',
9649
+ map: 'importMap',
9650
+ review: 'importReview',
9651
+ done: 'importDone',
9652
+ };
9653
+ return this.ui.text(keys[step], this.language);
9654
+ }
9655
+ async chooseFile(event) {
9656
+ const input = event.target;
9657
+ const file = input.files?.[0] ?? null;
9658
+ if (!file) {
9659
+ this.problem.set(this.ui.text('importNeedFile', this.language));
9660
+ return;
9661
+ }
9662
+ this.problem.set(null);
9663
+ this.busy.set(true);
9664
+ try {
9665
+ await this.loadLookups();
9666
+ const preview = await this.transport.preview(file, this.context());
9667
+ // Assigned only once the file has been read: holding a file whose headers describe the
9668
+ // previous one is an invariant worth not having to reason about.
9669
+ this.file = file;
9670
+ this.fileName.set(file.name);
9671
+ this.headers.set(preview.headers);
9672
+ this.sample.set(preview.sample);
9673
+ this.rowCount.set(preview.rowCount);
9674
+ this.plan.set(preview.suggestion);
9675
+ this.step.set('map');
9676
+ }
9677
+ catch (error) {
9678
+ // A parser refusing a format, or a file that is not what it claims, is ordinary user
9679
+ // input rather than a defect — it is reported where the user is looking.
9680
+ this.problem.set(messageOf(error));
9681
+ }
9682
+ finally {
9683
+ this.busy.set(false);
9684
+ }
9685
+ }
9686
+ async commit() {
9687
+ const plan = this.plan();
9688
+ if (!this.file || !plan) {
9689
+ this.problem.set(this.ui.text('importNeedFile', this.language));
9690
+ return;
9691
+ }
9692
+ this.busy.set(true);
9693
+ this.problem.set(null);
9694
+ try {
9695
+ const result = await this.transport.commit(this.file, plan, this.context());
9696
+ // A plan-level error means nothing was imported, and saying "imported 0 records" would
9697
+ // describe that as a successful run of an empty file.
9698
+ const fatal = result.planProblems?.filter(p => p.level === 'error') ?? [];
9699
+ if (fatal.length) {
9700
+ this.problem.set(this.ui.text('importPlanProblem', this.language, {
9701
+ message: formatConfigProblems(fatal),
9702
+ }));
9703
+ this.step.set('map');
9704
+ return;
9705
+ }
9706
+ this.result.set(result);
9707
+ this.step.set('done');
9708
+ this.importComplete.emit(result);
9709
+ }
9710
+ catch (error) {
9711
+ this.problem.set(messageOf(error));
9712
+ }
9713
+ finally {
9714
+ this.busy.set(false);
9715
+ }
9716
+ }
9717
+ async downloadTemplate(fields) {
9718
+ this.problem.set(null);
9719
+ try {
9720
+ const spec = buildTemplateSpec(this.config, { lang: this.language, fields });
9721
+ // The selection as well as the spec. A server transport needs the choice, not the
9722
+ // expansion of it — and recovering one from the other is a round trip that only works
9723
+ // while the expansion stays exactly invertible.
9724
+ const blob = await this.transport.template(spec, this.templateFormat, this.context(), fields);
9725
+ this.templateReady.emit(blob);
9726
+ saveBlob(blob, `${this.config?.entity || 'import'}-template.${this.templateFormat}`);
9727
+ }
9728
+ catch (error) {
9729
+ this.problem.set(messageOf(error));
9730
+ }
9731
+ }
9732
+ /**
9733
+ * How many records were written.
9734
+ *
9735
+ * `records.length` for the in-browser transport, which returns every record it built, and
9736
+ * `imported` for a server one, which streamed the file precisely so that fifty thousand
9737
+ * records never had to exist at once and therefore has a number rather than a list. Reading
9738
+ * `records.length` alone would report a successful import of nothing.
9739
+ */
9740
+ importedCount(result) {
9741
+ return result.imported ?? result.records.length;
9742
+ }
9743
+ /**
9744
+ * How many distinct rows failed, not how many problems they had between them.
9745
+ *
9746
+ * `failed` when the transport reports it, and the distinct rows in `errors` only when it does
9747
+ * not. Counting the errors alone was wrong the moment a transport started capping them: a run
9748
+ * of a thousand rows where two hundred failed said **seven**, because seven was how many
9749
+ * distinct rows fitted inside the first twenty retained problems. This is the number a user
9750
+ * acts on — which rows do I go and fix — so a confident wrong answer is the worst of the
9751
+ * available ones. The in-browser transport returns every error, so counting them there is
9752
+ * exact and `failed` is absent.
9753
+ */
9754
+ failedRowCount(result) {
9755
+ return result.failed ?? new Set(result.errors.map(error => error.row)).size;
9756
+ }
9757
+ restart() {
9758
+ this.file = null;
9759
+ this.fileName.set('');
9760
+ this.headers.set([]);
9761
+ this.sample.set([]);
9762
+ this.rowCount.set(0);
9763
+ this.plan.set(null);
9764
+ this.result.set(null);
9765
+ this.problem.set(null);
9766
+ this.step.set('upload');
9767
+ }
9768
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.22", ngImport: i0, type: EntityImportComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
9769
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.22", type: EntityImportComponent, isStandalone: true, selector: "ngx-entity-import", inputs: { config: "config", rules: "rules", lookups: "lookups", language: "language", templateFormat: "templateFormat" }, outputs: { importComplete: "importComplete", templateReady: "templateReady" }, ngImport: i0, template: `
9770
+ <div class="ngx-import" data-testid="entity-import">
9771
+ <ol class="ngx-import__steps" data-testid="import-steps">
9772
+ @for (name of steps; track name) {
9773
+ <li
9774
+ class="ngx-import__step"
9775
+ [class.ngx-import__step--current]="step() === name"
9776
+ [attr.aria-current]="step() === name ? 'step' : null"
9777
+ [attr.data-testid]="'import-step-' + name"
9778
+ >
9779
+ {{ stepLabel(name) }}
9780
+ </li>
9781
+ }
9782
+ </ol>
9783
+
9784
+ @if (problem(); as text) {
9785
+ <p class="ngx-import__problem" role="alert" data-testid="import-problem">{{ text }}</p>
9786
+ }
9787
+
9788
+ @switch (step()) {
9789
+ @case ('upload') {
9790
+ <ngx-import-template
9791
+ [config]="config"
9792
+ [language]="language"
9793
+ (download)="downloadTemplate($event)"
9794
+ />
9795
+
9796
+ <label class="ngx-import__file">
9797
+ <span>{{ ui.text('importChooseFile', language) }}</span>
9798
+ <input type="file" data-testid="import-file" (change)="chooseFile($event)" />
9799
+ </label>
9800
+
9801
+ @if (busy()) {
9802
+ <p data-testid="import-reading">{{ ui.text('importReading', language) }}</p>
9803
+ }
9804
+ @if (fileName()) {
9805
+ <p data-testid="import-file-chosen">
9806
+ {{ ui.text('importFileChosen', language, { name: fileName(), rows: rowCount() }) }}
9807
+ </p>
9808
+ }
9809
+ }
9810
+
9811
+ @case ('map') {
9812
+ <ngx-import-mapper
9813
+ [config]="config"
9814
+ [headers]="headers()"
9815
+ [plan]="plan()"
9816
+ [language]="language"
9817
+ (planChange)="plan.set($event)"
9818
+ />
9819
+ <button type="button" data-testid="import-to-review" (click)="step.set('review')">
9820
+ {{ ui.text('importReview', language) }}
9821
+ </button>
9822
+ }
9823
+
9824
+ @case ('review') {
9825
+ @if (plan(); as current) {
9826
+ <ngx-import-preview
9827
+ [config]="config"
9828
+ [plan]="current"
9829
+ [rows]="sample()"
9830
+ [lookups]="resolvedLookups()"
9831
+ [rules]="rules"
9832
+ [language]="language"
9833
+ />
9834
+ }
9835
+ <button
9836
+ type="button"
9837
+ data-testid="import-commit"
9838
+ [disabled]="busy()"
9839
+ (click)="commit()"
9840
+ >
9841
+ {{
9842
+ busy()
9843
+ ? ui.text('importImporting', language)
9844
+ : ui.text('importRunImport', language, { count: rowCount() })
9845
+ }}
9846
+ </button>
9847
+ <button type="button" data-testid="import-back" (click)="step.set('map')">
9848
+ {{ ui.text('importMap', language) }}
9849
+ </button>
9850
+ }
9851
+
9852
+ @case ('done') {
9853
+ @if (result(); as done) {
9854
+ <p data-testid="import-succeeded">
9855
+ {{ ui.text('importSucceeded', language, { count: importedCount(done) }) }}
9856
+ </p>
9857
+ @if (done.skipped) {
9858
+ <p data-testid="import-skipped">
9859
+ {{ ui.text('importSkippedRows', language, { count: done.skipped }) }}
9860
+ </p>
9861
+ }
9862
+ @if (failedRowCount(done)) {
9863
+ <p data-testid="import-failed">
9864
+ {{ ui.text('importFailedRows', language, { count: failedRowCount(done) }) }}
9865
+ </p>
9866
+ }
9867
+ @if (done.truncated) {
9868
+ <p data-testid="import-errors-truncated">
9869
+ {{
9870
+ ui.text('importErrorsTruncated', language, {
9871
+ shown: done.errors.length,
9872
+ count: done.errorCount ?? done.errors.length,
9873
+ })
9874
+ }}
9875
+ </p>
9876
+ }
9877
+ <ngx-import-errors [errors]="done.errors" [language]="language" />
9878
+ }
9879
+ <button type="button" data-testid="import-restart" (click)="restart()">
9880
+ {{ ui.text('importStartOver', language) }}
9881
+ </button>
9882
+ }
9883
+ }
9884
+ </div>
9885
+ `, isInline: true, styles: [".ngx-import{display:flex;flex-direction:column;gap:var(--ngx-gap, 16px);color:var(--ngx-color-text, #1f2937);font-size:var(--ngx-font-size, 14px)}.ngx-import__steps{display:flex;flex-wrap:wrap;gap:8px;list-style:none;margin:0;padding:0}.ngx-import__step{padding:4px 12px;border:1px solid var(--ngx-color-border, #e5e7eb);border-radius:var(--ngx-radius-sm, 6px);color:var(--ngx-color-muted, #6b7280);background:var(--ngx-color-surface-alt, #f9fafb)}.ngx-import__step--current{color:var(--ngx-color-accent, #4f46e5);border-color:var(--ngx-color-accent, #4f46e5);background:var(--ngx-color-accent-soft, #e0e7ff);font-weight:600}.ngx-import__problem{margin:0;padding:10px 12px;border:1px solid var(--ngx-color-border, #fecaca);border-radius:var(--ngx-radius-sm, 6px);background:var(--ngx-color-error-soft, #fef2f2);color:var(--ngx-color-error, #b91c1c);overflow-wrap:anywhere}.ngx-import__file{display:flex;flex-direction:column;gap:6px;padding:14px;border:1px dashed var(--ngx-color-border, #d1d5db);border-radius:var(--ngx-radius-sm, 6px);background:var(--ngx-color-surface, #ffffff)}.ngx-import button{align-self:flex-start;padding:8px 16px;border:1px solid var(--ngx-color-border, #d1d5db);border-radius:var(--ngx-radius-sm, 6px);background:var(--ngx-color-surface, #ffffff);color:inherit;font:inherit;cursor:pointer}.ngx-import button:hover:not(:disabled){border-color:var(--ngx-color-accent, #4f46e5);background:var(--ngx-color-accent-soft, #e0e7ff)}.ngx-import button:disabled{opacity:.55;cursor:not-allowed}\n"], dependencies: [{ kind: "component", type: ImportTemplateComponent, selector: "ngx-import-template", inputs: ["config", "language"], outputs: ["download"] }, { kind: "component", type: ImportMapperComponent, selector: "ngx-import-mapper", inputs: ["config", "headers", "plan", "language"], outputs: ["planChange"] }, { kind: "component", type: ImportPreviewComponent, selector: "ngx-import-preview", inputs: ["config", "plan", "rows", "lookups", "rules", "language"] }, { kind: "component", type: ImportErrorsComponent, selector: "ngx-import-errors", inputs: ["errors", "language"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
9886
+ }
9887
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.22", ngImport: i0, type: EntityImportComponent, decorators: [{
9888
+ type: Component,
9889
+ args: [{ selector: 'ngx-entity-import', standalone: true, changeDetection: ChangeDetectionStrategy.OnPush, imports: [
9890
+ ImportTemplateComponent,
9891
+ ImportMapperComponent,
9892
+ ImportPreviewComponent,
9893
+ ImportErrorsComponent,
9894
+ ], template: `
9895
+ <div class="ngx-import" data-testid="entity-import">
9896
+ <ol class="ngx-import__steps" data-testid="import-steps">
9897
+ @for (name of steps; track name) {
9898
+ <li
9899
+ class="ngx-import__step"
9900
+ [class.ngx-import__step--current]="step() === name"
9901
+ [attr.aria-current]="step() === name ? 'step' : null"
9902
+ [attr.data-testid]="'import-step-' + name"
9903
+ >
9904
+ {{ stepLabel(name) }}
9905
+ </li>
9906
+ }
9907
+ </ol>
9908
+
9909
+ @if (problem(); as text) {
9910
+ <p class="ngx-import__problem" role="alert" data-testid="import-problem">{{ text }}</p>
9911
+ }
9912
+
9913
+ @switch (step()) {
9914
+ @case ('upload') {
9915
+ <ngx-import-template
9916
+ [config]="config"
9917
+ [language]="language"
9918
+ (download)="downloadTemplate($event)"
9919
+ />
9920
+
9921
+ <label class="ngx-import__file">
9922
+ <span>{{ ui.text('importChooseFile', language) }}</span>
9923
+ <input type="file" data-testid="import-file" (change)="chooseFile($event)" />
9924
+ </label>
9925
+
9926
+ @if (busy()) {
9927
+ <p data-testid="import-reading">{{ ui.text('importReading', language) }}</p>
9928
+ }
9929
+ @if (fileName()) {
9930
+ <p data-testid="import-file-chosen">
9931
+ {{ ui.text('importFileChosen', language, { name: fileName(), rows: rowCount() }) }}
9932
+ </p>
9933
+ }
9934
+ }
9935
+
9936
+ @case ('map') {
9937
+ <ngx-import-mapper
9938
+ [config]="config"
9939
+ [headers]="headers()"
9940
+ [plan]="plan()"
9941
+ [language]="language"
9942
+ (planChange)="plan.set($event)"
9943
+ />
9944
+ <button type="button" data-testid="import-to-review" (click)="step.set('review')">
9945
+ {{ ui.text('importReview', language) }}
9946
+ </button>
9947
+ }
9948
+
9949
+ @case ('review') {
9950
+ @if (plan(); as current) {
9951
+ <ngx-import-preview
9952
+ [config]="config"
9953
+ [plan]="current"
9954
+ [rows]="sample()"
9955
+ [lookups]="resolvedLookups()"
9956
+ [rules]="rules"
9957
+ [language]="language"
9958
+ />
9959
+ }
9960
+ <button
9961
+ type="button"
9962
+ data-testid="import-commit"
9963
+ [disabled]="busy()"
9964
+ (click)="commit()"
9965
+ >
9966
+ {{
9967
+ busy()
9968
+ ? ui.text('importImporting', language)
9969
+ : ui.text('importRunImport', language, { count: rowCount() })
9970
+ }}
9971
+ </button>
9972
+ <button type="button" data-testid="import-back" (click)="step.set('map')">
9973
+ {{ ui.text('importMap', language) }}
9974
+ </button>
9975
+ }
9976
+
9977
+ @case ('done') {
9978
+ @if (result(); as done) {
9979
+ <p data-testid="import-succeeded">
9980
+ {{ ui.text('importSucceeded', language, { count: importedCount(done) }) }}
9981
+ </p>
9982
+ @if (done.skipped) {
9983
+ <p data-testid="import-skipped">
9984
+ {{ ui.text('importSkippedRows', language, { count: done.skipped }) }}
9985
+ </p>
9986
+ }
9987
+ @if (failedRowCount(done)) {
9988
+ <p data-testid="import-failed">
9989
+ {{ ui.text('importFailedRows', language, { count: failedRowCount(done) }) }}
9990
+ </p>
9991
+ }
9992
+ @if (done.truncated) {
9993
+ <p data-testid="import-errors-truncated">
9994
+ {{
9995
+ ui.text('importErrorsTruncated', language, {
9996
+ shown: done.errors.length,
9997
+ count: done.errorCount ?? done.errors.length,
9998
+ })
9999
+ }}
10000
+ </p>
10001
+ }
10002
+ <ngx-import-errors [errors]="done.errors" [language]="language" />
10003
+ }
10004
+ <button type="button" data-testid="import-restart" (click)="restart()">
10005
+ {{ ui.text('importStartOver', language) }}
10006
+ </button>
10007
+ }
10008
+ }
10009
+ </div>
10010
+ `, styles: [".ngx-import{display:flex;flex-direction:column;gap:var(--ngx-gap, 16px);color:var(--ngx-color-text, #1f2937);font-size:var(--ngx-font-size, 14px)}.ngx-import__steps{display:flex;flex-wrap:wrap;gap:8px;list-style:none;margin:0;padding:0}.ngx-import__step{padding:4px 12px;border:1px solid var(--ngx-color-border, #e5e7eb);border-radius:var(--ngx-radius-sm, 6px);color:var(--ngx-color-muted, #6b7280);background:var(--ngx-color-surface-alt, #f9fafb)}.ngx-import__step--current{color:var(--ngx-color-accent, #4f46e5);border-color:var(--ngx-color-accent, #4f46e5);background:var(--ngx-color-accent-soft, #e0e7ff);font-weight:600}.ngx-import__problem{margin:0;padding:10px 12px;border:1px solid var(--ngx-color-border, #fecaca);border-radius:var(--ngx-radius-sm, 6px);background:var(--ngx-color-error-soft, #fef2f2);color:var(--ngx-color-error, #b91c1c);overflow-wrap:anywhere}.ngx-import__file{display:flex;flex-direction:column;gap:6px;padding:14px;border:1px dashed var(--ngx-color-border, #d1d5db);border-radius:var(--ngx-radius-sm, 6px);background:var(--ngx-color-surface, #ffffff)}.ngx-import button{align-self:flex-start;padding:8px 16px;border:1px solid var(--ngx-color-border, #d1d5db);border-radius:var(--ngx-radius-sm, 6px);background:var(--ngx-color-surface, #ffffff);color:inherit;font:inherit;cursor:pointer}.ngx-import button:hover:not(:disabled){border-color:var(--ngx-color-accent, #4f46e5);background:var(--ngx-color-accent-soft, #e0e7ff)}.ngx-import button:disabled{opacity:.55;cursor:not-allowed}\n"] }]
10011
+ }], propDecorators: { config: [{
10012
+ type: Input,
10013
+ args: [{ required: true }]
10014
+ }], rules: [{
10015
+ type: Input
10016
+ }], lookups: [{
10017
+ type: Input
10018
+ }], language: [{
10019
+ type: Input
10020
+ }], templateFormat: [{
10021
+ type: Input
10022
+ }], importComplete: [{
10023
+ type: Output
10024
+ }], templateReady: [{
10025
+ type: Output
10026
+ }] } });
10027
+ function messageOf(error) {
10028
+ return error instanceof Error ? error.message : String(error);
10029
+ }
10030
+ /**
10031
+ * Hand a generated file to the browser.
10032
+ *
10033
+ * The object URL is revoked on the next turn rather than immediately: revoking it in the same
10034
+ * task can cancel the download that was just started, and the leak of waiting one tick is a
10035
+ * single URL.
10036
+ */
10037
+ function saveBlob(blob, filename) {
10038
+ if (typeof document === 'undefined')
10039
+ return;
10040
+ const url = URL.createObjectURL(blob);
10041
+ const anchor = document.createElement('a');
10042
+ anchor.href = url;
10043
+ anchor.download = filename;
10044
+ anchor.style.display = 'none';
10045
+ document.body.appendChild(anchor);
10046
+ anchor.click();
10047
+ document.body.removeChild(anchor);
10048
+ setTimeout(() => URL.revokeObjectURL(url));
10049
+ }
10050
+
8130
10051
  // Tokens
8131
10052
 
8132
10053
  /**
8133
10054
  * Generated bundle index. Do not edit.
8134
10055
  */
8135
10056
 
8136
- export { ASYNC_VALIDATOR_REGISTRY, ArrayFieldComponent, BooleanFieldComponent, COMMON_MODULES_REGISTRY, CONFIG_SOURCE, CascadeDataService, CheckboxFieldComponent, ConfigSourceService, CurrencyFieldComponent, DEFAULT_UI_TEXT, DEFAULT_VALIDATION_MESSAGES, DateFieldComponent, DateTimeFieldComponent, DropdownFieldComponent, DynamicFieldComponent, DynamicFormComponent, DynamicRecordFormComponent, ENTITY_REF_CACHE_STORE, ENTITY_REF_REGISTRY, EmailFieldComponent, EntityRefFieldComponent, EntityRefRegistryService, EntityRefSelectionService, EntityReferenceService, FIELD_TYPE_REGISTRY, FIELD_TYPE_SETS, FieldRegistryService, FileFieldComponent, GroupFieldComponent, HOOK_REGISTRY, HookRegistryService, ImageFieldComponent, InMemoryEntityRefCacheStore, LOOKUP_REGISTRY, LookupRegistryService, MARKDOWN_RENDERER, MASKED_PLACEHOLDER, MASKED_ROLES, MarkdownFieldComponent, MonthYearFieldComponent, MultiSelectFieldComponent, NumberFieldComponent, PasswordFieldComponent, RECORD_MIGRATIONS, RadioFieldComponent, RbacService, RulesEvaluationService, SYSTEM_DEFAULT_CAN_EDIT, TextFieldComponent, TextareaFieldComponent, TimeFieldComponent, UI_TEXT, UPLOAD_HANDLER, UiTextService, VALIDATION_MESSAGES, VALIDATOR_REGISTRY, ValidationMessagesService, ValidatorRegistryService, builtInFieldTypes, fieldDescribedBy, fieldDomId, nextFieldInstanceId, provideBuiltInFieldTypes, provideFieldTypes, provideNgxDynamicEntity, refreshChoiceOptions, resolveUiText };
10057
+ export { ASYNC_VALIDATOR_REGISTRY, ArrayFieldComponent, BooleanFieldComponent, COMMON_MODULES_REGISTRY, CONFIG_SOURCE, CascadeDataService, CheckboxFieldComponent, ConfigSourceService, CurrencyFieldComponent, DEFAULT_UI_TEXT, DEFAULT_VALIDATION_MESSAGES, DateFieldComponent, DateTimeFieldComponent, DropdownFieldComponent, DynamicFieldComponent, DynamicFormComponent, DynamicRecordFormComponent, ENTITY_REF_CACHE_STORE, ENTITY_REF_REGISTRY, EmailFieldComponent, EntityImportComponent, EntityRefFieldComponent, EntityRefRegistryService, EntityRefSelectionService, EntityReferenceService, FIELD_TYPE_REGISTRY, FIELD_TYPE_SETS, FieldRegistryService, FileFieldComponent, GroupFieldComponent, HOOK_REGISTRY, HookRegistryService, HttpImportTransport, IMPORT_TRANSPORT, ImageFieldComponent, ImportErrorsComponent, ImportMapperComponent, ImportPreviewComponent, ImportTemplateComponent, InMemoryEntityRefCacheStore, LOOKUP_REGISTRY, LocalImportTransport, LookupRegistryService, MARKDOWN_RENDERER, MASKED_PLACEHOLDER, MASKED_ROLES, MarkdownFieldComponent, MonthYearFieldComponent, MultiSelectFieldComponent, NumberFieldComponent, PasswordFieldComponent, RECORD_MIGRATIONS, RadioFieldComponent, RbacService, RulesEvaluationService, SHEET_PARSER, SYSTEM_DEFAULT_CAN_EDIT, TextFieldComponent, TextareaFieldComponent, TimeFieldComponent, UI_TEXT, UPLOAD_HANDLER, UiTextService, VALIDATION_MESSAGES, VALIDATOR_REGISTRY, ValidationMessagesService, ValidatorRegistryService, builtInFieldTypes, defaultSheetParser, fieldDescribedBy, fieldDomId, nextFieldInstanceId, provideBuiltInFieldTypes, provideFieldTypes, provideHttpImportTransport, provideNgxDynamicEntity, refreshChoiceOptions, resolveUiText };
8137
10058
  //# sourceMappingURL=ngx-dynamic-entity.mjs.map