ngx-dynamic-entity-builder 1.6.0 → 1.7.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.
@@ -264,21 +264,62 @@ class BuilderStore {
264
264
  walkFields(tab.fields);
265
265
  return ids;
266
266
  }
267
- findFieldInTabs(tabs = [], id) {
267
+ /**
268
+ * Resolve a field by key, where a key is either its `refererField` path or a bare id.
269
+ *
270
+ * Every resolution in this store funnels through here, which is why a bare id was enough
271
+ * to edit the wrong field: with `personal.address` and `work.address` both present, the
272
+ * first match won, so selecting or editing the second one silently addressed the first.
273
+ *
274
+ * The path is tried across the whole tree before any bare id is, so an exact key never
275
+ * loses to a nearer-but-wrong match. Bare ids still resolve — every existing caller and
276
+ * spec passes one, and for an unambiguous id it is the same field either way.
277
+ */
278
+ findFieldInTabs(tabs = [], key) {
279
+ return this.findFieldBy(tabs, f => f.refererField === key) ?? this.findFieldBy(tabs, f => f.id === key);
280
+ }
281
+ findFieldBy(tabs = [], match) {
268
282
  for (const t of tabs) {
269
- if (t.fields) {
270
- const found = t.fields.find(f => f.id === id);
271
- if (found)
272
- return found;
273
- }
283
+ const found = t.fields?.find(match);
284
+ if (found)
285
+ return found;
274
286
  if (t.children) {
275
- const found = this.findFieldInTabs(t.children, id);
276
- if (found)
277
- return found;
287
+ const nested = this.findFieldBy(t.children, match);
288
+ if (nested)
289
+ return nested;
278
290
  }
279
291
  }
280
292
  return null;
281
293
  }
294
+ /**
295
+ * The key a caller should hand back to address this exact field. Templates use it so a
296
+ * click on the second of two same-named fields selects that one.
297
+ */
298
+ keyOf(field) {
299
+ return field.refererField || field.id;
300
+ }
301
+ /**
302
+ * Whether this exact field is the selected one.
303
+ *
304
+ * Not `selectedFieldId() === field.id`: selection may hold a bare id or a path depending
305
+ * on who set it, and with two fields sharing an id a bare comparison lights up both rows
306
+ * — or neither, once the click starts storing a path. Resolving both sides answers for
307
+ * the field itself.
308
+ */
309
+ isSelected(field) {
310
+ const key = this._selectedFieldId();
311
+ if (!key)
312
+ return false;
313
+ // Same key, same field — and this is the path a row click takes, so it is the common
314
+ // case. It also covers a field handed in as an @Input that the store's own config has
315
+ // no object for, which identity alone cannot.
316
+ if (key === this.keyOf(field))
317
+ return true;
318
+ // Otherwise the selection is in the other form (a bare id against a field that carries a
319
+ // path, or vice versa), so let it resolve and compare the field it names.
320
+ const resolved = this.findFieldInTabs(this._config().tabs, key);
321
+ return !!resolved && resolved === field;
322
+ }
282
323
  // ─── Field CRUD ─────────────────────────────────────────────────────────────
283
324
  addField(type, targetTabId) {
284
325
  const meta = getFieldTypeMeta(type);
@@ -321,7 +362,7 @@ class BuilderStore {
321
362
  return;
322
363
  if (!this.findFieldInTabs(this._config().tabs, oldId))
323
364
  return;
324
- this.applyRename(oldId, trimmed);
365
+ this.applyRename(oldId, oldId, trimmed);
325
366
  this.manualIds.delete(oldId);
326
367
  this.manualIds.add(trimmed);
327
368
  }
@@ -337,9 +378,23 @@ class BuilderStore {
337
378
  * That was survivable while renaming was a rare manual act; label-derived ids make it
338
379
  * routine, so the references move with it.
339
380
  */
340
- applyRename(oldId, newId) {
381
+ /**
382
+ * `key` addresses the field to rename; `oldId` is the bare id every *reference* to it
383
+ * carries. They were one parameter, which worked only while an id was unique across the
384
+ * config: resolving needs the path, while `showWhen`, `parentField`, `autoPatch` targets
385
+ * and rules are all keyed by bare id and never match a path.
386
+ */
387
+ applyRename(key, oldId, newId) {
388
+ // Selection is held as whatever the caller passed — `addField` stores a bare id while a
389
+ // canvas click stores a path — so comparing the two strings said "not selected" for a
390
+ // field that plainly was, and the inspector emptied itself mid-rename. Compare the
391
+ // fields the two keys resolve to instead.
392
+ const selectedKey = this._selectedFieldId();
393
+ const tabsNow = this._config().tabs;
394
+ const wasSelected = !!selectedKey &&
395
+ this.findFieldInTabs(tabsNow, selectedKey) === this.findFieldInTabs(tabsNow, key);
341
396
  this.mutate(draft => {
342
- const field = this.findFieldInTabs(draft.tabs, oldId);
397
+ const field = this.findFieldInTabs(draft.tabs, key);
343
398
  if (!field)
344
399
  return;
345
400
  field.id = newId;
@@ -386,19 +441,40 @@ class BuilderStore {
386
441
  fieldId: rule.fieldId === oldId ? newId : rule.fieldId,
387
442
  targets: rule.targets.map(t => t.type === 'field' && t.id === oldId ? { ...t, id: newId } : t),
388
443
  })));
389
- if (this._selectedFieldId() === oldId)
390
- this._selectedFieldId.set(newId);
444
+ // `mutate` restamps `refererField`, so the key's last segment moves with the id. Without
445
+ // this the selection points at a path nothing resolves to any more, and the inspector
446
+ // empties itself mid-keystroke as soon as a typed label derives a new id.
447
+ if (wasSelected) {
448
+ // Keep the shape the selection already had: a bare id stays bare, a path keeps its
449
+ // scope and moves only its last segment.
450
+ const cut = selectedKey.lastIndexOf('.');
451
+ this._selectedFieldId.set(cut === -1 ? newId : `${selectedKey.slice(0, cut + 1)}${newId}`);
452
+ }
391
453
  }
392
- removeField(id) {
454
+ removeField(key) {
455
+ let removedId = null;
393
456
  this.mutate(draft => {
457
+ // Resolve inside the draft, then remove that one by identity. Filtering on
458
+ // `f.id !== id` deleted every field sharing the id, so removing `work.address` took
459
+ // `personal.address` with it.
460
+ const target = this.findFieldInTabs(draft.tabs, key);
461
+ if (!target)
462
+ return;
463
+ removedId = target.id;
394
464
  for (const tab of this.flattenTabs(draft.tabs)) {
395
465
  if (tab.fields) {
396
- tab.fields = tab.fields.filter(f => f.id !== id);
466
+ tab.fields = tab.fields.filter(f => f !== target);
397
467
  }
398
468
  }
399
469
  });
400
- this.manualIds.delete(id);
401
- if (this._selectedFieldId() === id)
470
+ if (removedId === null)
471
+ return;
472
+ // `manualIds` is keyed by bare id and shared by every field carrying it, so it is only
473
+ // safe to forget once no field uses that id any more.
474
+ if (!this.getAllFields(this._config().tabs ?? []).some(f => f.id === removedId)) {
475
+ this.manualIds.delete(removedId);
476
+ }
477
+ if (this._selectedFieldId() === key)
402
478
  this._selectedFieldId.set(null);
403
479
  }
404
480
  duplicateField(id) {
@@ -425,11 +501,17 @@ class BuilderStore {
425
501
  this._selectedFieldId.set(newId);
426
502
  return newId;
427
503
  }
428
- moveField(id, direction) {
504
+ moveField(key, direction) {
429
505
  this.mutate(draft => {
506
+ // Resolve by key, then locate that object — `f.id === id` moved whichever same-named
507
+ // field the walk reached first, which for two `address` fields was never the one the
508
+ // arrow button belonged to.
509
+ const wanted = this.findFieldInTabs(draft.tabs, key);
510
+ if (!wanted)
511
+ return;
430
512
  for (const tab of this.flattenTabs(draft.tabs)) {
431
513
  const fields = tab.fields ?? [];
432
- const from = fields.findIndex(f => f.id === id);
514
+ const from = fields.findIndex(f => f === wanted);
433
515
  if (from !== -1) {
434
516
  const to = from + direction;
435
517
  if (to < 0 || to >= fields.length)
@@ -461,8 +543,13 @@ class BuilderStore {
461
543
  const targetTab = this.flattenTabs(draft.tabs).find(t => t.id === targetTabId);
462
544
  if (!targetTab)
463
545
  return;
546
+ // Resolve by key first so a path names one field, then locate that object. Matching
547
+ // `f.id === id` moved whichever same-named field came first in the walk.
548
+ const wanted = this.findFieldInTabs(draft.tabs, id);
549
+ if (!wanted)
550
+ return;
464
551
  for (const tab of this.flattenTabs(draft.tabs)) {
465
- const index = (tab.fields ?? []).findIndex(f => f.id === id);
552
+ const index = (tab.fields ?? []).findIndex(f => f === wanted);
466
553
  if (index === -1)
467
554
  continue;
468
555
  if (tab.id === targetTabId)
@@ -509,17 +596,25 @@ class BuilderStore {
509
596
  * Only the config's default language drives the id; translating a label must not rename
510
597
  * anything.
511
598
  */
512
- setFieldLabel(id, language, value) {
599
+ setFieldLabel(key, language, value) {
513
600
  // Bound to a keystroke event, so this uses the structural-sharing path.
514
- this.mutateField(id, field => ({ ...field, label: { ...field.label, [language]: value } }));
601
+ this.mutateField(key, field => ({ ...field, label: { ...field.label, [language]: value } }));
515
602
  if (language !== (this._config().defaultLanguage ?? 'en'))
516
603
  return;
517
- if (this.manualIds.has(id))
604
+ // The mutation is addressed by key, but everything below is about the *id*: `manualIds`
605
+ // records bare ids, `availableId` compares bare ids, and a rename replaces one. Reading
606
+ // the field's own id keeps the two straight — passing the path into `manualIds.has`
607
+ // would never match, so a pinned id would silently start following the label again.
608
+ const field = this.findFieldInTabs(this._config().tabs, key);
609
+ if (!field)
610
+ return;
611
+ const currentId = field.id;
612
+ if (this.manualIds.has(currentId))
518
613
  return;
519
614
  const derived = labelToId(value);
520
- if (!derived || derived === id)
615
+ if (!derived || derived === currentId)
521
616
  return;
522
- this.applyRename(id, this.availableId(derived, id));
617
+ this.applyRename(this.keyOf(field), currentId, this.availableId(derived, currentId));
523
618
  }
524
619
  /** `derived`, or `derived_2`, `derived_3`… if another field already holds it. */
525
620
  availableId(derived, currentId) {
@@ -1129,14 +1224,21 @@ class BuilderStore {
1129
1224
  *
1130
1225
  * Returns without touching anything when the field is not found.
1131
1226
  */
1132
- mutateField(id, update) {
1227
+ mutateField(key, update) {
1133
1228
  let found = false;
1229
+ // Resolve once, then match on object identity. Matching on `field.id === id` inside the
1230
+ // walk updated *every* field carrying that id, so renaming `work.address` also rewrote
1231
+ // `personal.address` — both rows changed, and the second write was silent. Identity
1232
+ // cannot be ambiguous the way a bare id can.
1233
+ const target = this.findFieldInTabs(this._config().tabs, key);
1234
+ if (!target)
1235
+ return;
1134
1236
  const visitFields = (fields) => {
1135
1237
  if (!fields)
1136
1238
  return fields;
1137
1239
  let changed = false;
1138
1240
  const next = fields.map(field => {
1139
- if (field.id === id) {
1241
+ if (field === target) {
1140
1242
  found = true;
1141
1243
  changed = true;
1142
1244
  return update(field);
@@ -1235,24 +1337,37 @@ class BuilderStore {
1235
1337
  });
1236
1338
  }
1237
1339
  }
1238
- // Id uniqueness is a whole-config invariant, and it is counted over the entire field
1239
- // tree rather than `allFields`, which stops at a tab's own fields. Two ids colliding
1240
- // inside a group or array went unreported even though rules, showWhen conditions and
1241
- // autoPatch mappings all address fields by bare id and would resolve to whichever one
1242
- // the lookup happened to reach first.
1243
- const idCounts = new Map();
1244
- const countIds = (fields) => {
1245
- for (const f of fields ?? []) {
1246
- idCounts.set(f.id, (idCounts.get(f.id) ?? 0) + 1);
1247
- if (f.children?.length)
1248
- countIds(f.children);
1249
- }
1250
- };
1251
- for (const tab of this.flattenTabs(config.tabs))
1252
- countIds(tab.fields);
1253
- for (const [id, count] of idCounts) {
1340
+ // Id uniqueness is a *per-scope* invariant, not a whole-config one — this counted into
1341
+ // one flat map keyed by bare id, so it rejected `personal.address` alongside
1342
+ // `work.address` and made every such config unopenable: Save is disabled while an error
1343
+ // stands, so unrelated edits could not be saved either. The runtime, the record shape
1344
+ // and `validateConfig` have all accepted per-scope ids since 1.4.0; this was the last
1345
+ // component still enforcing the old rule, and it enforced it against configs the rest
1346
+ // of the stack calls valid.
1347
+ //
1348
+ // The scope rule itself comes from core rather than being reimplemented here, so the
1349
+ // builder and the validator cannot drift apart on what "same scope" means.
1350
+ const scopeCounts = new Map();
1351
+ for (const entry of collectFieldScopes(config)) {
1352
+ if (!entry.field.id)
1353
+ continue;
1354
+ const key = `${entry.scope}::${entry.field.id}`;
1355
+ const seen = scopeCounts.get(key);
1356
+ if (seen)
1357
+ seen.count += 1;
1358
+ else
1359
+ scopeCounts.set(key, { id: entry.field.id, count: 1 });
1360
+ }
1361
+ for (const [key, { id, count }] of scopeCounts) {
1254
1362
  if (count > 1) {
1255
- problems.push({ level: 'error', message: `Duplicate field id "${id}" (${count}×).`, fieldId: id });
1363
+ // Still an error, and it must stay one: two fields in the same scope share a single
1364
+ // control and a single record key, so the second silently overwrites the first.
1365
+ const scope = key.slice(0, key.lastIndexOf('::'));
1366
+ problems.push({
1367
+ level: 'error',
1368
+ message: `Duplicate field id "${id}" (${count}×) in ${scope}. Two fields in one scope share a control and a record key.`,
1369
+ fieldId: id,
1370
+ });
1256
1371
  }
1257
1372
  }
1258
1373
  return problems;
@@ -1341,7 +1456,7 @@ class EntityReferenceConfigComponent {
1341
1456
  return field.entityReference ?? { enabled: true };
1342
1457
  }
1343
1458
  patchRef(field, patch) {
1344
- this.store.updateEntityReference(field.id, patch);
1459
+ this.store.updateEntityReference(this.store.keyOf(field), patch);
1345
1460
  }
1346
1461
  fieldLabel(field) {
1347
1462
  return resolveLabel(field.label, this.store.activeLanguage());
@@ -1482,7 +1597,7 @@ class EntityReferenceConfigComponent {
1482
1597
  mat-stroked-button
1483
1598
  type="button"
1484
1599
  data-testid="add-auto-patch"
1485
- (click)="store.addAutoPatchMapping(f.id)"
1600
+ (click)="store.addAutoPatchMapping(store.keyOf(f))"
1486
1601
  >
1487
1602
  <mat-icon>add</mat-icon> Mapping
1488
1603
  </button>
@@ -1494,7 +1609,7 @@ class EntityReferenceConfigComponent {
1494
1609
  <mat-select
1495
1610
  data-testid="auto-patch-tab"
1496
1611
  [ngModel]="f.autoPatch.targetTab"
1497
- (ngModelChange)="store.setAutoPatchTargetTab(f.id, $event)"
1612
+ (ngModelChange)="store.setAutoPatchTargetTab(store.keyOf(f), $event)"
1498
1613
  >
1499
1614
  @for (tab of store.tabs(); track tab.id) {
1500
1615
  <mat-option [value]="tab.id">{{ tabLabel(tab.id) }}</mat-option>
@@ -1509,7 +1624,7 @@ class EntityReferenceConfigComponent {
1509
1624
  <input
1510
1625
  matInput
1511
1626
  [ngModel]="mapping.source"
1512
- (ngModelChange)="store.updateAutoPatchMapping(f.id, $index, { source: $event })"
1627
+ (ngModelChange)="store.updateAutoPatchMapping(store.keyOf(f), $index, { source: $event })"
1513
1628
  />
1514
1629
  </mat-form-field>
1515
1630
  <mat-form-field appearance="outline" subscriptSizing="dynamic">
@@ -1517,7 +1632,7 @@ class EntityReferenceConfigComponent {
1517
1632
  <mat-select
1518
1633
  data-testid="auto-patch-target"
1519
1634
  [ngModel]="mapping.target"
1520
- (ngModelChange)="store.updateAutoPatchMapping(f.id, $index, { target: $event })"
1635
+ (ngModelChange)="store.updateAutoPatchMapping(store.keyOf(f), $index, { target: $event })"
1521
1636
  >
1522
1637
  @for (option of optionsWith(mapping.target); track option.value) {
1523
1638
  <mat-option [value]="option.value">
@@ -1531,7 +1646,7 @@ class EntityReferenceConfigComponent {
1531
1646
  type="button"
1532
1647
  color="warn"
1533
1648
  matTooltip="Remove mapping"
1534
- (click)="store.removeAutoPatchMapping(f.id, $index)"
1649
+ (click)="store.removeAutoPatchMapping(store.keyOf(f), $index)"
1535
1650
  >
1536
1651
  <mat-icon>delete</mat-icon>
1537
1652
  </button>
@@ -1654,7 +1769,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.22", ngImpo
1654
1769
  mat-stroked-button
1655
1770
  type="button"
1656
1771
  data-testid="add-auto-patch"
1657
- (click)="store.addAutoPatchMapping(f.id)"
1772
+ (click)="store.addAutoPatchMapping(store.keyOf(f))"
1658
1773
  >
1659
1774
  <mat-icon>add</mat-icon> Mapping
1660
1775
  </button>
@@ -1666,7 +1781,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.22", ngImpo
1666
1781
  <mat-select
1667
1782
  data-testid="auto-patch-tab"
1668
1783
  [ngModel]="f.autoPatch.targetTab"
1669
- (ngModelChange)="store.setAutoPatchTargetTab(f.id, $event)"
1784
+ (ngModelChange)="store.setAutoPatchTargetTab(store.keyOf(f), $event)"
1670
1785
  >
1671
1786
  @for (tab of store.tabs(); track tab.id) {
1672
1787
  <mat-option [value]="tab.id">{{ tabLabel(tab.id) }}</mat-option>
@@ -1681,7 +1796,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.22", ngImpo
1681
1796
  <input
1682
1797
  matInput
1683
1798
  [ngModel]="mapping.source"
1684
- (ngModelChange)="store.updateAutoPatchMapping(f.id, $index, { source: $event })"
1799
+ (ngModelChange)="store.updateAutoPatchMapping(store.keyOf(f), $index, { source: $event })"
1685
1800
  />
1686
1801
  </mat-form-field>
1687
1802
  <mat-form-field appearance="outline" subscriptSizing="dynamic">
@@ -1689,7 +1804,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.22", ngImpo
1689
1804
  <mat-select
1690
1805
  data-testid="auto-patch-target"
1691
1806
  [ngModel]="mapping.target"
1692
- (ngModelChange)="store.updateAutoPatchMapping(f.id, $index, { target: $event })"
1807
+ (ngModelChange)="store.updateAutoPatchMapping(store.keyOf(f), $index, { target: $event })"
1693
1808
  >
1694
1809
  @for (option of optionsWith(mapping.target); track option.value) {
1695
1810
  <mat-option [value]="option.value">
@@ -1703,7 +1818,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.22", ngImpo
1703
1818
  type="button"
1704
1819
  color="warn"
1705
1820
  matTooltip="Remove mapping"
1706
- (click)="store.removeAutoPatchMapping(f.id, $index)"
1821
+ (click)="store.removeAutoPatchMapping(store.keyOf(f), $index)"
1707
1822
  >
1708
1823
  <mat-icon>delete</mat-icon>
1709
1824
  </button>
@@ -2212,20 +2327,20 @@ class ReferencedFieldConfigComponent {
2212
2327
  field = computed(() => this.store.selectedField(), ...(ngDevMode ? [{ debugName: "field" }] : /* istanbul ignore next */ []));
2213
2328
  toggleReferenced(field, enabled) {
2214
2329
  if (enabled) {
2215
- this.store.updateField(field.id, { isReferenced: true });
2330
+ this.store.updateField(this.store.keyOf(field), { isReferenced: true });
2216
2331
  }
2217
2332
  else {
2218
- this.store.unlinkReferencedField(field.id);
2333
+ this.store.unlinkReferencedField(this.store.keyOf(field));
2219
2334
  }
2220
2335
  }
2221
2336
  updateEntityKey(field, key) {
2222
2337
  const trimmed = key.trim();
2223
- this.store.updateField(field.id, { referencedEntityKey: trimmed || undefined });
2338
+ this.store.updateField(this.store.keyOf(field), { referencedEntityKey: trimmed || undefined });
2224
2339
  this.checkDriftForField(field.id);
2225
2340
  }
2226
2341
  updateFieldId(field, fieldId) {
2227
2342
  const trimmed = fieldId.trim();
2228
- this.store.updateField(field.id, { referencedFieldId: trimmed || undefined });
2343
+ this.store.updateField(this.store.keyOf(field), { referencedFieldId: trimmed || undefined });
2229
2344
  this.checkDriftForField(field.id);
2230
2345
  }
2231
2346
  async syncWithSource(field) {
@@ -2236,7 +2351,7 @@ class ReferencedFieldConfigComponent {
2236
2351
  return;
2237
2352
  const sourceField = this.findField(sourceConfig.tabs ?? [], field.referencedFieldId);
2238
2353
  if (sourceField) {
2239
- this.store.syncReferencedField(field.id, sourceField);
2354
+ this.store.syncReferencedField(this.store.keyOf(field), sourceField);
2240
2355
  }
2241
2356
  }
2242
2357
  /**
@@ -2479,7 +2594,7 @@ class FieldInspectorComponent {
2479
2594
  }
2480
2595
  moveToTab(field, tabId) {
2481
2596
  if (tabId && tabId !== this.currentTabId(field))
2482
- this.store.moveFieldToTab(field.id, tabId);
2597
+ this.store.moveFieldToTab(this.store.keyOf(field), tabId);
2483
2598
  }
2484
2599
  /**
2485
2600
  * Fields offered wherever this inspector names one — the `showWhen` key and both ends of a
@@ -2516,7 +2631,7 @@ class FieldInspectorComponent {
2516
2631
  while (key in next)
2517
2632
  key = `field_${++n}`;
2518
2633
  next[key] = true;
2519
- this.store.setShowWhen(field.id, next);
2634
+ this.store.setShowWhen(this.store.keyOf(field), next);
2520
2635
  }
2521
2636
  renameShowWhen(field, oldKey, newKey) {
2522
2637
  const trimmed = newKey.trim();
@@ -2529,15 +2644,15 @@ class FieldInspectorComponent {
2529
2644
  // Rebuild in order so the row does not jump while the user is typing.
2530
2645
  for (const [k, v] of Object.entries(current))
2531
2646
  next[k === oldKey ? trimmed : k] = v;
2532
- this.store.setShowWhen(field.id, next);
2647
+ this.store.setShowWhen(this.store.keyOf(field), next);
2533
2648
  }
2534
2649
  setShowWhenValue(field, key, raw) {
2535
- this.store.setShowWhen(field.id, { ...(field.showWhen ?? {}), [key]: this.parseShowWhen(raw) });
2650
+ this.store.setShowWhen(this.store.keyOf(field), { ...(field.showWhen ?? {}), [key]: this.parseShowWhen(raw) });
2536
2651
  }
2537
2652
  removeShowWhen(field, key) {
2538
2653
  const next = { ...(field.showWhen ?? {}) };
2539
2654
  delete next[key];
2540
- this.store.setShowWhen(field.id, next);
2655
+ this.store.setShowWhen(this.store.keyOf(field), next);
2541
2656
  }
2542
2657
  /**
2543
2658
  * `showWhen` compares with `!==`, so the authored type has to survive the round trip:
@@ -2563,7 +2678,7 @@ class FieldInspectorComponent {
2563
2678
  return String(value);
2564
2679
  }
2565
2680
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.22", ngImport: i0, type: FieldInspectorComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
2566
- static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.22", type: FieldInspectorComponent, isStandalone: true, selector: "ngx-field-inspector", ngImport: i0, template: "@if (field(); as f) {\n <div class=\"deb-inspector\">\n <!-- Identity -->\n <div class=\"deb-row\">\n <mat-form-field appearance=\"outline\" subscriptSizing=\"dynamic\">\n <mat-label>Field id</mat-label>\n <input matInput data-testid=\"field-id\" [value]=\"f.id\" disabled />\n @if (store.hasManualId(f.id)) {\n <mat-hint>The stored data key for this saved field \u2014 fixed.</mat-hint>\n } @else {\n <mat-hint>Derived from the label. Used as the data key.</mat-hint>\n }\n </mat-form-field>\n <span class=\"deb-chip\">{{ meta()?.label || f.type }}</span>\n </div>\n\n @if (tabOptions().length > 1) {\n <mat-form-field appearance=\"outline\" subscriptSizing=\"dynamic\" class=\"deb-full\">\n <mat-label>Tab</mat-label>\n <mat-select data-testid=\"field-tab\"\n [ngModel]=\"currentTabId(f)\" (ngModelChange)=\"moveToTab(f, $event)\">\n @for (tab of tabOptions(); track tab.id) {\n <mat-option [value]=\"tab.id\">{{ tab.label }}</mat-option>\n }\n </mat-select>\n <mat-hint>Moving the field rewrites its path and repoints the rules that named it.</mat-hint>\n </mat-form-field>\n }\n\n\n <!-- Localised text (active language) -->\n <mat-form-field appearance=\"outline\" subscriptSizing=\"dynamic\" class=\"deb-full\">\n <mat-label>Label ({{ lang() }})</mat-label>\n <input\n matInput\n data-testid=\"field-label\"\n [ngModel]=\"labelValue(f)\"\n (ngModelChange)=\"store.setFieldLabel(f.id, lang(), $event)\"\n />\n </mat-form-field>\n\n @if (meta()?.supportsPlaceholder) {\n <mat-form-field appearance=\"outline\" subscriptSizing=\"dynamic\" class=\"deb-full\">\n <mat-label>Placeholder ({{ lang() }})</mat-label>\n <input\n matInput\n [ngModel]=\"placeholderValue(f)\"\n (ngModelChange)=\"store.setFieldPlaceholder(f.id, lang(), $event)\"\n />\n </mat-form-field>\n }\n\n @if (meta()?.supportsDefaultValue) {\n <mat-form-field appearance=\"outline\" subscriptSizing=\"dynamic\" class=\"deb-full\">\n <mat-label>Default value</mat-label>\n <input\n matInput\n [ngModel]=\"f.defaultValue ?? ''\"\n (ngModelChange)=\"store.updateField(f.id, { defaultValue: $event })\"\n />\n </mat-form-field>\n }\n\n <!-- Entity reference, cascade, and autoPatch -->\n @if (meta()?.isEntityRef) {\n <mat-divider></mat-divider>\n <ngx-entity-reference-config />\n }\n\n <mat-divider></mat-divider>\n\n <!-- Validators -->\n @if (meta(); as m) {\n @if (m.flagValidators.length || m.paramValidators.length) {\n <span class=\"deb-section-title\">Validation</span>\n <div class=\"deb-toggles\">\n @for (v of m.flagValidators; track v) {\n <mat-checkbox\n [ngModel]=\"store.hasFlagValidator(f, v)\"\n (ngModelChange)=\"store.toggleFlagValidator(f.id, v, $event)\"\n >{{ v }}</mat-checkbox>\n }\n </div>\n @for (v of m.paramValidators; track v) {\n <mat-form-field appearance=\"outline\" subscriptSizing=\"dynamic\" class=\"deb-full\">\n <mat-label>{{ v }}</mat-label>\n <input\n matInput\n type=\"number\"\n [ngModel]=\"store.getParamValidator(f, v)\"\n (ngModelChange)=\"store.setParamValidator(f.id, v, toNum($event))\"\n />\n </mat-form-field>\n }\n }\n }\n\n <!-- Options (dropdown / radio / multiSelect) -->\n @if (meta()?.hasOptions) {\n <mat-divider></mat-divider>\n <span class=\"deb-section-title\">Data source</span>\n <mat-form-field appearance=\"outline\" subscriptSizing=\"dynamic\" class=\"deb-full\">\n <mat-label>Options come from</mat-label>\n <mat-select\n data-testid=\"data-source\"\n [ngModel]=\"store.fieldDataSource(f)\"\n (ngModelChange)=\"store.setFieldDataSource(f.id, $event)\"\n >\n <mat-option value=\"none\">Nothing yet</mat-option>\n <mat-option value=\"manual\">Options authored here</mat-option>\n <mat-option value=\"lookup\">A named list</mat-option>\n </mat-select>\n </mat-form-field>\n }\n\n <!-- Named lookup list -->\n @if (meta()?.hasOptions && store.fieldDataSource(f) === 'lookup') {\n <mat-form-field appearance=\"outline\" subscriptSizing=\"dynamic\" class=\"deb-full\">\n <mat-label>List name</mat-label>\n <input\n matInput\n data-testid=\"list-name\"\n [ngModel]=\"f.listName ?? ''\"\n (ngModelChange)=\"store.setListName(f.id, $event)\"\n />\n </mat-form-field>\n <p class=\"deb-hint\">\n The consuming app resolves this name through its lookup registry. Options are not\n authored here.\n </p>\n }\n\n <!-- Inline options -->\n @if (meta()?.hasOptions && store.fieldDataSource(f) !== 'lookup') {\n <div class=\"deb-row\" style=\"justify-content: space-between;\">\n <span class=\"deb-section-title\">Options</span>\n <button mat-stroked-button type=\"button\" (click)=\"store.addOption(f.id)\">\n <mat-icon>add</mat-icon> Option\n </button>\n </div>\n @for (opt of f.options ?? []; track $index) {\n <div class=\"deb-option-row\" data-testid=\"option-row\">\n <!-- One input: the displayed text is the stored value. -->\n <mat-form-field appearance=\"outline\" subscriptSizing=\"dynamic\" class=\"deb-full\">\n <mat-label>Option ({{ lang() }})</mat-label>\n <input\n matInput\n [attr.data-testid]=\"'option-' + $index\"\n [ngModel]=\"optionLabel(opt)\"\n (ngModelChange)=\"store.setOptionLabel(f.id, $index, lang(), $event)\"\n />\n </mat-form-field>\n <button mat-icon-button type=\"button\" color=\"warn\"\n (click)=\"store.removeOption(f.id, $index)\" matTooltip=\"Remove option\">\n <mat-icon>delete</mat-icon>\n </button>\n </div>\n }\n @if (!(f.options ?? []).length) {\n <p class=\"deb-hint\">No options yet \u2014 add at least one.</p>\n }\n }\n\n <mat-divider></mat-divider>\n\n <!-- Flags -->\n <span class=\"deb-section-title\">Display</span>\n <div class=\"deb-toggles\">\n <mat-slide-toggle\n [ngModel]=\"f.visibility ?? true\"\n (ngModelChange)=\"store.updateField(f.id, { visibility: $event })\"\n >Visible</mat-slide-toggle>\n <mat-slide-toggle\n [ngModel]=\"f.table?.visible ?? false\"\n (ngModelChange)=\"store.updateField(f.id, { table: { visible: $event } })\"\n >Table column</mat-slide-toggle>\n <mat-slide-toggle\n [ngModel]=\"f.readonly ?? false\"\n (ngModelChange)=\"store.updateField(f.id, { readonly: $event })\"\n >Read-only</mat-slide-toggle>\n <mat-slide-toggle\n [ngModel]=\"f.disabled ?? false\"\n (ngModelChange)=\"store.updateField(f.id, { disabled: $event })\"\n >Disabled</mat-slide-toggle>\n <mat-slide-toggle\n [ngModel]=\"f.maskData ?? false\"\n (ngModelChange)=\"store.updateField(f.id, { maskData: $event })\"\n >Mask data (RBAC)</mat-slide-toggle>\n <mat-slide-toggle\n data-testid=\"toggle-critical\"\n [ngModel]=\"f.criticalField ?? false\"\n (ngModelChange)=\"store.updateField(f.id, { criticalField: $event })\"\n matTooltip=\"Renders locked; editing it announces a change against the session baseline.\"\n >Critical field</mat-slide-toggle>\n <mat-slide-toggle\n [ngModel]=\"f.showOnMinimize ?? false\"\n (ngModelChange)=\"store.updateField(f.id, { showOnMinimize: $event })\"\n matTooltip=\"Shown in the record editor's summary panel.\"\n >Show in summary</mat-slide-toggle>\n </div>\n\n <mat-divider></mat-divider>\n\n <!-- Conditional visibility (static showWhen) -->\n <div class=\"deb-row deb-row--split\">\n <span class=\"deb-section-title\">Show when</span>\n <button mat-stroked-button type=\"button\" data-testid=\"add-show-when\" (click)=\"addShowWhen(f)\">\n <mat-icon>add</mat-icon> Condition\n </button>\n </div>\n @for (entry of showWhenEntries(f); track entry.key) {\n <div class=\"deb-option-row\" data-testid=\"option-row\">\n <mat-form-field appearance=\"outline\" subscriptSizing=\"dynamic\">\n <mat-label>Watched field</mat-label>\n <mat-select data-testid=\"show-when-field\"\n [ngModel]=\"entry.key\" (ngModelChange)=\"renameShowWhen(f, entry.key, $event)\">\n @for (option of optionsWith(entry.key); track option.value) {\n <mat-option [value]=\"option.value\">\n {{ option.label }} <span class=\"deb-path-hint\">{{ option.path }}</span>\n </mat-option>\n }\n </mat-select>\n </mat-form-field>\n <mat-form-field appearance=\"outline\" subscriptSizing=\"dynamic\">\n <mat-label>Equals</mat-label>\n <input matInput [ngModel]=\"entry.display\" (ngModelChange)=\"setShowWhenValue(f, entry.key, $event)\" />\n </mat-form-field>\n <button mat-icon-button type=\"button\" color=\"warn\" matTooltip=\"Remove condition\"\n (click)=\"removeShowWhen(f, entry.key)\">\n <mat-icon>delete</mat-icon>\n </button>\n </div>\n }\n @if (!showWhenEntries(f).length) {\n <p class=\"deb-hint\">Always visible. Add a condition to show this field only for certain values.</p>\n }\n\n <!-- patchOnTrue (boolean/checkbox only) -->\n @if (f.type === 'boolean' || f.type === 'checkbox') {\n <mat-divider></mat-divider>\n <div class=\"deb-row deb-row--split\">\n <span class=\"deb-section-title\">Copy on true</span>\n <button mat-stroked-button type=\"button\" data-testid=\"add-patch-on-true\"\n (click)=\"store.addPatchOnTrueMapping(f.id)\">\n <mat-icon>add</mat-icon> Mapping\n </button>\n </div>\n @for (mapping of f.patchOnTrue ?? []; track $index) {\n <div class=\"deb-option-row\" data-testid=\"option-row\">\n <mat-form-field appearance=\"outline\" subscriptSizing=\"dynamic\">\n <mat-label>From field</mat-label>\n <mat-select data-testid=\"patch-on-true-from\"\n [ngModel]=\"mapping.from\"\n (ngModelChange)=\"store.updatePatchOnTrueMapping(f.id, $index, { from: $event })\">\n @for (option of optionsWith(mapping.from); track option.value) {\n <mat-option [value]=\"option.value\">\n {{ option.label }} <span class=\"deb-path-hint\">{{ option.path }}</span>\n </mat-option>\n }\n </mat-select>\n </mat-form-field>\n <mat-form-field appearance=\"outline\" subscriptSizing=\"dynamic\">\n <mat-label>To field</mat-label>\n <mat-select data-testid=\"patch-on-true-to\"\n [ngModel]=\"mapping.to\"\n (ngModelChange)=\"store.updatePatchOnTrueMapping(f.id, $index, { to: $event })\">\n @for (option of optionsWith(mapping.to); track option.value) {\n <mat-option [value]=\"option.value\">\n {{ option.label }} <span class=\"deb-path-hint\">{{ option.path }}</span>\n </mat-option>\n }\n </mat-select>\n </mat-form-field>\n <button mat-icon-button type=\"button\" color=\"warn\" matTooltip=\"Remove mapping\"\n (click)=\"store.removePatchOnTrueMapping(f.id, $index)\">\n <mat-icon>delete</mat-icon>\n </button>\n </div>\n }\n @if (!(f.patchOnTrue ?? []).length) {\n <p class=\"deb-hint\">When this flips to true, copy one field's value into another.</p>\n }\n }\n\n <mat-divider></mat-divider>\n\n <!-- Rules attached to this field -->\n <ngx-field-rules-list />\n\n <mat-divider></mat-divider>\n\n <!-- Cross-entity field reference & drift management (Phase 8) -->\n <ngx-referenced-field-config />\n </div>\n} @else {\n <div class=\"deb-inspector__empty\">\n <mat-icon>touch_app</mat-icon>\n <p>Select a field to edit its properties.</p>\n </div>\n}\n", styles: [".deb-inspector{display:flex;flex-direction:column;gap:12px}.deb-inspector__empty{color:var(--deb-muted, #6b7280);text-align:center;padding:24px 8px}.deb-full{width:100%}.deb-row{display:flex;gap:8px;align-items:center}.deb-row>*{flex:1}.deb-row--split{justify-content:space-between}.deb-row--split>*{flex:0 0 auto}.deb-toggles{display:grid;grid-template-columns:1fr 1fr;gap:8px 12px}.deb-option-row{display:flex;gap:6px;align-items:center}.deb-chip{display:inline-block;padding:2px 8px;border-radius:12px;background:var(--deb-accent-soft, #eef2ff);color:var(--deb-accent, #6366f1);font-size:12px;font-weight:600}\n"], dependencies: [{ kind: "ngmodule", type: FormsModule }, { kind: "directive", type: i1.DefaultValueAccessor, selector: "input:not([type=checkbox])[formControlName],textarea[formControlName],input:not([type=checkbox])[formControl],textarea[formControl],input:not([type=checkbox])[ngModel],textarea[ngModel],[ngDefaultControl]" }, { kind: "directive", type: i1.NumberValueAccessor, selector: "input[type=number][formControlName],input[type=number][formControl],input[type=number][ngModel]" }, { kind: "directive", type: i1.NgControlStatus, selector: "[formControlName],[ngModel],[formControl]" }, { kind: "directive", type: i1.NgModel, selector: "[ngModel]:not([formControlName]):not([formControl])", inputs: ["name", "disabled", "ngModel", "ngModelOptions"], outputs: ["ngModelChange"], exportAs: ["ngModel"] }, { kind: "ngmodule", type: MatButtonModule }, { kind: "component", type: i2.MatButton, selector: " button[matButton], a[matButton], button[mat-button], button[mat-raised-button], button[mat-flat-button], button[mat-stroked-button], a[mat-button], a[mat-raised-button], a[mat-flat-button], a[mat-stroked-button] ", inputs: ["matButton"], exportAs: ["matButton", "matAnchor"] }, { kind: "component", type: i2.MatIconButton, selector: "button[mat-icon-button], a[mat-icon-button], button[matIconButton], a[matIconButton]", exportAs: ["matButton", "matAnchor"] }, { kind: "ngmodule", type: MatCheckboxModule }, { kind: "component", type: i3$1.MatCheckbox, selector: "mat-checkbox", inputs: ["aria-label", "aria-labelledby", "aria-describedby", "aria-expanded", "aria-controls", "aria-owns", "id", "required", "labelPosition", "name", "value", "disableRipple", "tabIndex", "color", "disabledInteractive", "checked", "disabled", "indeterminate"], outputs: ["change", "indeterminateChange"], exportAs: ["matCheckbox"] }, { kind: "ngmodule", type: MatDividerModule }, { kind: "component", type: i3.MatDivider, selector: "mat-divider", inputs: ["vertical", "inset"] }, { kind: "ngmodule", type: MatFormFieldModule }, { kind: "component", type: i4.MatFormField, selector: "mat-form-field", inputs: ["hideRequiredMarker", "color", "floatLabel", "appearance", "subscriptSizing", "hintLabel"], exportAs: ["matFormField"] }, { kind: "directive", type: i4.MatLabel, selector: "mat-label" }, { kind: "directive", type: i4.MatHint, selector: "mat-hint", inputs: ["align", "id"] }, { kind: "ngmodule", type: MatIconModule }, { kind: "component", type: i4$1.MatIcon, selector: "mat-icon", inputs: ["color", "inline", "svgIcon", "fontSet", "fontIcon"], exportAs: ["matIcon"] }, { kind: "ngmodule", type: MatInputModule }, { kind: "directive", type: i5.MatInput, selector: "input[matInput], textarea[matInput], select[matNativeControl], input[matNativeControl], textarea[matNativeControl]", inputs: ["disabled", "id", "placeholder", "name", "required", "type", "errorStateMatcher", "aria-describedby", "value", "readonly", "disabledInteractive"], exportAs: ["matInput"] }, { kind: "ngmodule", type: MatSelectModule }, { kind: "component", type: i8.MatSelect, selector: "mat-select", inputs: ["aria-describedby", "panelClass", "disabled", "disableRipple", "tabIndex", "hideSingleSelectionIndicator", "placeholder", "required", "multiple", "disableOptionCentering", "compareWith", "value", "aria-label", "aria-labelledby", "errorStateMatcher", "typeaheadDebounceInterval", "sortComparator", "id", "panelWidth", "canSelectNullableOptions"], outputs: ["openedChange", "opened", "closed", "selectionChange", "valueChange"], exportAs: ["matSelect"] }, { kind: "component", type: i8.MatOption, selector: "mat-option", inputs: ["value", "id", "disabled"], outputs: ["onSelectionChange"], exportAs: ["matOption"] }, { kind: "ngmodule", type: MatSlideToggleModule }, { kind: "component", type: i9.MatSlideToggle, selector: "mat-slide-toggle", inputs: ["name", "id", "labelPosition", "aria-label", "aria-labelledby", "aria-describedby", "required", "color", "disabled", "disableRipple", "tabIndex", "checked", "hideIcon", "disabledInteractive"], outputs: ["change", "toggleChange"], exportAs: ["matSlideToggle"] }, { kind: "ngmodule", type: MatTooltipModule }, { kind: "directive", type: i4$2.MatTooltip, selector: "[matTooltip]", inputs: ["matTooltipPosition", "matTooltipPositionAtOrigin", "matTooltipDisabled", "matTooltipShowDelay", "matTooltipHideDelay", "matTooltipTouchGestures", "matTooltip", "matTooltipClass"], exportAs: ["matTooltip"] }, { kind: "component", type: EntityReferenceConfigComponent, selector: "ngx-entity-reference-config" }, { kind: "component", type: FieldRulesListComponent, selector: "ngx-field-rules-list" }, { kind: "component", type: ReferencedFieldConfigComponent, selector: "ngx-referenced-field-config" }] });
2681
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.22", type: FieldInspectorComponent, isStandalone: true, selector: "ngx-field-inspector", ngImport: i0, template: "@if (field(); as f) {\n <div class=\"deb-inspector\">\n <!-- Identity -->\n <div class=\"deb-row\">\n <mat-form-field appearance=\"outline\" subscriptSizing=\"dynamic\">\n <mat-label>Field id</mat-label>\n <input matInput data-testid=\"field-id\" [value]=\"f.id\" disabled />\n @if (store.hasManualId(f.id)) {\n <mat-hint>The stored data key for this saved field \u2014 fixed.</mat-hint>\n } @else {\n <mat-hint>Derived from the label. Used as the data key.</mat-hint>\n }\n </mat-form-field>\n <span class=\"deb-chip\">{{ meta()?.label || f.type }}</span>\n </div>\n\n @if (tabOptions().length > 1) {\n <mat-form-field appearance=\"outline\" subscriptSizing=\"dynamic\" class=\"deb-full\">\n <mat-label>Tab</mat-label>\n <mat-select data-testid=\"field-tab\"\n [ngModel]=\"currentTabId(f)\" (ngModelChange)=\"moveToTab(f, $event)\">\n @for (tab of tabOptions(); track tab.id) {\n <mat-option [value]=\"tab.id\">{{ tab.label }}</mat-option>\n }\n </mat-select>\n <mat-hint>Moving the field rewrites its path and repoints the rules that named it.</mat-hint>\n </mat-form-field>\n }\n\n\n <!-- Localised text (active language) -->\n <mat-form-field appearance=\"outline\" subscriptSizing=\"dynamic\" class=\"deb-full\">\n <mat-label>Label ({{ lang() }})</mat-label>\n <input\n matInput\n data-testid=\"field-label\"\n [ngModel]=\"labelValue(f)\"\n (ngModelChange)=\"store.setFieldLabel(store.keyOf(f), lang(), $event)\"\n />\n </mat-form-field>\n\n @if (meta()?.supportsPlaceholder) {\n <mat-form-field appearance=\"outline\" subscriptSizing=\"dynamic\" class=\"deb-full\">\n <mat-label>Placeholder ({{ lang() }})</mat-label>\n <input\n matInput\n [ngModel]=\"placeholderValue(f)\"\n (ngModelChange)=\"store.setFieldPlaceholder(store.keyOf(f), lang(), $event)\"\n />\n </mat-form-field>\n }\n\n @if (meta()?.supportsDefaultValue) {\n <mat-form-field appearance=\"outline\" subscriptSizing=\"dynamic\" class=\"deb-full\">\n <mat-label>Default value</mat-label>\n <input\n matInput\n [ngModel]=\"f.defaultValue ?? ''\"\n (ngModelChange)=\"store.updateField(store.keyOf(f), { defaultValue: $event })\"\n />\n </mat-form-field>\n }\n\n <!-- Entity reference, cascade, and autoPatch -->\n @if (meta()?.isEntityRef) {\n <mat-divider></mat-divider>\n <ngx-entity-reference-config />\n }\n\n <mat-divider></mat-divider>\n\n <!-- Validators -->\n @if (meta(); as m) {\n @if (m.flagValidators.length || m.paramValidators.length) {\n <span class=\"deb-section-title\">Validation</span>\n <div class=\"deb-toggles\">\n @for (v of m.flagValidators; track v) {\n <mat-checkbox\n [ngModel]=\"store.hasFlagValidator(f, v)\"\n (ngModelChange)=\"store.toggleFlagValidator(store.keyOf(f), v, $event)\"\n >{{ v }}</mat-checkbox>\n }\n </div>\n @for (v of m.paramValidators; track v) {\n <mat-form-field appearance=\"outline\" subscriptSizing=\"dynamic\" class=\"deb-full\">\n <mat-label>{{ v }}</mat-label>\n <input\n matInput\n type=\"number\"\n [ngModel]=\"store.getParamValidator(f, v)\"\n (ngModelChange)=\"store.setParamValidator(store.keyOf(f), v, toNum($event))\"\n />\n </mat-form-field>\n }\n }\n }\n\n <!-- Options (dropdown / radio / multiSelect) -->\n @if (meta()?.hasOptions) {\n <mat-divider></mat-divider>\n <span class=\"deb-section-title\">Data source</span>\n <mat-form-field appearance=\"outline\" subscriptSizing=\"dynamic\" class=\"deb-full\">\n <mat-label>Options come from</mat-label>\n <mat-select\n data-testid=\"data-source\"\n [ngModel]=\"store.fieldDataSource(f)\"\n (ngModelChange)=\"store.setFieldDataSource(store.keyOf(f), $event)\"\n >\n <mat-option value=\"none\">Nothing yet</mat-option>\n <mat-option value=\"manual\">Options authored here</mat-option>\n <mat-option value=\"lookup\">A named list</mat-option>\n </mat-select>\n </mat-form-field>\n }\n\n <!-- Named lookup list -->\n @if (meta()?.hasOptions && store.fieldDataSource(f) === 'lookup') {\n <mat-form-field appearance=\"outline\" subscriptSizing=\"dynamic\" class=\"deb-full\">\n <mat-label>List name</mat-label>\n <input\n matInput\n data-testid=\"list-name\"\n [ngModel]=\"f.listName ?? ''\"\n (ngModelChange)=\"store.setListName(store.keyOf(f), $event)\"\n />\n </mat-form-field>\n <p class=\"deb-hint\">\n The consuming app resolves this name through its lookup registry. Options are not\n authored here.\n </p>\n }\n\n <!-- Inline options -->\n @if (meta()?.hasOptions && store.fieldDataSource(f) !== 'lookup') {\n <div class=\"deb-row\" style=\"justify-content: space-between;\">\n <span class=\"deb-section-title\">Options</span>\n <button mat-stroked-button type=\"button\" (click)=\"store.addOption(store.keyOf(f))\">\n <mat-icon>add</mat-icon> Option\n </button>\n </div>\n @for (opt of f.options ?? []; track $index) {\n <div class=\"deb-option-row\" data-testid=\"option-row\">\n <!-- One input: the displayed text is the stored value. -->\n <mat-form-field appearance=\"outline\" subscriptSizing=\"dynamic\" class=\"deb-full\">\n <mat-label>Option ({{ lang() }})</mat-label>\n <input\n matInput\n [attr.data-testid]=\"'option-' + $index\"\n [ngModel]=\"optionLabel(opt)\"\n (ngModelChange)=\"store.setOptionLabel(store.keyOf(f), $index, lang(), $event)\"\n />\n </mat-form-field>\n <button mat-icon-button type=\"button\" color=\"warn\"\n (click)=\"store.removeOption(store.keyOf(f), $index)\" matTooltip=\"Remove option\">\n <mat-icon>delete</mat-icon>\n </button>\n </div>\n }\n @if (!(f.options ?? []).length) {\n <p class=\"deb-hint\">No options yet \u2014 add at least one.</p>\n }\n }\n\n <mat-divider></mat-divider>\n\n <!-- Flags -->\n <span class=\"deb-section-title\">Display</span>\n <div class=\"deb-toggles\">\n <mat-slide-toggle\n [ngModel]=\"f.visibility ?? true\"\n (ngModelChange)=\"store.updateField(store.keyOf(f), { visibility: $event })\"\n >Visible</mat-slide-toggle>\n <mat-slide-toggle\n [ngModel]=\"f.table?.visible ?? false\"\n (ngModelChange)=\"store.updateField(store.keyOf(f), { table: { visible: $event } })\"\n >Table column</mat-slide-toggle>\n <mat-slide-toggle\n [ngModel]=\"f.readonly ?? false\"\n (ngModelChange)=\"store.updateField(store.keyOf(f), { readonly: $event })\"\n >Read-only</mat-slide-toggle>\n <mat-slide-toggle\n [ngModel]=\"f.disabled ?? false\"\n (ngModelChange)=\"store.updateField(store.keyOf(f), { disabled: $event })\"\n >Disabled</mat-slide-toggle>\n <mat-slide-toggle\n [ngModel]=\"f.maskData ?? false\"\n (ngModelChange)=\"store.updateField(store.keyOf(f), { maskData: $event })\"\n >Mask data (RBAC)</mat-slide-toggle>\n <mat-slide-toggle\n data-testid=\"toggle-critical\"\n [ngModel]=\"f.criticalField ?? false\"\n (ngModelChange)=\"store.updateField(store.keyOf(f), { criticalField: $event })\"\n matTooltip=\"Renders locked; editing it announces a change against the session baseline.\"\n >Critical field</mat-slide-toggle>\n <mat-slide-toggle\n [ngModel]=\"f.showOnMinimize ?? false\"\n (ngModelChange)=\"store.updateField(store.keyOf(f), { showOnMinimize: $event })\"\n matTooltip=\"Shown in the record editor's summary panel.\"\n >Show in summary</mat-slide-toggle>\n </div>\n\n <mat-divider></mat-divider>\n\n <!-- Conditional visibility (static showWhen) -->\n <div class=\"deb-row deb-row--split\">\n <span class=\"deb-section-title\">Show when</span>\n <button mat-stroked-button type=\"button\" data-testid=\"add-show-when\" (click)=\"addShowWhen(f)\">\n <mat-icon>add</mat-icon> Condition\n </button>\n </div>\n @for (entry of showWhenEntries(f); track entry.key) {\n <div class=\"deb-option-row\" data-testid=\"option-row\">\n <mat-form-field appearance=\"outline\" subscriptSizing=\"dynamic\">\n <mat-label>Watched field</mat-label>\n <mat-select data-testid=\"show-when-field\"\n [ngModel]=\"entry.key\" (ngModelChange)=\"renameShowWhen(f, entry.key, $event)\">\n @for (option of optionsWith(entry.key); track option.value) {\n <mat-option [value]=\"option.value\">\n {{ option.label }} <span class=\"deb-path-hint\">{{ option.path }}</span>\n </mat-option>\n }\n </mat-select>\n </mat-form-field>\n <mat-form-field appearance=\"outline\" subscriptSizing=\"dynamic\">\n <mat-label>Equals</mat-label>\n <input matInput [ngModel]=\"entry.display\" (ngModelChange)=\"setShowWhenValue(f, entry.key, $event)\" />\n </mat-form-field>\n <button mat-icon-button type=\"button\" color=\"warn\" matTooltip=\"Remove condition\"\n (click)=\"removeShowWhen(f, entry.key)\">\n <mat-icon>delete</mat-icon>\n </button>\n </div>\n }\n @if (!showWhenEntries(f).length) {\n <p class=\"deb-hint\">Always visible. Add a condition to show this field only for certain values.</p>\n }\n\n <!-- patchOnTrue (boolean/checkbox only) -->\n @if (f.type === 'boolean' || f.type === 'checkbox') {\n <mat-divider></mat-divider>\n <div class=\"deb-row deb-row--split\">\n <span class=\"deb-section-title\">Copy on true</span>\n <button mat-stroked-button type=\"button\" data-testid=\"add-patch-on-true\"\n (click)=\"store.addPatchOnTrueMapping(store.keyOf(f))\">\n <mat-icon>add</mat-icon> Mapping\n </button>\n </div>\n @for (mapping of f.patchOnTrue ?? []; track $index) {\n <div class=\"deb-option-row\" data-testid=\"option-row\">\n <mat-form-field appearance=\"outline\" subscriptSizing=\"dynamic\">\n <mat-label>From field</mat-label>\n <mat-select data-testid=\"patch-on-true-from\"\n [ngModel]=\"mapping.from\"\n (ngModelChange)=\"store.updatePatchOnTrueMapping(store.keyOf(f), $index, { from: $event })\">\n @for (option of optionsWith(mapping.from); track option.value) {\n <mat-option [value]=\"option.value\">\n {{ option.label }} <span class=\"deb-path-hint\">{{ option.path }}</span>\n </mat-option>\n }\n </mat-select>\n </mat-form-field>\n <mat-form-field appearance=\"outline\" subscriptSizing=\"dynamic\">\n <mat-label>To field</mat-label>\n <mat-select data-testid=\"patch-on-true-to\"\n [ngModel]=\"mapping.to\"\n (ngModelChange)=\"store.updatePatchOnTrueMapping(store.keyOf(f), $index, { to: $event })\">\n @for (option of optionsWith(mapping.to); track option.value) {\n <mat-option [value]=\"option.value\">\n {{ option.label }} <span class=\"deb-path-hint\">{{ option.path }}</span>\n </mat-option>\n }\n </mat-select>\n </mat-form-field>\n <button mat-icon-button type=\"button\" color=\"warn\" matTooltip=\"Remove mapping\"\n (click)=\"store.removePatchOnTrueMapping(store.keyOf(f), $index)\">\n <mat-icon>delete</mat-icon>\n </button>\n </div>\n }\n @if (!(f.patchOnTrue ?? []).length) {\n <p class=\"deb-hint\">When this flips to true, copy one field's value into another.</p>\n }\n }\n\n <mat-divider></mat-divider>\n\n <!-- Rules attached to this field -->\n <ngx-field-rules-list />\n\n <mat-divider></mat-divider>\n\n <!-- Cross-entity field reference & drift management (Phase 8) -->\n <ngx-referenced-field-config />\n </div>\n} @else {\n <div class=\"deb-inspector__empty\">\n <mat-icon>touch_app</mat-icon>\n <p>Select a field to edit its properties.</p>\n </div>\n}\n", styles: [".deb-inspector{display:flex;flex-direction:column;gap:12px}.deb-inspector__empty{color:var(--deb-muted, #6b7280);text-align:center;padding:24px 8px}.deb-full{width:100%}.deb-row{display:flex;gap:8px;align-items:center}.deb-row>*{flex:1}.deb-row--split{justify-content:space-between}.deb-row--split>*{flex:0 0 auto}.deb-toggles{display:grid;grid-template-columns:1fr 1fr;gap:8px 12px}.deb-option-row{display:flex;gap:6px;align-items:center}.deb-chip{display:inline-block;padding:2px 8px;border-radius:12px;background:var(--deb-accent-soft, #eef2ff);color:var(--deb-accent, #6366f1);font-size:12px;font-weight:600}\n"], dependencies: [{ kind: "ngmodule", type: FormsModule }, { kind: "directive", type: i1.DefaultValueAccessor, selector: "input:not([type=checkbox])[formControlName],textarea[formControlName],input:not([type=checkbox])[formControl],textarea[formControl],input:not([type=checkbox])[ngModel],textarea[ngModel],[ngDefaultControl]" }, { kind: "directive", type: i1.NumberValueAccessor, selector: "input[type=number][formControlName],input[type=number][formControl],input[type=number][ngModel]" }, { kind: "directive", type: i1.NgControlStatus, selector: "[formControlName],[ngModel],[formControl]" }, { kind: "directive", type: i1.NgModel, selector: "[ngModel]:not([formControlName]):not([formControl])", inputs: ["name", "disabled", "ngModel", "ngModelOptions"], outputs: ["ngModelChange"], exportAs: ["ngModel"] }, { kind: "ngmodule", type: MatButtonModule }, { kind: "component", type: i2.MatButton, selector: " button[matButton], a[matButton], button[mat-button], button[mat-raised-button], button[mat-flat-button], button[mat-stroked-button], a[mat-button], a[mat-raised-button], a[mat-flat-button], a[mat-stroked-button] ", inputs: ["matButton"], exportAs: ["matButton", "matAnchor"] }, { kind: "component", type: i2.MatIconButton, selector: "button[mat-icon-button], a[mat-icon-button], button[matIconButton], a[matIconButton]", exportAs: ["matButton", "matAnchor"] }, { kind: "ngmodule", type: MatCheckboxModule }, { kind: "component", type: i3$1.MatCheckbox, selector: "mat-checkbox", inputs: ["aria-label", "aria-labelledby", "aria-describedby", "aria-expanded", "aria-controls", "aria-owns", "id", "required", "labelPosition", "name", "value", "disableRipple", "tabIndex", "color", "disabledInteractive", "checked", "disabled", "indeterminate"], outputs: ["change", "indeterminateChange"], exportAs: ["matCheckbox"] }, { kind: "ngmodule", type: MatDividerModule }, { kind: "component", type: i3.MatDivider, selector: "mat-divider", inputs: ["vertical", "inset"] }, { kind: "ngmodule", type: MatFormFieldModule }, { kind: "component", type: i4.MatFormField, selector: "mat-form-field", inputs: ["hideRequiredMarker", "color", "floatLabel", "appearance", "subscriptSizing", "hintLabel"], exportAs: ["matFormField"] }, { kind: "directive", type: i4.MatLabel, selector: "mat-label" }, { kind: "directive", type: i4.MatHint, selector: "mat-hint", inputs: ["align", "id"] }, { kind: "ngmodule", type: MatIconModule }, { kind: "component", type: i4$1.MatIcon, selector: "mat-icon", inputs: ["color", "inline", "svgIcon", "fontSet", "fontIcon"], exportAs: ["matIcon"] }, { kind: "ngmodule", type: MatInputModule }, { kind: "directive", type: i5.MatInput, selector: "input[matInput], textarea[matInput], select[matNativeControl], input[matNativeControl], textarea[matNativeControl]", inputs: ["disabled", "id", "placeholder", "name", "required", "type", "errorStateMatcher", "aria-describedby", "value", "readonly", "disabledInteractive"], exportAs: ["matInput"] }, { kind: "ngmodule", type: MatSelectModule }, { kind: "component", type: i8.MatSelect, selector: "mat-select", inputs: ["aria-describedby", "panelClass", "disabled", "disableRipple", "tabIndex", "hideSingleSelectionIndicator", "placeholder", "required", "multiple", "disableOptionCentering", "compareWith", "value", "aria-label", "aria-labelledby", "errorStateMatcher", "typeaheadDebounceInterval", "sortComparator", "id", "panelWidth", "canSelectNullableOptions"], outputs: ["openedChange", "opened", "closed", "selectionChange", "valueChange"], exportAs: ["matSelect"] }, { kind: "component", type: i8.MatOption, selector: "mat-option", inputs: ["value", "id", "disabled"], outputs: ["onSelectionChange"], exportAs: ["matOption"] }, { kind: "ngmodule", type: MatSlideToggleModule }, { kind: "component", type: i9.MatSlideToggle, selector: "mat-slide-toggle", inputs: ["name", "id", "labelPosition", "aria-label", "aria-labelledby", "aria-describedby", "required", "color", "disabled", "disableRipple", "tabIndex", "checked", "hideIcon", "disabledInteractive"], outputs: ["change", "toggleChange"], exportAs: ["matSlideToggle"] }, { kind: "ngmodule", type: MatTooltipModule }, { kind: "directive", type: i4$2.MatTooltip, selector: "[matTooltip]", inputs: ["matTooltipPosition", "matTooltipPositionAtOrigin", "matTooltipDisabled", "matTooltipShowDelay", "matTooltipHideDelay", "matTooltipTouchGestures", "matTooltip", "matTooltipClass"], exportAs: ["matTooltip"] }, { kind: "component", type: EntityReferenceConfigComponent, selector: "ngx-entity-reference-config" }, { kind: "component", type: FieldRulesListComponent, selector: "ngx-field-rules-list" }, { kind: "component", type: ReferencedFieldConfigComponent, selector: "ngx-referenced-field-config" }] });
2567
2682
  }
2568
2683
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.22", ngImport: i0, type: FieldInspectorComponent, decorators: [{
2569
2684
  type: Component,
@@ -2581,7 +2696,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.22", ngImpo
2581
2696
  EntityReferenceConfigComponent,
2582
2697
  FieldRulesListComponent,
2583
2698
  ReferencedFieldConfigComponent,
2584
- ], template: "@if (field(); as f) {\n <div class=\"deb-inspector\">\n <!-- Identity -->\n <div class=\"deb-row\">\n <mat-form-field appearance=\"outline\" subscriptSizing=\"dynamic\">\n <mat-label>Field id</mat-label>\n <input matInput data-testid=\"field-id\" [value]=\"f.id\" disabled />\n @if (store.hasManualId(f.id)) {\n <mat-hint>The stored data key for this saved field \u2014 fixed.</mat-hint>\n } @else {\n <mat-hint>Derived from the label. Used as the data key.</mat-hint>\n }\n </mat-form-field>\n <span class=\"deb-chip\">{{ meta()?.label || f.type }}</span>\n </div>\n\n @if (tabOptions().length > 1) {\n <mat-form-field appearance=\"outline\" subscriptSizing=\"dynamic\" class=\"deb-full\">\n <mat-label>Tab</mat-label>\n <mat-select data-testid=\"field-tab\"\n [ngModel]=\"currentTabId(f)\" (ngModelChange)=\"moveToTab(f, $event)\">\n @for (tab of tabOptions(); track tab.id) {\n <mat-option [value]=\"tab.id\">{{ tab.label }}</mat-option>\n }\n </mat-select>\n <mat-hint>Moving the field rewrites its path and repoints the rules that named it.</mat-hint>\n </mat-form-field>\n }\n\n\n <!-- Localised text (active language) -->\n <mat-form-field appearance=\"outline\" subscriptSizing=\"dynamic\" class=\"deb-full\">\n <mat-label>Label ({{ lang() }})</mat-label>\n <input\n matInput\n data-testid=\"field-label\"\n [ngModel]=\"labelValue(f)\"\n (ngModelChange)=\"store.setFieldLabel(f.id, lang(), $event)\"\n />\n </mat-form-field>\n\n @if (meta()?.supportsPlaceholder) {\n <mat-form-field appearance=\"outline\" subscriptSizing=\"dynamic\" class=\"deb-full\">\n <mat-label>Placeholder ({{ lang() }})</mat-label>\n <input\n matInput\n [ngModel]=\"placeholderValue(f)\"\n (ngModelChange)=\"store.setFieldPlaceholder(f.id, lang(), $event)\"\n />\n </mat-form-field>\n }\n\n @if (meta()?.supportsDefaultValue) {\n <mat-form-field appearance=\"outline\" subscriptSizing=\"dynamic\" class=\"deb-full\">\n <mat-label>Default value</mat-label>\n <input\n matInput\n [ngModel]=\"f.defaultValue ?? ''\"\n (ngModelChange)=\"store.updateField(f.id, { defaultValue: $event })\"\n />\n </mat-form-field>\n }\n\n <!-- Entity reference, cascade, and autoPatch -->\n @if (meta()?.isEntityRef) {\n <mat-divider></mat-divider>\n <ngx-entity-reference-config />\n }\n\n <mat-divider></mat-divider>\n\n <!-- Validators -->\n @if (meta(); as m) {\n @if (m.flagValidators.length || m.paramValidators.length) {\n <span class=\"deb-section-title\">Validation</span>\n <div class=\"deb-toggles\">\n @for (v of m.flagValidators; track v) {\n <mat-checkbox\n [ngModel]=\"store.hasFlagValidator(f, v)\"\n (ngModelChange)=\"store.toggleFlagValidator(f.id, v, $event)\"\n >{{ v }}</mat-checkbox>\n }\n </div>\n @for (v of m.paramValidators; track v) {\n <mat-form-field appearance=\"outline\" subscriptSizing=\"dynamic\" class=\"deb-full\">\n <mat-label>{{ v }}</mat-label>\n <input\n matInput\n type=\"number\"\n [ngModel]=\"store.getParamValidator(f, v)\"\n (ngModelChange)=\"store.setParamValidator(f.id, v, toNum($event))\"\n />\n </mat-form-field>\n }\n }\n }\n\n <!-- Options (dropdown / radio / multiSelect) -->\n @if (meta()?.hasOptions) {\n <mat-divider></mat-divider>\n <span class=\"deb-section-title\">Data source</span>\n <mat-form-field appearance=\"outline\" subscriptSizing=\"dynamic\" class=\"deb-full\">\n <mat-label>Options come from</mat-label>\n <mat-select\n data-testid=\"data-source\"\n [ngModel]=\"store.fieldDataSource(f)\"\n (ngModelChange)=\"store.setFieldDataSource(f.id, $event)\"\n >\n <mat-option value=\"none\">Nothing yet</mat-option>\n <mat-option value=\"manual\">Options authored here</mat-option>\n <mat-option value=\"lookup\">A named list</mat-option>\n </mat-select>\n </mat-form-field>\n }\n\n <!-- Named lookup list -->\n @if (meta()?.hasOptions && store.fieldDataSource(f) === 'lookup') {\n <mat-form-field appearance=\"outline\" subscriptSizing=\"dynamic\" class=\"deb-full\">\n <mat-label>List name</mat-label>\n <input\n matInput\n data-testid=\"list-name\"\n [ngModel]=\"f.listName ?? ''\"\n (ngModelChange)=\"store.setListName(f.id, $event)\"\n />\n </mat-form-field>\n <p class=\"deb-hint\">\n The consuming app resolves this name through its lookup registry. Options are not\n authored here.\n </p>\n }\n\n <!-- Inline options -->\n @if (meta()?.hasOptions && store.fieldDataSource(f) !== 'lookup') {\n <div class=\"deb-row\" style=\"justify-content: space-between;\">\n <span class=\"deb-section-title\">Options</span>\n <button mat-stroked-button type=\"button\" (click)=\"store.addOption(f.id)\">\n <mat-icon>add</mat-icon> Option\n </button>\n </div>\n @for (opt of f.options ?? []; track $index) {\n <div class=\"deb-option-row\" data-testid=\"option-row\">\n <!-- One input: the displayed text is the stored value. -->\n <mat-form-field appearance=\"outline\" subscriptSizing=\"dynamic\" class=\"deb-full\">\n <mat-label>Option ({{ lang() }})</mat-label>\n <input\n matInput\n [attr.data-testid]=\"'option-' + $index\"\n [ngModel]=\"optionLabel(opt)\"\n (ngModelChange)=\"store.setOptionLabel(f.id, $index, lang(), $event)\"\n />\n </mat-form-field>\n <button mat-icon-button type=\"button\" color=\"warn\"\n (click)=\"store.removeOption(f.id, $index)\" matTooltip=\"Remove option\">\n <mat-icon>delete</mat-icon>\n </button>\n </div>\n }\n @if (!(f.options ?? []).length) {\n <p class=\"deb-hint\">No options yet \u2014 add at least one.</p>\n }\n }\n\n <mat-divider></mat-divider>\n\n <!-- Flags -->\n <span class=\"deb-section-title\">Display</span>\n <div class=\"deb-toggles\">\n <mat-slide-toggle\n [ngModel]=\"f.visibility ?? true\"\n (ngModelChange)=\"store.updateField(f.id, { visibility: $event })\"\n >Visible</mat-slide-toggle>\n <mat-slide-toggle\n [ngModel]=\"f.table?.visible ?? false\"\n (ngModelChange)=\"store.updateField(f.id, { table: { visible: $event } })\"\n >Table column</mat-slide-toggle>\n <mat-slide-toggle\n [ngModel]=\"f.readonly ?? false\"\n (ngModelChange)=\"store.updateField(f.id, { readonly: $event })\"\n >Read-only</mat-slide-toggle>\n <mat-slide-toggle\n [ngModel]=\"f.disabled ?? false\"\n (ngModelChange)=\"store.updateField(f.id, { disabled: $event })\"\n >Disabled</mat-slide-toggle>\n <mat-slide-toggle\n [ngModel]=\"f.maskData ?? false\"\n (ngModelChange)=\"store.updateField(f.id, { maskData: $event })\"\n >Mask data (RBAC)</mat-slide-toggle>\n <mat-slide-toggle\n data-testid=\"toggle-critical\"\n [ngModel]=\"f.criticalField ?? false\"\n (ngModelChange)=\"store.updateField(f.id, { criticalField: $event })\"\n matTooltip=\"Renders locked; editing it announces a change against the session baseline.\"\n >Critical field</mat-slide-toggle>\n <mat-slide-toggle\n [ngModel]=\"f.showOnMinimize ?? false\"\n (ngModelChange)=\"store.updateField(f.id, { showOnMinimize: $event })\"\n matTooltip=\"Shown in the record editor's summary panel.\"\n >Show in summary</mat-slide-toggle>\n </div>\n\n <mat-divider></mat-divider>\n\n <!-- Conditional visibility (static showWhen) -->\n <div class=\"deb-row deb-row--split\">\n <span class=\"deb-section-title\">Show when</span>\n <button mat-stroked-button type=\"button\" data-testid=\"add-show-when\" (click)=\"addShowWhen(f)\">\n <mat-icon>add</mat-icon> Condition\n </button>\n </div>\n @for (entry of showWhenEntries(f); track entry.key) {\n <div class=\"deb-option-row\" data-testid=\"option-row\">\n <mat-form-field appearance=\"outline\" subscriptSizing=\"dynamic\">\n <mat-label>Watched field</mat-label>\n <mat-select data-testid=\"show-when-field\"\n [ngModel]=\"entry.key\" (ngModelChange)=\"renameShowWhen(f, entry.key, $event)\">\n @for (option of optionsWith(entry.key); track option.value) {\n <mat-option [value]=\"option.value\">\n {{ option.label }} <span class=\"deb-path-hint\">{{ option.path }}</span>\n </mat-option>\n }\n </mat-select>\n </mat-form-field>\n <mat-form-field appearance=\"outline\" subscriptSizing=\"dynamic\">\n <mat-label>Equals</mat-label>\n <input matInput [ngModel]=\"entry.display\" (ngModelChange)=\"setShowWhenValue(f, entry.key, $event)\" />\n </mat-form-field>\n <button mat-icon-button type=\"button\" color=\"warn\" matTooltip=\"Remove condition\"\n (click)=\"removeShowWhen(f, entry.key)\">\n <mat-icon>delete</mat-icon>\n </button>\n </div>\n }\n @if (!showWhenEntries(f).length) {\n <p class=\"deb-hint\">Always visible. Add a condition to show this field only for certain values.</p>\n }\n\n <!-- patchOnTrue (boolean/checkbox only) -->\n @if (f.type === 'boolean' || f.type === 'checkbox') {\n <mat-divider></mat-divider>\n <div class=\"deb-row deb-row--split\">\n <span class=\"deb-section-title\">Copy on true</span>\n <button mat-stroked-button type=\"button\" data-testid=\"add-patch-on-true\"\n (click)=\"store.addPatchOnTrueMapping(f.id)\">\n <mat-icon>add</mat-icon> Mapping\n </button>\n </div>\n @for (mapping of f.patchOnTrue ?? []; track $index) {\n <div class=\"deb-option-row\" data-testid=\"option-row\">\n <mat-form-field appearance=\"outline\" subscriptSizing=\"dynamic\">\n <mat-label>From field</mat-label>\n <mat-select data-testid=\"patch-on-true-from\"\n [ngModel]=\"mapping.from\"\n (ngModelChange)=\"store.updatePatchOnTrueMapping(f.id, $index, { from: $event })\">\n @for (option of optionsWith(mapping.from); track option.value) {\n <mat-option [value]=\"option.value\">\n {{ option.label }} <span class=\"deb-path-hint\">{{ option.path }}</span>\n </mat-option>\n }\n </mat-select>\n </mat-form-field>\n <mat-form-field appearance=\"outline\" subscriptSizing=\"dynamic\">\n <mat-label>To field</mat-label>\n <mat-select data-testid=\"patch-on-true-to\"\n [ngModel]=\"mapping.to\"\n (ngModelChange)=\"store.updatePatchOnTrueMapping(f.id, $index, { to: $event })\">\n @for (option of optionsWith(mapping.to); track option.value) {\n <mat-option [value]=\"option.value\">\n {{ option.label }} <span class=\"deb-path-hint\">{{ option.path }}</span>\n </mat-option>\n }\n </mat-select>\n </mat-form-field>\n <button mat-icon-button type=\"button\" color=\"warn\" matTooltip=\"Remove mapping\"\n (click)=\"store.removePatchOnTrueMapping(f.id, $index)\">\n <mat-icon>delete</mat-icon>\n </button>\n </div>\n }\n @if (!(f.patchOnTrue ?? []).length) {\n <p class=\"deb-hint\">When this flips to true, copy one field's value into another.</p>\n }\n }\n\n <mat-divider></mat-divider>\n\n <!-- Rules attached to this field -->\n <ngx-field-rules-list />\n\n <mat-divider></mat-divider>\n\n <!-- Cross-entity field reference & drift management (Phase 8) -->\n <ngx-referenced-field-config />\n </div>\n} @else {\n <div class=\"deb-inspector__empty\">\n <mat-icon>touch_app</mat-icon>\n <p>Select a field to edit its properties.</p>\n </div>\n}\n", styles: [".deb-inspector{display:flex;flex-direction:column;gap:12px}.deb-inspector__empty{color:var(--deb-muted, #6b7280);text-align:center;padding:24px 8px}.deb-full{width:100%}.deb-row{display:flex;gap:8px;align-items:center}.deb-row>*{flex:1}.deb-row--split{justify-content:space-between}.deb-row--split>*{flex:0 0 auto}.deb-toggles{display:grid;grid-template-columns:1fr 1fr;gap:8px 12px}.deb-option-row{display:flex;gap:6px;align-items:center}.deb-chip{display:inline-block;padding:2px 8px;border-radius:12px;background:var(--deb-accent-soft, #eef2ff);color:var(--deb-accent, #6366f1);font-size:12px;font-weight:600}\n"] }]
2699
+ ], template: "@if (field(); as f) {\n <div class=\"deb-inspector\">\n <!-- Identity -->\n <div class=\"deb-row\">\n <mat-form-field appearance=\"outline\" subscriptSizing=\"dynamic\">\n <mat-label>Field id</mat-label>\n <input matInput data-testid=\"field-id\" [value]=\"f.id\" disabled />\n @if (store.hasManualId(f.id)) {\n <mat-hint>The stored data key for this saved field \u2014 fixed.</mat-hint>\n } @else {\n <mat-hint>Derived from the label. Used as the data key.</mat-hint>\n }\n </mat-form-field>\n <span class=\"deb-chip\">{{ meta()?.label || f.type }}</span>\n </div>\n\n @if (tabOptions().length > 1) {\n <mat-form-field appearance=\"outline\" subscriptSizing=\"dynamic\" class=\"deb-full\">\n <mat-label>Tab</mat-label>\n <mat-select data-testid=\"field-tab\"\n [ngModel]=\"currentTabId(f)\" (ngModelChange)=\"moveToTab(f, $event)\">\n @for (tab of tabOptions(); track tab.id) {\n <mat-option [value]=\"tab.id\">{{ tab.label }}</mat-option>\n }\n </mat-select>\n <mat-hint>Moving the field rewrites its path and repoints the rules that named it.</mat-hint>\n </mat-form-field>\n }\n\n\n <!-- Localised text (active language) -->\n <mat-form-field appearance=\"outline\" subscriptSizing=\"dynamic\" class=\"deb-full\">\n <mat-label>Label ({{ lang() }})</mat-label>\n <input\n matInput\n data-testid=\"field-label\"\n [ngModel]=\"labelValue(f)\"\n (ngModelChange)=\"store.setFieldLabel(store.keyOf(f), lang(), $event)\"\n />\n </mat-form-field>\n\n @if (meta()?.supportsPlaceholder) {\n <mat-form-field appearance=\"outline\" subscriptSizing=\"dynamic\" class=\"deb-full\">\n <mat-label>Placeholder ({{ lang() }})</mat-label>\n <input\n matInput\n [ngModel]=\"placeholderValue(f)\"\n (ngModelChange)=\"store.setFieldPlaceholder(store.keyOf(f), lang(), $event)\"\n />\n </mat-form-field>\n }\n\n @if (meta()?.supportsDefaultValue) {\n <mat-form-field appearance=\"outline\" subscriptSizing=\"dynamic\" class=\"deb-full\">\n <mat-label>Default value</mat-label>\n <input\n matInput\n [ngModel]=\"f.defaultValue ?? ''\"\n (ngModelChange)=\"store.updateField(store.keyOf(f), { defaultValue: $event })\"\n />\n </mat-form-field>\n }\n\n <!-- Entity reference, cascade, and autoPatch -->\n @if (meta()?.isEntityRef) {\n <mat-divider></mat-divider>\n <ngx-entity-reference-config />\n }\n\n <mat-divider></mat-divider>\n\n <!-- Validators -->\n @if (meta(); as m) {\n @if (m.flagValidators.length || m.paramValidators.length) {\n <span class=\"deb-section-title\">Validation</span>\n <div class=\"deb-toggles\">\n @for (v of m.flagValidators; track v) {\n <mat-checkbox\n [ngModel]=\"store.hasFlagValidator(f, v)\"\n (ngModelChange)=\"store.toggleFlagValidator(store.keyOf(f), v, $event)\"\n >{{ v }}</mat-checkbox>\n }\n </div>\n @for (v of m.paramValidators; track v) {\n <mat-form-field appearance=\"outline\" subscriptSizing=\"dynamic\" class=\"deb-full\">\n <mat-label>{{ v }}</mat-label>\n <input\n matInput\n type=\"number\"\n [ngModel]=\"store.getParamValidator(f, v)\"\n (ngModelChange)=\"store.setParamValidator(store.keyOf(f), v, toNum($event))\"\n />\n </mat-form-field>\n }\n }\n }\n\n <!-- Options (dropdown / radio / multiSelect) -->\n @if (meta()?.hasOptions) {\n <mat-divider></mat-divider>\n <span class=\"deb-section-title\">Data source</span>\n <mat-form-field appearance=\"outline\" subscriptSizing=\"dynamic\" class=\"deb-full\">\n <mat-label>Options come from</mat-label>\n <mat-select\n data-testid=\"data-source\"\n [ngModel]=\"store.fieldDataSource(f)\"\n (ngModelChange)=\"store.setFieldDataSource(store.keyOf(f), $event)\"\n >\n <mat-option value=\"none\">Nothing yet</mat-option>\n <mat-option value=\"manual\">Options authored here</mat-option>\n <mat-option value=\"lookup\">A named list</mat-option>\n </mat-select>\n </mat-form-field>\n }\n\n <!-- Named lookup list -->\n @if (meta()?.hasOptions && store.fieldDataSource(f) === 'lookup') {\n <mat-form-field appearance=\"outline\" subscriptSizing=\"dynamic\" class=\"deb-full\">\n <mat-label>List name</mat-label>\n <input\n matInput\n data-testid=\"list-name\"\n [ngModel]=\"f.listName ?? ''\"\n (ngModelChange)=\"store.setListName(store.keyOf(f), $event)\"\n />\n </mat-form-field>\n <p class=\"deb-hint\">\n The consuming app resolves this name through its lookup registry. Options are not\n authored here.\n </p>\n }\n\n <!-- Inline options -->\n @if (meta()?.hasOptions && store.fieldDataSource(f) !== 'lookup') {\n <div class=\"deb-row\" style=\"justify-content: space-between;\">\n <span class=\"deb-section-title\">Options</span>\n <button mat-stroked-button type=\"button\" (click)=\"store.addOption(store.keyOf(f))\">\n <mat-icon>add</mat-icon> Option\n </button>\n </div>\n @for (opt of f.options ?? []; track $index) {\n <div class=\"deb-option-row\" data-testid=\"option-row\">\n <!-- One input: the displayed text is the stored value. -->\n <mat-form-field appearance=\"outline\" subscriptSizing=\"dynamic\" class=\"deb-full\">\n <mat-label>Option ({{ lang() }})</mat-label>\n <input\n matInput\n [attr.data-testid]=\"'option-' + $index\"\n [ngModel]=\"optionLabel(opt)\"\n (ngModelChange)=\"store.setOptionLabel(store.keyOf(f), $index, lang(), $event)\"\n />\n </mat-form-field>\n <button mat-icon-button type=\"button\" color=\"warn\"\n (click)=\"store.removeOption(store.keyOf(f), $index)\" matTooltip=\"Remove option\">\n <mat-icon>delete</mat-icon>\n </button>\n </div>\n }\n @if (!(f.options ?? []).length) {\n <p class=\"deb-hint\">No options yet \u2014 add at least one.</p>\n }\n }\n\n <mat-divider></mat-divider>\n\n <!-- Flags -->\n <span class=\"deb-section-title\">Display</span>\n <div class=\"deb-toggles\">\n <mat-slide-toggle\n [ngModel]=\"f.visibility ?? true\"\n (ngModelChange)=\"store.updateField(store.keyOf(f), { visibility: $event })\"\n >Visible</mat-slide-toggle>\n <mat-slide-toggle\n [ngModel]=\"f.table?.visible ?? false\"\n (ngModelChange)=\"store.updateField(store.keyOf(f), { table: { visible: $event } })\"\n >Table column</mat-slide-toggle>\n <mat-slide-toggle\n [ngModel]=\"f.readonly ?? false\"\n (ngModelChange)=\"store.updateField(store.keyOf(f), { readonly: $event })\"\n >Read-only</mat-slide-toggle>\n <mat-slide-toggle\n [ngModel]=\"f.disabled ?? false\"\n (ngModelChange)=\"store.updateField(store.keyOf(f), { disabled: $event })\"\n >Disabled</mat-slide-toggle>\n <mat-slide-toggle\n [ngModel]=\"f.maskData ?? false\"\n (ngModelChange)=\"store.updateField(store.keyOf(f), { maskData: $event })\"\n >Mask data (RBAC)</mat-slide-toggle>\n <mat-slide-toggle\n data-testid=\"toggle-critical\"\n [ngModel]=\"f.criticalField ?? false\"\n (ngModelChange)=\"store.updateField(store.keyOf(f), { criticalField: $event })\"\n matTooltip=\"Renders locked; editing it announces a change against the session baseline.\"\n >Critical field</mat-slide-toggle>\n <mat-slide-toggle\n [ngModel]=\"f.showOnMinimize ?? false\"\n (ngModelChange)=\"store.updateField(store.keyOf(f), { showOnMinimize: $event })\"\n matTooltip=\"Shown in the record editor's summary panel.\"\n >Show in summary</mat-slide-toggle>\n </div>\n\n <mat-divider></mat-divider>\n\n <!-- Conditional visibility (static showWhen) -->\n <div class=\"deb-row deb-row--split\">\n <span class=\"deb-section-title\">Show when</span>\n <button mat-stroked-button type=\"button\" data-testid=\"add-show-when\" (click)=\"addShowWhen(f)\">\n <mat-icon>add</mat-icon> Condition\n </button>\n </div>\n @for (entry of showWhenEntries(f); track entry.key) {\n <div class=\"deb-option-row\" data-testid=\"option-row\">\n <mat-form-field appearance=\"outline\" subscriptSizing=\"dynamic\">\n <mat-label>Watched field</mat-label>\n <mat-select data-testid=\"show-when-field\"\n [ngModel]=\"entry.key\" (ngModelChange)=\"renameShowWhen(f, entry.key, $event)\">\n @for (option of optionsWith(entry.key); track option.value) {\n <mat-option [value]=\"option.value\">\n {{ option.label }} <span class=\"deb-path-hint\">{{ option.path }}</span>\n </mat-option>\n }\n </mat-select>\n </mat-form-field>\n <mat-form-field appearance=\"outline\" subscriptSizing=\"dynamic\">\n <mat-label>Equals</mat-label>\n <input matInput [ngModel]=\"entry.display\" (ngModelChange)=\"setShowWhenValue(f, entry.key, $event)\" />\n </mat-form-field>\n <button mat-icon-button type=\"button\" color=\"warn\" matTooltip=\"Remove condition\"\n (click)=\"removeShowWhen(f, entry.key)\">\n <mat-icon>delete</mat-icon>\n </button>\n </div>\n }\n @if (!showWhenEntries(f).length) {\n <p class=\"deb-hint\">Always visible. Add a condition to show this field only for certain values.</p>\n }\n\n <!-- patchOnTrue (boolean/checkbox only) -->\n @if (f.type === 'boolean' || f.type === 'checkbox') {\n <mat-divider></mat-divider>\n <div class=\"deb-row deb-row--split\">\n <span class=\"deb-section-title\">Copy on true</span>\n <button mat-stroked-button type=\"button\" data-testid=\"add-patch-on-true\"\n (click)=\"store.addPatchOnTrueMapping(store.keyOf(f))\">\n <mat-icon>add</mat-icon> Mapping\n </button>\n </div>\n @for (mapping of f.patchOnTrue ?? []; track $index) {\n <div class=\"deb-option-row\" data-testid=\"option-row\">\n <mat-form-field appearance=\"outline\" subscriptSizing=\"dynamic\">\n <mat-label>From field</mat-label>\n <mat-select data-testid=\"patch-on-true-from\"\n [ngModel]=\"mapping.from\"\n (ngModelChange)=\"store.updatePatchOnTrueMapping(store.keyOf(f), $index, { from: $event })\">\n @for (option of optionsWith(mapping.from); track option.value) {\n <mat-option [value]=\"option.value\">\n {{ option.label }} <span class=\"deb-path-hint\">{{ option.path }}</span>\n </mat-option>\n }\n </mat-select>\n </mat-form-field>\n <mat-form-field appearance=\"outline\" subscriptSizing=\"dynamic\">\n <mat-label>To field</mat-label>\n <mat-select data-testid=\"patch-on-true-to\"\n [ngModel]=\"mapping.to\"\n (ngModelChange)=\"store.updatePatchOnTrueMapping(store.keyOf(f), $index, { to: $event })\">\n @for (option of optionsWith(mapping.to); track option.value) {\n <mat-option [value]=\"option.value\">\n {{ option.label }} <span class=\"deb-path-hint\">{{ option.path }}</span>\n </mat-option>\n }\n </mat-select>\n </mat-form-field>\n <button mat-icon-button type=\"button\" color=\"warn\" matTooltip=\"Remove mapping\"\n (click)=\"store.removePatchOnTrueMapping(store.keyOf(f), $index)\">\n <mat-icon>delete</mat-icon>\n </button>\n </div>\n }\n @if (!(f.patchOnTrue ?? []).length) {\n <p class=\"deb-hint\">When this flips to true, copy one field's value into another.</p>\n }\n }\n\n <mat-divider></mat-divider>\n\n <!-- Rules attached to this field -->\n <ngx-field-rules-list />\n\n <mat-divider></mat-divider>\n\n <!-- Cross-entity field reference & drift management (Phase 8) -->\n <ngx-referenced-field-config />\n </div>\n} @else {\n <div class=\"deb-inspector__empty\">\n <mat-icon>touch_app</mat-icon>\n <p>Select a field to edit its properties.</p>\n </div>\n}\n", styles: [".deb-inspector{display:flex;flex-direction:column;gap:12px}.deb-inspector__empty{color:var(--deb-muted, #6b7280);text-align:center;padding:24px 8px}.deb-full{width:100%}.deb-row{display:flex;gap:8px;align-items:center}.deb-row>*{flex:1}.deb-row--split{justify-content:space-between}.deb-row--split>*{flex:0 0 auto}.deb-toggles{display:grid;grid-template-columns:1fr 1fr;gap:8px 12px}.deb-option-row{display:flex;gap:6px;align-items:center}.deb-chip{display:inline-block;padding:2px 8px;border-radius:12px;background:var(--deb-accent-soft, #eef2ff);color:var(--deb-accent, #6366f1);font-size:12px;font-weight:600}\n"] }]
2585
2700
  }] });
2586
2701
 
2587
2702
  /**
@@ -2977,13 +3092,13 @@ class EntityBuilderTreeNodeComponent {
2977
3092
  cdkDrag
2978
3093
  class="deb-field-row"
2979
3094
  data-testid="builder-field-row"
2980
- [class.deb-field-row--active]="store.selectedFieldId() === field.id"
2981
- (click)="store.selectField(field.id)"
2982
- (keydown.enter)="store.selectField(field.id)"
2983
- (keydown.space)="store.selectField(field.id); $event.preventDefault()"
3095
+ [class.deb-field-row--active]="store.isSelected(field)"
3096
+ (click)="store.selectField(store.keyOf(field))"
3097
+ (keydown.enter)="store.selectField(store.keyOf(field))"
3098
+ (keydown.space)="store.selectField(store.keyOf(field)); $event.preventDefault()"
2984
3099
  role="button"
2985
3100
  tabindex="0"
2986
- [attr.aria-pressed]="store.selectedFieldId() === field.id"
3101
+ [attr.aria-pressed]="store.isSelected(field)"
2987
3102
  [attr.aria-label]="'Select field ' + fieldLabel(field)"
2988
3103
  >
2989
3104
  <mat-icon class="deb-drag-handle" cdkDragHandle aria-hidden="true" title="Drag to reorder (or use the move buttons)">drag_indicator</mat-icon>
@@ -3006,24 +3121,24 @@ class EntityBuilderTreeNodeComponent {
3006
3121
  <button mat-icon-button type="button" [disabled]="index === 0"
3007
3122
  [attr.data-testid]="'row-up-' + field.id"
3008
3123
  [attr.aria-label]="'Move ' + fieldLabel(field) + ' up'"
3009
- (click)="store.moveField(field.id, -1); $event.stopPropagation()" matTooltip="Move up">
3124
+ (click)="store.moveField(store.keyOf(field), -1); $event.stopPropagation()" matTooltip="Move up">
3010
3125
  <mat-icon>arrow_upward</mat-icon>
3011
3126
  </button>
3012
3127
  <button mat-icon-button type="button" [disabled]="index === totalCount - 1"
3013
3128
  [attr.data-testid]="'row-down-' + field.id"
3014
3129
  [attr.aria-label]="'Move ' + fieldLabel(field) + ' down'"
3015
- (click)="store.moveField(field.id, 1); $event.stopPropagation()" matTooltip="Move down">
3130
+ (click)="store.moveField(store.keyOf(field), 1); $event.stopPropagation()" matTooltip="Move down">
3016
3131
  <mat-icon>arrow_downward</mat-icon>
3017
3132
  </button>
3018
3133
  <button mat-icon-button type="button"
3019
3134
  [attr.data-testid]="'row-duplicate-' + field.id"
3020
3135
  [attr.aria-label]="'Duplicate ' + fieldLabel(field)"
3021
- (click)="store.duplicateField(field.id); $event.stopPropagation()" matTooltip="Duplicate">
3136
+ (click)="store.duplicateField(store.keyOf(field)); $event.stopPropagation()" matTooltip="Duplicate">
3022
3137
  <mat-icon>content_copy</mat-icon>
3023
3138
  </button>
3024
3139
  <button mat-icon-button type="button" color="warn"
3025
3140
  [attr.data-testid]="'row-delete-' + field.id"
3026
- (click)="store.removeField(field.id); $event.stopPropagation()" matTooltip="Delete">
3141
+ (click)="store.removeField(store.keyOf(field)); $event.stopPropagation()" matTooltip="Delete">
3027
3142
  <mat-icon>delete</mat-icon>
3028
3143
  </button>
3029
3144
  </div>
@@ -3059,13 +3174,13 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.22", ngImpo
3059
3174
  cdkDrag
3060
3175
  class="deb-field-row"
3061
3176
  data-testid="builder-field-row"
3062
- [class.deb-field-row--active]="store.selectedFieldId() === field.id"
3063
- (click)="store.selectField(field.id)"
3064
- (keydown.enter)="store.selectField(field.id)"
3065
- (keydown.space)="store.selectField(field.id); $event.preventDefault()"
3177
+ [class.deb-field-row--active]="store.isSelected(field)"
3178
+ (click)="store.selectField(store.keyOf(field))"
3179
+ (keydown.enter)="store.selectField(store.keyOf(field))"
3180
+ (keydown.space)="store.selectField(store.keyOf(field)); $event.preventDefault()"
3066
3181
  role="button"
3067
3182
  tabindex="0"
3068
- [attr.aria-pressed]="store.selectedFieldId() === field.id"
3183
+ [attr.aria-pressed]="store.isSelected(field)"
3069
3184
  [attr.aria-label]="'Select field ' + fieldLabel(field)"
3070
3185
  >
3071
3186
  <mat-icon class="deb-drag-handle" cdkDragHandle aria-hidden="true" title="Drag to reorder (or use the move buttons)">drag_indicator</mat-icon>
@@ -3088,24 +3203,24 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.22", ngImpo
3088
3203
  <button mat-icon-button type="button" [disabled]="index === 0"
3089
3204
  [attr.data-testid]="'row-up-' + field.id"
3090
3205
  [attr.aria-label]="'Move ' + fieldLabel(field) + ' up'"
3091
- (click)="store.moveField(field.id, -1); $event.stopPropagation()" matTooltip="Move up">
3206
+ (click)="store.moveField(store.keyOf(field), -1); $event.stopPropagation()" matTooltip="Move up">
3092
3207
  <mat-icon>arrow_upward</mat-icon>
3093
3208
  </button>
3094
3209
  <button mat-icon-button type="button" [disabled]="index === totalCount - 1"
3095
3210
  [attr.data-testid]="'row-down-' + field.id"
3096
3211
  [attr.aria-label]="'Move ' + fieldLabel(field) + ' down'"
3097
- (click)="store.moveField(field.id, 1); $event.stopPropagation()" matTooltip="Move down">
3212
+ (click)="store.moveField(store.keyOf(field), 1); $event.stopPropagation()" matTooltip="Move down">
3098
3213
  <mat-icon>arrow_downward</mat-icon>
3099
3214
  </button>
3100
3215
  <button mat-icon-button type="button"
3101
3216
  [attr.data-testid]="'row-duplicate-' + field.id"
3102
3217
  [attr.aria-label]="'Duplicate ' + fieldLabel(field)"
3103
- (click)="store.duplicateField(field.id); $event.stopPropagation()" matTooltip="Duplicate">
3218
+ (click)="store.duplicateField(store.keyOf(field)); $event.stopPropagation()" matTooltip="Duplicate">
3104
3219
  <mat-icon>content_copy</mat-icon>
3105
3220
  </button>
3106
3221
  <button mat-icon-button type="button" color="warn"
3107
3222
  [attr.data-testid]="'row-delete-' + field.id"
3108
- (click)="store.removeField(field.id); $event.stopPropagation()" matTooltip="Delete">
3223
+ (click)="store.removeField(store.keyOf(field)); $event.stopPropagation()" matTooltip="Delete">
3109
3224
  <mat-icon>delete</mat-icon>
3110
3225
  </button>
3111
3226
  </div>