ngx-dynamic-entity-builder 1.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.
Files changed (36) hide show
  1. package/README.md +54 -0
  2. package/esm2022/lib/builder-store.service.mjs +923 -0
  3. package/esm2022/lib/clone.mjs +14 -0
  4. package/esm2022/lib/components/connection-source-config.component.mjs +121 -0
  5. package/esm2022/lib/components/entity-builder-canvas.component.mjs +83 -0
  6. package/esm2022/lib/components/entity-builder-tree-node.component.mjs +173 -0
  7. package/esm2022/lib/components/entity-reference-config.component.mjs +405 -0
  8. package/esm2022/lib/components/field-inspector.component.mjs +139 -0
  9. package/esm2022/lib/components/field-palette.component.mjs +57 -0
  10. package/esm2022/lib/components/field-rules-list.component.mjs +224 -0
  11. package/esm2022/lib/components/referenced-field-config.component.mjs +228 -0
  12. package/esm2022/lib/components/rule-form.component.mjs +201 -0
  13. package/esm2022/lib/components/tab-manager.component.mjs +341 -0
  14. package/esm2022/lib/entity-builder.component.mjs +153 -0
  15. package/esm2022/lib/field-catalog.mjs +6 -0
  16. package/esm2022/ngx-dynamic-entity-builder.mjs +5 -0
  17. package/esm2022/public-api.mjs +17 -0
  18. package/fesm2022/ngx-dynamic-entity-builder.mjs +2910 -0
  19. package/fesm2022/ngx-dynamic-entity-builder.mjs.map +1 -0
  20. package/index.d.ts +5 -0
  21. package/lib/builder-store.service.d.ts +165 -0
  22. package/lib/clone.d.ts +9 -0
  23. package/lib/components/connection-source-config.component.d.ts +16 -0
  24. package/lib/components/entity-builder-canvas.component.d.ts +9 -0
  25. package/lib/components/entity-builder-tree-node.component.d.ts +17 -0
  26. package/lib/components/entity-reference-config.component.d.ts +29 -0
  27. package/lib/components/field-inspector.component.d.ts +33 -0
  28. package/lib/components/field-palette.component.d.ts +13 -0
  29. package/lib/components/field-rules-list.component.d.ts +19 -0
  30. package/lib/components/referenced-field-config.component.d.ts +18 -0
  31. package/lib/components/rule-form.component.d.ts +16 -0
  32. package/lib/components/tab-manager.component.d.ts +19 -0
  33. package/lib/entity-builder.component.d.ts +42 -0
  34. package/lib/field-catalog.d.ts +5 -0
  35. package/package.json +42 -0
  36. package/public-api.d.ts +13 -0
@@ -0,0 +1,2910 @@
1
+ import * as i0 from '@angular/core';
2
+ import { signal, computed, Injectable, inject, Component, EventEmitter, Output, Input, InjectionToken, effect } from '@angular/core';
3
+ import * as i1 from '@angular/forms';
4
+ import { FormsModule } from '@angular/forms';
5
+ import * as i1$1 from '@angular/cdk/drag-drop';
6
+ import { DragDropModule } from '@angular/cdk/drag-drop';
7
+ import * as i2 from '@angular/material/button';
8
+ import { MatButtonModule } from '@angular/material/button';
9
+ import * as i2$1 from '@angular/material/card';
10
+ import { MatCardModule } from '@angular/material/card';
11
+ import * as i3$1 from '@angular/material/checkbox';
12
+ import { MatCheckboxModule } from '@angular/material/checkbox';
13
+ import * as i4$3 from '@angular/material/expansion';
14
+ import { MatExpansionModule } from '@angular/material/expansion';
15
+ import * as i4 from '@angular/material/form-field';
16
+ import { MatFormFieldModule } from '@angular/material/form-field';
17
+ import * as i4$1 from '@angular/material/icon';
18
+ import { MatIconModule } from '@angular/material/icon';
19
+ import * as i5 from '@angular/material/input';
20
+ import { MatInputModule } from '@angular/material/input';
21
+ import * as i8 from '@angular/material/select';
22
+ import { MatSelectModule } from '@angular/material/select';
23
+ import * as i10 from '@angular/material/slide-toggle';
24
+ import { MatSlideToggleModule } from '@angular/material/slide-toggle';
25
+ import * as i11 from '@angular/material/toolbar';
26
+ import { MatToolbarModule } from '@angular/material/toolbar';
27
+ import * as i4$2 from '@angular/material/tooltip';
28
+ import { MatTooltipModule } from '@angular/material/tooltip';
29
+ import { normalizeConfigOptions, getFieldTypeMeta, createFieldConfig, labelToId, createFieldSnapshot, computeFieldDrift, humanizeId, findTab, resolveLabel, resolveOptionLabel, FIELD_TYPE_CATALOG, COMMON_MODULES } from '@dynamic-entity/core';
30
+ export { FIELD_TYPE_CATALOG, createFieldConfig, getFieldTypeMeta, humanizeId } from '@dynamic-entity/core';
31
+ import * as i3 from '@angular/material/divider';
32
+ import { MatDividerModule } from '@angular/material/divider';
33
+ import * as i9 from '@angular/material/core';
34
+ import { CommonModule } from '@angular/common';
35
+ import { ConfigSourceService } from 'ngx-dynamic-entity';
36
+
37
+ /**
38
+ * field-catalog.ts — re-exports shared field catalog from @dynamic-entity/core.
39
+ * Single source of truth shared by both visual builder and renderer.
40
+ */
41
+
42
+ /**
43
+ * Deep clone with a JSON fallback.
44
+ *
45
+ * `structuredClone` is not universally available (older browsers, and jsdom before v22),
46
+ * so it must never be called unguarded — an unguarded call throws a ReferenceError and the
47
+ * caller silently does nothing. Config and rule objects are JSON-safe, so the fallback is
48
+ * lossless here.
49
+ */
50
+ function deepClone(value) {
51
+ if (typeof structuredClone === 'function')
52
+ return structuredClone(value);
53
+ return JSON.parse(JSON.stringify(value));
54
+ }
55
+
56
+ const clone = deepClone;
57
+ const ID_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/;
58
+ class BuilderStore {
59
+ _config = signal(this.emptyConfig());
60
+ _selectedFieldId = signal(null);
61
+ _activeLanguage = signal('en');
62
+ /** Rules live beside the config: they are persisted per form, not inside `EntityFormConfig`. */
63
+ _rules = signal([]);
64
+ /**
65
+ * Field ids the author owns — set by an explicit rename, or by loading a saved config
66
+ * whose ids are already live data keys. Ids outside this set are still derived from the
67
+ * field's label. Builder-session state, deliberately not part of the exported config.
68
+ */
69
+ manualIds = new Set();
70
+ _isDirty = signal(false);
71
+ config = this._config.asReadonly();
72
+ selectedFieldId = this._selectedFieldId.asReadonly();
73
+ activeLanguage = this._activeLanguage.asReadonly();
74
+ rules = this._rules.asReadonly();
75
+ isDirty = this._isDirty.asReadonly();
76
+ tabs = computed(() => this._config().tabs ?? []);
77
+ fields = computed(() => {
78
+ return (this._config().tabs ?? []).flatMap(t => t.fields ?? []);
79
+ });
80
+ selectedField = computed(() => {
81
+ const id = this._selectedFieldId();
82
+ if (!id)
83
+ return null;
84
+ return this.findFieldInTabs(this._config().tabs, id);
85
+ });
86
+ problems = computed(() => this.validate(this._config()));
87
+ errors = computed(() => this.problems().filter(p => p.level === 'error'));
88
+ isValid = computed(() => this.errors().length === 0);
89
+ // ─── Initialisation ─────────────────────────────────────────────────────────
90
+ load(config) {
91
+ // Check if any fields carry both inline options and listName before normalizing options
92
+ const fieldsWithBoth = this.getAllFields(config.tabs ?? []).filter(f => Array.isArray(f.options) && f.options.length > 0 && typeof f.listName === 'string' && f.listName.trim() !== '');
93
+ for (const f of fieldsWithBoth) {
94
+ console.warn(`[BuilderStore] Field "${f.id}" carries both inline options and listName "${f.listName}". ` +
95
+ `Inline options win; listName was dropped.`);
96
+ }
97
+ // The other boundary where a config enters the library (the renderer's ngOnChanges is
98
+ // the first). Authoring must start from the canonical option shape, or the builder
99
+ // would round-trip a legacy config straight back out unchanged.
100
+ const next = clone(normalizeConfigOptions(config));
101
+ next.tabs = next.tabs ?? [];
102
+ if (next.tabs.length === 0) {
103
+ next.tabs = [{ id: 'default', label: { en: 'Default' }, fields: [] }];
104
+ }
105
+ this._config.set(next);
106
+ const firstField = this.getAllFields(next.tabs)[0];
107
+ this._selectedFieldId.set(firstField?.id ?? null);
108
+ this._activeLanguage.set(next.defaultLanguage ?? 'en');
109
+ // Ids in a saved config are live data keys — records are already stored under them.
110
+ // Editing a label must never rewrite one, so freeze every id the config arrived with.
111
+ this.manualIds.clear();
112
+ for (const field of this.getAllFields(next.tabs))
113
+ this.manualIds.add(field.id);
114
+ this._isDirty.set(false);
115
+ }
116
+ reset(entity = '') {
117
+ this._config.set(this.emptyConfig(entity));
118
+ this._selectedFieldId.set(null);
119
+ this._rules.set([]);
120
+ this.manualIds.clear();
121
+ this._isDirty.set(false);
122
+ }
123
+ resetDirty() {
124
+ this._isDirty.set(false);
125
+ }
126
+ emptyConfig(entity = '') {
127
+ return {
128
+ entity,
129
+ version: 1,
130
+ defaultLanguage: 'en',
131
+ tabs: [],
132
+ };
133
+ }
134
+ // ─── Entity-level settings ──────────────────────────────────────────────────
135
+ setEntityName(entity) {
136
+ this.mutate(draft => {
137
+ draft.entity = entity.trim();
138
+ });
139
+ }
140
+ setDefaultLanguage(language) {
141
+ this.mutate(draft => {
142
+ draft.defaultLanguage = language;
143
+ });
144
+ }
145
+ setMaskData(mask) {
146
+ this.mutate(draft => {
147
+ draft.maskData = mask;
148
+ });
149
+ }
150
+ setPermission(action, roles) {
151
+ this.mutate(draft => {
152
+ const permissions = { ...(draft.permissions ?? {}) };
153
+ permissions[action] = roles;
154
+ draft.permissions = permissions;
155
+ });
156
+ }
157
+ setActiveLanguage(language) {
158
+ this._activeLanguage.set(language);
159
+ }
160
+ // ─── Helper Traversal ───────────────────────────────────────────────────────
161
+ getAllFields(tabs = []) {
162
+ const list = [];
163
+ for (const t of tabs) {
164
+ if (t.fields)
165
+ list.push(...t.fields);
166
+ if (t.children)
167
+ list.push(...this.getAllFields(t.children));
168
+ }
169
+ return list;
170
+ }
171
+ findFieldInTabs(tabs = [], id) {
172
+ for (const t of tabs) {
173
+ if (t.fields) {
174
+ const found = t.fields.find(f => f.id === id);
175
+ if (found)
176
+ return found;
177
+ }
178
+ if (t.children) {
179
+ const found = this.findFieldInTabs(t.children, id);
180
+ if (found)
181
+ return found;
182
+ }
183
+ }
184
+ return null;
185
+ }
186
+ // ─── Field CRUD ─────────────────────────────────────────────────────────────
187
+ addField(type, targetTabId) {
188
+ const meta = getFieldTypeMeta(type);
189
+ const prefix = meta?.idPrefix ?? 'field';
190
+ const allFields = this.fields();
191
+ const id = this.uniqueId(prefix, allFields);
192
+ this.mutate(draft => {
193
+ const field = createFieldConfig(type, id, draft.defaultLanguage ?? 'en');
194
+ const targetTab = targetTabId
195
+ ? draft.tabs.find(t => t.id === targetTabId)
196
+ : draft.tabs[0];
197
+ if (targetTab) {
198
+ targetTab.fields = targetTab.fields ?? [];
199
+ targetTab.fields.push(field);
200
+ }
201
+ else {
202
+ draft.tabs.push({ id: 'main', label: { en: 'Main' }, fields: [field] });
203
+ }
204
+ });
205
+ this._selectedFieldId.set(id);
206
+ return id;
207
+ }
208
+ updateField(id, patch) {
209
+ this.mutate(draft => {
210
+ const field = this.findFieldInTabs(draft.tabs, id);
211
+ if (field)
212
+ Object.assign(field, patch);
213
+ });
214
+ }
215
+ /**
216
+ * Rename a field explicitly, pinning the id so it is no longer derived from the label.
217
+ *
218
+ * Not reachable from the inspector — the id input is read-only by design. This exists for
219
+ * programmatic callers that must match an id to an existing data key.
220
+ */
221
+ renameField(oldId, newId) {
222
+ const trimmed = newId.trim();
223
+ if (!trimmed || trimmed === oldId)
224
+ return;
225
+ if (this.fields().some(f => f.id === trimmed))
226
+ return;
227
+ if (!this.findFieldInTabs(this._config().tabs, oldId))
228
+ return;
229
+ this.applyRename(oldId, trimmed);
230
+ this.manualIds.delete(oldId);
231
+ this.manualIds.add(trimmed);
232
+ }
233
+ /** Whether this field's id is pinned (loaded from storage or explicitly renamed). */
234
+ hasManualId(id) {
235
+ return this.manualIds.has(id);
236
+ }
237
+ /**
238
+ * Move a field to a new id and repoint every id-based reference in the config at it.
239
+ *
240
+ * Ids are the wiring between fields — rules, cascades, patches and `showWhen` all address
241
+ * fields by id — so a rename that only touches `field.id` silently breaks that wiring.
242
+ * That was survivable while renaming was a rare manual act; label-derived ids make it
243
+ * routine, so the references move with it.
244
+ */
245
+ applyRename(oldId, newId) {
246
+ this.mutate(draft => {
247
+ const field = this.findFieldInTabs(draft.tabs, oldId);
248
+ if (!field)
249
+ return;
250
+ field.id = newId;
251
+ const walkFields = (fields) => {
252
+ for (const f of fields ?? []) {
253
+ if (f.showWhen && oldId in f.showWhen) {
254
+ const next = {};
255
+ // Rebuild in place so condition order survives the rename.
256
+ for (const [key, value] of Object.entries(f.showWhen)) {
257
+ next[key === oldId ? newId : key] = value;
258
+ }
259
+ f.showWhen = next;
260
+ }
261
+ if (f.entityReference?.parentField === oldId) {
262
+ f.entityReference = { ...f.entityReference, parentField: newId };
263
+ }
264
+ if (f.autoPatch) {
265
+ // `source` names a field on the *linked* record, not this config — leave it.
266
+ f.autoPatch = {
267
+ ...f.autoPatch,
268
+ mappings: f.autoPatch.mappings.map(m => m.target === oldId ? { ...m, target: newId } : m),
269
+ };
270
+ }
271
+ if (f.patchOnTrue) {
272
+ f.patchOnTrue = f.patchOnTrue.map(m => ({
273
+ from: m.from === oldId ? newId : m.from,
274
+ to: m.to === oldId ? newId : m.to,
275
+ }));
276
+ }
277
+ if (f.children?.length)
278
+ walkFields(f.children);
279
+ }
280
+ };
281
+ const walkTabs = (tabs) => {
282
+ for (const tab of tabs ?? []) {
283
+ walkFields(tab.fields);
284
+ walkTabs(tab.children);
285
+ }
286
+ };
287
+ walkTabs(draft.tabs);
288
+ });
289
+ this._rules.update(rules => rules.map(rule => ({
290
+ ...rule,
291
+ fieldId: rule.fieldId === oldId ? newId : rule.fieldId,
292
+ targets: rule.targets.map(t => t.type === 'field' && t.id === oldId ? { ...t, id: newId } : t),
293
+ })));
294
+ if (this._selectedFieldId() === oldId)
295
+ this._selectedFieldId.set(newId);
296
+ }
297
+ removeField(id) {
298
+ this.mutate(draft => {
299
+ for (const tab of draft.tabs) {
300
+ if (tab.fields) {
301
+ tab.fields = tab.fields.filter(f => f.id !== id);
302
+ }
303
+ }
304
+ });
305
+ this.manualIds.delete(id);
306
+ if (this._selectedFieldId() === id)
307
+ this._selectedFieldId.set(null);
308
+ }
309
+ duplicateField(id) {
310
+ // Look the source up by id — not by current selection. The insert below searches by id
311
+ // too, so falling back to the selected field could report a new id while inserting
312
+ // nothing, or duplicate a field the caller never named.
313
+ const source = this.findFieldInTabs(this._config().tabs, id);
314
+ if (!source)
315
+ return null;
316
+ const meta = getFieldTypeMeta(source.type);
317
+ const prefix = meta?.idPrefix ?? 'field';
318
+ const newId = this.uniqueId(prefix, this.fields());
319
+ this.mutate(draft => {
320
+ for (const tab of draft.tabs) {
321
+ const index = (tab.fields ?? []).findIndex(f => f.id === id);
322
+ if (index !== -1) {
323
+ const copy = clone(source);
324
+ copy.id = newId;
325
+ tab.fields.splice(index + 1, 0, copy);
326
+ break;
327
+ }
328
+ }
329
+ });
330
+ this._selectedFieldId.set(newId);
331
+ return newId;
332
+ }
333
+ moveField(id, direction) {
334
+ this.mutate(draft => {
335
+ for (const tab of draft.tabs) {
336
+ const fields = tab.fields ?? [];
337
+ const from = fields.findIndex(f => f.id === id);
338
+ if (from !== -1) {
339
+ const to = from + direction;
340
+ if (to < 0 || to >= fields.length)
341
+ return;
342
+ const [item] = fields.splice(from, 1);
343
+ fields.splice(to, 0, item);
344
+ return;
345
+ }
346
+ }
347
+ });
348
+ }
349
+ /** Safe reorder for drag & drop within active tab or flat list */
350
+ reorderField(fromIndex, toIndex, tabId) {
351
+ this.mutate(draft => {
352
+ const targetTab = tabId
353
+ ? draft.tabs.find(t => t.id === tabId)
354
+ : draft.tabs[0];
355
+ const fields = targetTab?.fields ?? [];
356
+ if (fromIndex < 0 ||
357
+ toIndex < 0 ||
358
+ fromIndex >= fields.length ||
359
+ toIndex >= fields.length) {
360
+ return;
361
+ }
362
+ const [item] = fields.splice(fromIndex, 1);
363
+ fields.splice(toIndex, 0, item);
364
+ });
365
+ }
366
+ selectField(id) {
367
+ this._selectedFieldId.set(id);
368
+ }
369
+ // ─── Localised text ─────────────────────────────────────────────────────────
370
+ /**
371
+ * Set a field's label, and derive the field's id from it: type "Employee Count", get
372
+ * `employeeCount`. The inspector renders the id read-only, so this is the only way a
373
+ * new field's id is set.
374
+ *
375
+ * Two things stop derivation: a config loaded from storage (its ids are live data keys
376
+ * that records are already stored under — see `load`), and an explicit `renameField`
377
+ * call, which remains available to programmatic callers.
378
+ *
379
+ * Only the config's default language drives the id; translating a label must not rename
380
+ * anything.
381
+ */
382
+ setFieldLabel(id, language, value) {
383
+ this.mutate(draft => {
384
+ const field = this.findFieldInTabs(draft.tabs, id);
385
+ if (field)
386
+ field.label = { ...field.label, [language]: value };
387
+ });
388
+ if (language !== (this._config().defaultLanguage ?? 'en'))
389
+ return;
390
+ if (this.manualIds.has(id))
391
+ return;
392
+ const derived = labelToId(value);
393
+ if (!derived || derived === id)
394
+ return;
395
+ this.applyRename(id, this.availableId(derived, id));
396
+ }
397
+ /** `derived`, or `derived_2`, `derived_3`… if another field already holds it. */
398
+ availableId(derived, currentId) {
399
+ const taken = new Set(this.fields().map(f => f.id).filter(fid => fid !== currentId));
400
+ if (!taken.has(derived))
401
+ return derived;
402
+ let n = 2;
403
+ while (taken.has(`${derived}_${n}`))
404
+ n += 1;
405
+ return `${derived}_${n}`;
406
+ }
407
+ setFieldPlaceholder(id, language, value) {
408
+ this.mutate(draft => {
409
+ const field = this.findFieldInTabs(draft.tabs, id);
410
+ if (!field)
411
+ return;
412
+ field.placeholder = { ...(field.placeholder ?? {}), [language]: value };
413
+ });
414
+ }
415
+ // ─── Validators ─────────────────────────────────────────────────────────────
416
+ toggleFlagValidator(id, validator, on) {
417
+ this.mutate(draft => {
418
+ const field = this.findFieldInTabs(draft.tabs, id);
419
+ if (!field)
420
+ return;
421
+ field.validators = field.validators ?? {};
422
+ if (validator === 'required') {
423
+ if (on)
424
+ field.validators.required = true;
425
+ else
426
+ delete field.validators.required;
427
+ }
428
+ else if (validator === 'email') {
429
+ if (on)
430
+ field.validators.pattern = '^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$';
431
+ else
432
+ delete field.validators.pattern;
433
+ }
434
+ });
435
+ }
436
+ setParamValidator(id, validator, value) {
437
+ this.mutate(draft => {
438
+ const field = this.findFieldInTabs(draft.tabs, id);
439
+ if (!field)
440
+ return;
441
+ field.validators = field.validators ?? {};
442
+ if (value === null || value === undefined || Number.isNaN(value)) {
443
+ delete field.validators[validator];
444
+ }
445
+ else {
446
+ field.validators[validator] = value;
447
+ }
448
+ });
449
+ }
450
+ getParamValidator(field, validator) {
451
+ const val = field.validators?.[validator];
452
+ return typeof val === 'number' ? val : null;
453
+ }
454
+ hasFlagValidator(field, validator) {
455
+ if (validator === 'required')
456
+ return !!field.validators?.required;
457
+ if (validator === 'email')
458
+ return !!field.validators?.pattern;
459
+ return false;
460
+ }
461
+ // ─── Options (dropdown / multiSelect / radio) ────────────────────────────────
462
+ /**
463
+ * Where a choice field's options come from.
464
+ *
465
+ * `entity` is absent on purpose: in this model an entity reference is a field *type*
466
+ * (`entity-ref`), not a data source a dropdown can switch to, so offering it here would mean
467
+ * mutating the field's type — and rebuilding its control — from a source picker. The
468
+ * exclusion that matters within a choice field is inline `options` vs `listName`.
469
+ */
470
+ fieldDataSource(field) {
471
+ if (!field)
472
+ return 'none';
473
+ // Presence, not emptiness: a source the author has picked but not filled in yet — no
474
+ // options authored, no list name typed — must still read as that source, or the editor
475
+ // for it disappears the moment they select it.
476
+ if (Array.isArray(field.options))
477
+ return 'manual';
478
+ if (typeof field.listName === 'string')
479
+ return 'lookup';
480
+ return 'none';
481
+ }
482
+ /**
483
+ * Switch a choice field's data source, clearing the one it is leaving.
484
+ *
485
+ * The reference makes these mutually exclusive (doc §4.4) and it has to stay that way here:
486
+ * `optionsFor` resolves inline options first, so a field left holding both would silently
487
+ * ignore its list.
488
+ */
489
+ setFieldDataSource(fieldId, source) {
490
+ this.mutate(draft => {
491
+ const field = this.findFieldInTabs(draft.tabs, fieldId);
492
+ if (!field)
493
+ return;
494
+ if (source === 'manual') {
495
+ delete field.listName;
496
+ field.options = field.options?.length ? field.options : [];
497
+ }
498
+ else if (source === 'lookup') {
499
+ delete field.options;
500
+ field.listName = field.listName ?? '';
501
+ }
502
+ else {
503
+ delete field.options;
504
+ delete field.listName;
505
+ }
506
+ });
507
+ }
508
+ /** Set the named list a choice field reads its options from. */
509
+ setListName(fieldId, listName) {
510
+ this.mutate(draft => {
511
+ const field = this.findFieldInTabs(draft.tabs, fieldId);
512
+ if (!field)
513
+ return;
514
+ const trimmed = listName.trim();
515
+ if (trimmed) {
516
+ field.listName = trimmed;
517
+ // Belt and braces: inline options would win over the list at render time.
518
+ delete field.options;
519
+ }
520
+ else {
521
+ field.listName = '';
522
+ }
523
+ });
524
+ }
525
+ /** Link a field to a field defined in another entity configuration (Phase 8). */
526
+ linkReferencedField(fieldId, sourceEntityKey, sourceField) {
527
+ this.mutate(draft => {
528
+ const field = this.findFieldInTabs(draft.tabs, fieldId);
529
+ if (!field)
530
+ return;
531
+ field.isReferenced = true;
532
+ field.referencedEntityKey = sourceEntityKey;
533
+ field.referencedFieldId = sourceField.id;
534
+ field.referencedSnapshot = createFieldSnapshot(sourceField);
535
+ field.hasDrift = false;
536
+ // Copy label, type, validators, options from source
537
+ if (sourceField.label)
538
+ field.label = clone(sourceField.label);
539
+ if (sourceField.type)
540
+ field.type = sourceField.type;
541
+ if (sourceField.validators)
542
+ field.validators = clone(sourceField.validators);
543
+ if (sourceField.options)
544
+ field.options = clone(sourceField.options);
545
+ });
546
+ }
547
+ /** Unlink a referenced field back to an independent field. */
548
+ unlinkReferencedField(fieldId) {
549
+ this.mutate(draft => {
550
+ const field = this.findFieldInTabs(draft.tabs, fieldId);
551
+ if (!field)
552
+ return;
553
+ delete field.isReferenced;
554
+ delete field.referencedEntityKey;
555
+ delete field.referencedFieldId;
556
+ delete field.referencedSnapshot;
557
+ delete field.hasDrift;
558
+ });
559
+ }
560
+ /** Sync a drifted referenced field with the updated source field definition. */
561
+ syncReferencedField(fieldId, currentSourceField) {
562
+ this.mutate(draft => {
563
+ const field = this.findFieldInTabs(draft.tabs, fieldId);
564
+ if (!field || !field.isReferenced)
565
+ return;
566
+ field.referencedSnapshot = createFieldSnapshot(currentSourceField);
567
+ field.hasDrift = false;
568
+ if (currentSourceField.label)
569
+ field.label = clone(currentSourceField.label);
570
+ if (currentSourceField.type)
571
+ field.type = currentSourceField.type;
572
+ if (currentSourceField.validators)
573
+ field.validators = clone(currentSourceField.validators);
574
+ if (currentSourceField.options)
575
+ field.options = clone(currentSourceField.options);
576
+ });
577
+ }
578
+ /** Scan all referenced fields in the config and update their hasDrift state against current source configs. */
579
+ checkDrift(sourceConfigs) {
580
+ this.mutate(draft => {
581
+ const scanFields = (fields) => {
582
+ for (const f of fields) {
583
+ if (f.isReferenced && f.referencedEntityKey && f.referencedFieldId) {
584
+ const sourceConfig = sourceConfigs[f.referencedEntityKey];
585
+ const sourceField = sourceConfig
586
+ ? this.findFieldInTabs(sourceConfig.tabs ?? [], f.referencedFieldId)
587
+ : undefined;
588
+ f.hasDrift = computeFieldDrift(f, sourceField ?? undefined);
589
+ }
590
+ if (f.children?.length)
591
+ scanFields(f.children);
592
+ }
593
+ };
594
+ for (const tab of draft.tabs ?? []) {
595
+ if (tab.fields?.length)
596
+ scanFields(tab.fields);
597
+ }
598
+ });
599
+ }
600
+ addOption(fieldId) {
601
+ this.mutate(draft => {
602
+ const field = this.findFieldInTabs(draft.tabs, fieldId);
603
+ if (!field)
604
+ return;
605
+ const options = field.options ?? [];
606
+ const n = options.length + 1;
607
+ const lang = draft.defaultLanguage ?? 'en';
608
+ options.push({ [lang]: `Option ${n}` });
609
+ field.options = options;
610
+ // Authoring an inline option makes this a manual field — see `setFieldDataSource`.
611
+ delete field.listName;
612
+ });
613
+ }
614
+ /** Merge language keys into an option. `setOptionLabel` is the usual single-language path. */
615
+ updateOption(fieldId, index, patch) {
616
+ this.mutate(draft => {
617
+ const field = this.findFieldInTabs(draft.tabs, fieldId);
618
+ const option = field?.options?.[index];
619
+ if (!option)
620
+ return;
621
+ field.options[index] = { ...option, ...patch };
622
+ });
623
+ }
624
+ /**
625
+ * Set an option's text for one language.
626
+ *
627
+ * An option is a language-keyed object and the displayed text **is** the stored value,
628
+ * so this is the only way to edit one — there is no separate value to set.
629
+ */
630
+ setOptionLabel(fieldId, index, language, value) {
631
+ this.mutate(draft => {
632
+ const field = this.findFieldInTabs(draft.tabs, fieldId);
633
+ const option = field?.options?.[index];
634
+ if (!option)
635
+ return;
636
+ field.options[index] = { ...option, [language]: value };
637
+ });
638
+ }
639
+ removeOption(fieldId, index) {
640
+ this.mutate(draft => {
641
+ const field = this.findFieldInTabs(draft.tabs, fieldId);
642
+ if (!field?.options)
643
+ return;
644
+ field.options = field.options.filter((_, i) => i !== index);
645
+ });
646
+ }
647
+ // ─── Entity reference / cascade ─────────────────────────────────────────────
648
+ /** Merge a patch into a field's `entityReference` block, keeping it enabled. */
649
+ updateEntityReference(fieldId, patch) {
650
+ this.mutate(draft => {
651
+ const field = this.findFieldInTabs(draft.tabs, fieldId);
652
+ if (!field)
653
+ return;
654
+ const next = {
655
+ ...(field.entityReference ?? { enabled: true }),
656
+ ...patch,
657
+ enabled: true,
658
+ };
659
+ // Drop keys explicitly cleared to `undefined` so exported configs stay clean.
660
+ for (const key of Object.keys(patch)) {
661
+ if (patch[key] === undefined)
662
+ delete next[key];
663
+ }
664
+ field.entityReference = next;
665
+ });
666
+ }
667
+ // ─── autoPatch ──────────────────────────────────────────────────────────────
668
+ addAutoPatchMapping(fieldId) {
669
+ this.mutate(draft => {
670
+ const field = this.findFieldInTabs(draft.tabs, fieldId);
671
+ if (!field)
672
+ return;
673
+ const targetTab = field.autoPatch?.targetTab ?? draft.tabs[0]?.id ?? '';
674
+ const mappings = [...(field.autoPatch?.mappings ?? []), { source: '', target: '' }];
675
+ field.autoPatch = { targetTab, mappings };
676
+ });
677
+ }
678
+ setAutoPatchTargetTab(fieldId, targetTab) {
679
+ this.mutate(draft => {
680
+ const field = this.findFieldInTabs(draft.tabs, fieldId);
681
+ if (!field?.autoPatch)
682
+ return;
683
+ field.autoPatch = { ...field.autoPatch, targetTab };
684
+ });
685
+ }
686
+ updateAutoPatchMapping(fieldId, index, patch) {
687
+ this.mutate(draft => {
688
+ const field = this.findFieldInTabs(draft.tabs, fieldId);
689
+ if (!field?.autoPatch?.mappings?.[index])
690
+ return;
691
+ field.autoPatch = {
692
+ ...field.autoPatch,
693
+ mappings: field.autoPatch.mappings.map((m, i) => (i === index ? { ...m, ...patch } : m)),
694
+ };
695
+ });
696
+ }
697
+ /** Removing the last mapping drops `autoPatch` entirely — no empty config in the export. */
698
+ removeAutoPatchMapping(fieldId, index) {
699
+ this.mutate(draft => {
700
+ const field = this.findFieldInTabs(draft.tabs, fieldId);
701
+ if (!field?.autoPatch)
702
+ return;
703
+ const mappings = field.autoPatch.mappings.filter((_, i) => i !== index);
704
+ if (mappings.length === 0)
705
+ delete field.autoPatch;
706
+ else
707
+ field.autoPatch = { ...field.autoPatch, mappings };
708
+ });
709
+ }
710
+ // ─── patchOnTrue ────────────────────────────────────────────────────────────
711
+ addPatchOnTrueMapping(fieldId) {
712
+ this.mutate(draft => {
713
+ const field = this.findFieldInTabs(draft.tabs, fieldId);
714
+ if (!field)
715
+ return;
716
+ field.patchOnTrue = [...(field.patchOnTrue ?? []), { from: '', to: '' }];
717
+ });
718
+ }
719
+ updatePatchOnTrueMapping(fieldId, index, patch) {
720
+ this.mutate(draft => {
721
+ const field = this.findFieldInTabs(draft.tabs, fieldId);
722
+ if (!field?.patchOnTrue?.[index])
723
+ return;
724
+ field.patchOnTrue = field.patchOnTrue.map((m, i) => (i === index ? { ...m, ...patch } : m));
725
+ });
726
+ }
727
+ removePatchOnTrueMapping(fieldId, index) {
728
+ this.mutate(draft => {
729
+ const field = this.findFieldInTabs(draft.tabs, fieldId);
730
+ if (!field?.patchOnTrue)
731
+ return;
732
+ const mappings = field.patchOnTrue.filter((_, i) => i !== index);
733
+ if (mappings.length === 0)
734
+ delete field.patchOnTrue;
735
+ else
736
+ field.patchOnTrue = mappings;
737
+ });
738
+ }
739
+ // ─── showWhen (static conditional visibility) ───────────────────────────────
740
+ /** Replace a field's `showWhen` map; an empty map removes the condition. */
741
+ setShowWhen(fieldId, showWhen) {
742
+ this.mutate(draft => {
743
+ const field = this.findFieldInTabs(draft.tabs, fieldId);
744
+ if (!field)
745
+ return;
746
+ if (!showWhen || Object.keys(showWhen).length === 0)
747
+ delete field.showWhen;
748
+ else
749
+ field.showWhen = showWhen;
750
+ });
751
+ }
752
+ // ─── Rules ──────────────────────────────────────────────────────────────────
753
+ /** Rules for the field currently selected in the inspector. */
754
+ rulesForSelectedField = computed(() => {
755
+ const id = this._selectedFieldId();
756
+ if (!id)
757
+ return [];
758
+ return this._rules().filter(r => r.fieldId === id || r.targets.some(t => t.id === id));
759
+ });
760
+ loadRules(rules) {
761
+ this._rules.set(clone(rules));
762
+ }
763
+ addRule(rule) {
764
+ const id = rule.id?.trim() || this.uniqueId('rule', this._rules().map(r => ({ id: r.id ?? '' })));
765
+ this._rules.update(rules => [...rules, { ...clone(rule), id }]);
766
+ return id;
767
+ }
768
+ updateRule(id, patch) {
769
+ this._rules.update(rules => rules.map(r => (r.id === id ? { ...r, ...clone(patch), id } : r)));
770
+ }
771
+ removeRule(id) {
772
+ this._rules.update(rules => rules.filter(r => r.id !== id));
773
+ }
774
+ toggleRule(id, enabled) {
775
+ this.updateRule(id, { enabled });
776
+ }
777
+ /** Move a rule in priority order. Priorities are renumbered 1..n so they stay contiguous. */
778
+ moveRule(id, direction) {
779
+ this._rules.update(rules => {
780
+ const next = [...rules];
781
+ const from = next.findIndex(r => r.id === id);
782
+ if (from === -1)
783
+ return rules;
784
+ const to = from + direction;
785
+ if (to < 0 || to >= next.length)
786
+ return rules;
787
+ const [item] = next.splice(from, 1);
788
+ next.splice(to, 0, item);
789
+ return next.map((r, i) => ({ ...r, priority: i + 1 }));
790
+ });
791
+ }
792
+ exportRules() {
793
+ return clone(this._rules());
794
+ }
795
+ // ─── Tabs ───────────────────────────────────────────────────────────────────
796
+ addTab() {
797
+ // Must be unique across the whole tree, not just the top level: a tab id is the record
798
+ // storage path, so a new top-level tab colliding with an existing sub-tab would make two
799
+ // tabs write to the same key and break findTab/getTabPath.
800
+ const id = this.uniqueId('tab', this.allTabIds().map(tid => ({ id: tid })));
801
+ this.mutate(draft => {
802
+ const tabs = draft.tabs ?? (draft.tabs = []);
803
+ const lang = draft.defaultLanguage ?? 'en';
804
+ tabs.push({ id, label: { [lang]: humanizeId(id) }, fields: [] });
805
+ });
806
+ return id;
807
+ }
808
+ /** Every tab id in the tree, at any depth. Tab ids must be globally unique — they are paths. */
809
+ allTabIds() {
810
+ const ids = [];
811
+ const collect = (tabs) => {
812
+ for (const t of tabs ?? []) {
813
+ ids.push(t.id);
814
+ collect(t.children);
815
+ }
816
+ };
817
+ collect(this._config().tabs);
818
+ return ids;
819
+ }
820
+ addSubTab(parentId) {
821
+ const id = this.uniqueId('tab', this.allTabIds().map(tid => ({ id: tid })));
822
+ this.mutate(draft => {
823
+ const parent = findTab(draft.tabs, parentId);
824
+ if (parent) {
825
+ parent.children = parent.children ?? [];
826
+ const lang = draft.defaultLanguage ?? 'en';
827
+ parent.children.push({ id, label: { [lang]: humanizeId(id) }, fields: [] });
828
+ }
829
+ });
830
+ return id;
831
+ }
832
+ setPrimaryTab(tabId) {
833
+ this.mutate(draft => {
834
+ const markPrimary = (tabs) => {
835
+ for (const t of tabs) {
836
+ t.isPrimaryTab = t.id === tabId;
837
+ if (t.children)
838
+ markPrimary(t.children);
839
+ }
840
+ };
841
+ markPrimary(draft.tabs ?? []);
842
+ });
843
+ }
844
+ updateTab(id, patch) {
845
+ this.mutate(draft => {
846
+ const tab = findTab(draft.tabs, id);
847
+ if (tab)
848
+ Object.assign(tab, patch);
849
+ });
850
+ }
851
+ setTabLabel(id, language, value) {
852
+ this.mutate(draft => {
853
+ const tab = findTab(draft.tabs, id);
854
+ if (tab)
855
+ tab.label = { ...tab.label, [language]: value };
856
+ });
857
+ }
858
+ removeTab(id) {
859
+ this.mutate(draft => {
860
+ const removeRecursive = (tabs) => {
861
+ return tabs
862
+ .filter(t => t.id !== id)
863
+ .map(t => ({
864
+ ...t,
865
+ children: t.children ? removeRecursive(t.children) : undefined,
866
+ }));
867
+ };
868
+ draft.tabs = removeRecursive(draft.tabs ?? []);
869
+ });
870
+ }
871
+ moveTab(id, direction) {
872
+ this.mutate(draft => {
873
+ const tabs = draft.tabs ?? [];
874
+ const from = tabs.findIndex(t => t.id === id);
875
+ if (from === -1)
876
+ return;
877
+ const to = from + direction;
878
+ if (to < 0 || to >= tabs.length)
879
+ return;
880
+ const [item] = tabs.splice(from, 1);
881
+ tabs.splice(to, 0, item);
882
+ });
883
+ }
884
+ // ─── Export ─────────────────────────────────────────────────────────────────
885
+ exportConfig() {
886
+ return clone(this._config());
887
+ }
888
+ // ─── Internals ──────────────────────────────────────────────────────────────
889
+ mutate(fn) {
890
+ const draft = clone(this._config());
891
+ draft.tabs = draft.tabs ?? [];
892
+ fn(draft);
893
+ this._config.set(draft);
894
+ this._isDirty.set(true);
895
+ }
896
+ uniqueId(prefix, existing) {
897
+ const taken = new Set(existing.map(e => e.id));
898
+ let n = 1;
899
+ let candidate = `${prefix}_${n}`;
900
+ while (taken.has(candidate)) {
901
+ n += 1;
902
+ candidate = `${prefix}_${n}`;
903
+ }
904
+ return candidate;
905
+ }
906
+ validate(config) {
907
+ const problems = [];
908
+ if (!config.entity || !config.entity.trim()) {
909
+ problems.push({ level: 'error', message: 'Entity name is required.' });
910
+ }
911
+ else if (!ID_PATTERN.test(config.entity)) {
912
+ problems.push({
913
+ level: 'error',
914
+ message: 'Entity name must start with a letter or underscore and contain only letters, digits, and underscores.',
915
+ });
916
+ }
917
+ const allFields = this.getAllFields(config.tabs);
918
+ if (allFields.length === 0) {
919
+ problems.push({ level: 'warning', message: 'This entity has no fields yet.' });
920
+ }
921
+ const seen = new Map();
922
+ for (const field of allFields) {
923
+ seen.set(field.id, (seen.get(field.id) ?? 0) + 1);
924
+ if (!field.id || !ID_PATTERN.test(field.id)) {
925
+ problems.push({
926
+ level: 'error',
927
+ message: `Field id "${field.id}" is not a valid identifier.`,
928
+ fieldId: field.id,
929
+ });
930
+ }
931
+ const labels = field.label ?? {};
932
+ const hasLabel = Object.values(labels).some(v => typeof v === 'string' && v.trim());
933
+ if (!hasLabel) {
934
+ problems.push({
935
+ level: 'warning',
936
+ message: `Field "${field.id}" has no label.`,
937
+ fieldId: field.id,
938
+ });
939
+ }
940
+ const meta = getFieldTypeMeta(field.type);
941
+ // A field on the lookup source has no inline options by design — its options arrive from
942
+ // the registry at runtime. Warn only about the missing list name.
943
+ if (meta?.hasOptions && typeof field.listName === 'string') {
944
+ if (!field.listName.trim()) {
945
+ problems.push({
946
+ level: 'warning',
947
+ message: `Field "${field.id}" reads options from a named list but has no list name.`,
948
+ fieldId: field.id,
949
+ });
950
+ }
951
+ }
952
+ else if (meta?.hasOptions && (!field.options || field.options.length === 0)) {
953
+ problems.push({
954
+ level: 'warning',
955
+ message: `Field "${field.id}" is a ${field.type} but has no options.`,
956
+ fieldId: field.id,
957
+ });
958
+ }
959
+ }
960
+ for (const [id, count] of seen) {
961
+ if (count > 1) {
962
+ problems.push({ level: 'error', message: `Duplicate field id "${id}" (${count}×).`, fieldId: id });
963
+ }
964
+ }
965
+ return problems;
966
+ }
967
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: BuilderStore, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
968
+ static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: BuilderStore });
969
+ }
970
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: BuilderStore, decorators: [{
971
+ type: Injectable
972
+ }] });
973
+
974
+ /**
975
+ * EntityReferenceConfigComponent — authors the `entityReference` block of an entity-ref field:
976
+ * registry key, display fields, static filters, and the parent→child cascade.
977
+ *
978
+ * Also authors `autoPatch`, since the mappings only make sense against the record the
979
+ * selected reference returns.
980
+ */
981
+ class EntityReferenceConfigComponent {
982
+ store = inject(BuilderStore);
983
+ field = this.store.selectedField;
984
+ /** Sibling fields that can drive a cascade — anything but this field itself. */
985
+ parentCandidates = computed(() => {
986
+ const current = this.field();
987
+ return this.store.fields().filter(f => f.id !== current?.id);
988
+ });
989
+ filtersParseError = signal(null);
990
+ filtersError = this.filtersParseError.asReadonly();
991
+ ref(field) {
992
+ return field.entityReference ?? { enabled: true };
993
+ }
994
+ patchRef(field, patch) {
995
+ this.store.updateEntityReference(field.id, patch);
996
+ }
997
+ fieldLabel(field) {
998
+ return resolveLabel(field.label, this.store.activeLanguage());
999
+ }
1000
+ tabLabel(tabId) {
1001
+ const tab = this.store.tabs().find(t => t.id === tabId);
1002
+ return tab ? resolveLabel(tab.label, this.store.activeLanguage()) : tabId;
1003
+ }
1004
+ displayFieldsText(field) {
1005
+ return (this.ref(field).displayFields ?? []).join(', ');
1006
+ }
1007
+ setDisplayFields(field, value) {
1008
+ const parts = value
1009
+ .split(',')
1010
+ .map(s => s.trim())
1011
+ .filter(Boolean);
1012
+ this.patchRef(field, { displayFields: parts.length ? parts : undefined });
1013
+ }
1014
+ filtersText(field) {
1015
+ const filters = this.ref(field).filters;
1016
+ return filters ? JSON.stringify(filters) : '';
1017
+ }
1018
+ /** Invalid JSON is reported inline and left unapplied rather than silently dropped. */
1019
+ setFilters(field, value) {
1020
+ const trimmed = value.trim();
1021
+ if (!trimmed) {
1022
+ this.filtersParseError.set(null);
1023
+ this.patchRef(field, { filters: undefined });
1024
+ return;
1025
+ }
1026
+ try {
1027
+ const parsed = JSON.parse(trimmed);
1028
+ if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
1029
+ this.filtersParseError.set('Filters must be a JSON object.');
1030
+ return;
1031
+ }
1032
+ this.filtersParseError.set(null);
1033
+ this.patchRef(field, { filters: parsed });
1034
+ }
1035
+ catch {
1036
+ this.filtersParseError.set('Invalid JSON.');
1037
+ }
1038
+ }
1039
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: EntityReferenceConfigComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
1040
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "17.3.12", type: EntityReferenceConfigComponent, isStandalone: true, selector: "ngx-entity-reference-config", ngImport: i0, template: `
1041
+ @if (field(); as f) {
1042
+ <div class="deb-entity-ref">
1043
+ <span class="deb-section-title">Entity reference</span>
1044
+
1045
+ <mat-form-field appearance="outline" subscriptSizing="dynamic" class="deb-full">
1046
+ <mat-label>Registry key</mat-label>
1047
+ <input
1048
+ matInput
1049
+ data-testid="entity-ref-key"
1050
+ [ngModel]="ref(f).linkedEntityKey ?? ''"
1051
+ (ngModelChange)="patchRef(f, { linkedEntityKey: $event || undefined })"
1052
+ />
1053
+ <mat-hint>Key the consumer registered a loader under. Defaults to the field id.</mat-hint>
1054
+ </mat-form-field>
1055
+
1056
+ <mat-form-field appearance="outline" subscriptSizing="dynamic" class="deb-full">
1057
+ <mat-label>Display fields</mat-label>
1058
+ <input
1059
+ matInput
1060
+ data-testid="entity-ref-display-fields"
1061
+ [ngModel]="displayFieldsText(f)"
1062
+ (ngModelChange)="setDisplayFields(f, $event)"
1063
+ />
1064
+ <mat-hint>Comma-separated record paths used to build each option label.</mat-hint>
1065
+ </mat-form-field>
1066
+
1067
+ <mat-form-field appearance="outline" subscriptSizing="dynamic" class="deb-full">
1068
+ <mat-label>Static filters (JSON)</mat-label>
1069
+ <input
1070
+ matInput
1071
+ data-testid="entity-ref-filters"
1072
+ [ngModel]="filtersText(f)"
1073
+ (ngModelChange)="setFilters(f, $event)"
1074
+ />
1075
+ @if (filtersError()) {
1076
+ <mat-hint class="deb-error-hint">{{ filtersError() }}</mat-hint>
1077
+ } @else {
1078
+ <mat-hint>Passed to the loader, e.g. &#123;"isEmployee": false&#125;.</mat-hint>
1079
+ }
1080
+ </mat-form-field>
1081
+
1082
+ <mat-divider></mat-divider>
1083
+
1084
+ <!-- Cascade -->
1085
+ <span class="deb-section-title">Cascade</span>
1086
+ <mat-form-field appearance="outline" subscriptSizing="dynamic" class="deb-full">
1087
+ <mat-label>Parent field</mat-label>
1088
+ <mat-select
1089
+ data-testid="entity-ref-parent"
1090
+ [ngModel]="ref(f).parentField ?? ''"
1091
+ (ngModelChange)="patchRef(f, { parentField: $event || undefined })"
1092
+ >
1093
+ <mat-option value="">None — load all options</mat-option>
1094
+ @for (candidate of parentCandidates(); track candidate.id) {
1095
+ <mat-option [value]="candidate.id">{{ fieldLabel(candidate) }} ({{ candidate.id }})</mat-option>
1096
+ }
1097
+ </mat-select>
1098
+ <mat-hint>This field's options reload whenever the parent changes.</mat-hint>
1099
+ </mat-form-field>
1100
+
1101
+ @if (ref(f).parentField) {
1102
+ <mat-form-field appearance="outline" subscriptSizing="dynamic" class="deb-full">
1103
+ <mat-label>Lookup filter path</mat-label>
1104
+ <input
1105
+ matInput
1106
+ data-testid="entity-ref-lookup-filter"
1107
+ [ngModel]="ref(f).lookupFilter ?? ''"
1108
+ (ngModelChange)="patchRef(f, { lookupFilter: $event || undefined })"
1109
+ />
1110
+ <mat-hint>Keep options whose record matches the parent value at this path.</mat-hint>
1111
+ </mat-form-field>
1112
+
1113
+ <mat-form-field appearance="outline" subscriptSizing="dynamic" class="deb-full">
1114
+ <mat-label>Lookup path (nested options)</mat-label>
1115
+ <input
1116
+ matInput
1117
+ data-testid="entity-ref-lookup-path"
1118
+ [ngModel]="ref(f).lookupPath ?? ''"
1119
+ (ngModelChange)="patchRef(f, { lookupPath: $event || undefined })"
1120
+ />
1121
+ <mat-hint>Take options from this array on the selected parent's record instead.</mat-hint>
1122
+ </mat-form-field>
1123
+ }
1124
+
1125
+ <mat-divider></mat-divider>
1126
+
1127
+ <!-- autoPatch -->
1128
+ <div class="deb-row deb-row--split">
1129
+ <span class="deb-section-title">Auto-patch on select</span>
1130
+ <button
1131
+ mat-stroked-button
1132
+ type="button"
1133
+ data-testid="add-auto-patch"
1134
+ (click)="store.addAutoPatchMapping(f.id)"
1135
+ >
1136
+ <mat-icon>add</mat-icon> Mapping
1137
+ </button>
1138
+ </div>
1139
+
1140
+ @if (f.autoPatch) {
1141
+ <mat-form-field appearance="outline" subscriptSizing="dynamic" class="deb-full">
1142
+ <mat-label>Target tab</mat-label>
1143
+ <mat-select
1144
+ data-testid="auto-patch-tab"
1145
+ [ngModel]="f.autoPatch.targetTab"
1146
+ (ngModelChange)="store.setAutoPatchTargetTab(f.id, $event)"
1147
+ >
1148
+ @for (tab of store.tabs(); track tab.id) {
1149
+ <mat-option [value]="tab.id">{{ tabLabel(tab.id) }}</mat-option>
1150
+ }
1151
+ </mat-select>
1152
+ </mat-form-field>
1153
+
1154
+ @for (mapping of f.autoPatch.mappings; track $index) {
1155
+ <div class="deb-option-row">
1156
+ <mat-form-field appearance="outline" subscriptSizing="dynamic">
1157
+ <mat-label>Source (linked record)</mat-label>
1158
+ <input
1159
+ matInput
1160
+ [ngModel]="mapping.source"
1161
+ (ngModelChange)="store.updateAutoPatchMapping(f.id, $index, { source: $event })"
1162
+ />
1163
+ </mat-form-field>
1164
+ <mat-form-field appearance="outline" subscriptSizing="dynamic">
1165
+ <mat-label>Target field</mat-label>
1166
+ <input
1167
+ matInput
1168
+ [ngModel]="mapping.target"
1169
+ (ngModelChange)="store.updateAutoPatchMapping(f.id, $index, { target: $event })"
1170
+ />
1171
+ </mat-form-field>
1172
+ <button
1173
+ mat-icon-button
1174
+ type="button"
1175
+ color="warn"
1176
+ matTooltip="Remove mapping"
1177
+ (click)="store.removeAutoPatchMapping(f.id, $index)"
1178
+ >
1179
+ <mat-icon>delete</mat-icon>
1180
+ </button>
1181
+ </div>
1182
+ }
1183
+ } @else {
1184
+ <p class="deb-hint">
1185
+ No auto-patch configured — selecting a reference will not copy any values.
1186
+ </p>
1187
+ }
1188
+ </div>
1189
+ }
1190
+ `, isInline: true, styles: [".deb-entity-ref{display:flex;flex-direction:column;gap:12px}.deb-full{width:100%}.deb-row{display:flex;gap:8px;align-items:center}.deb-row--split{justify-content:space-between}.deb-option-row{display:flex;gap:6px;align-items:center}.deb-option-row>mat-form-field{flex:1}.deb-error-hint{color:#b91c1c}\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.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[mat-button], button[mat-raised-button], button[mat-flat-button], button[mat-stroked-button] ", exportAs: ["matButton"] }, { kind: "component", type: i2.MatIconButton, selector: "button[mat-icon-button]", exportAs: ["matButton"] }, { 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"], 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"], outputs: ["openedChange", "opened", "closed", "selectionChange", "valueChange"], exportAs: ["matSelect"] }, { kind: "component", type: i9.MatOption, selector: "mat-option", inputs: ["value", "id", "disabled"], outputs: ["onSelectionChange"], exportAs: ["matOption"] }, { kind: "ngmodule", type: MatSlideToggleModule }, { kind: "ngmodule", type: MatTooltipModule }, { kind: "directive", type: i4$2.MatTooltip, selector: "[matTooltip]", inputs: ["matTooltipPosition", "matTooltipPositionAtOrigin", "matTooltipDisabled", "matTooltipShowDelay", "matTooltipHideDelay", "matTooltipTouchGestures", "matTooltip", "matTooltipClass"], exportAs: ["matTooltip"] }] });
1191
+ }
1192
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: EntityReferenceConfigComponent, decorators: [{
1193
+ type: Component,
1194
+ args: [{ selector: 'ngx-entity-reference-config', standalone: true, imports: [
1195
+ FormsModule,
1196
+ MatButtonModule,
1197
+ MatDividerModule,
1198
+ MatFormFieldModule,
1199
+ MatIconModule,
1200
+ MatInputModule,
1201
+ MatSelectModule,
1202
+ MatSlideToggleModule,
1203
+ MatTooltipModule,
1204
+ ], template: `
1205
+ @if (field(); as f) {
1206
+ <div class="deb-entity-ref">
1207
+ <span class="deb-section-title">Entity reference</span>
1208
+
1209
+ <mat-form-field appearance="outline" subscriptSizing="dynamic" class="deb-full">
1210
+ <mat-label>Registry key</mat-label>
1211
+ <input
1212
+ matInput
1213
+ data-testid="entity-ref-key"
1214
+ [ngModel]="ref(f).linkedEntityKey ?? ''"
1215
+ (ngModelChange)="patchRef(f, { linkedEntityKey: $event || undefined })"
1216
+ />
1217
+ <mat-hint>Key the consumer registered a loader under. Defaults to the field id.</mat-hint>
1218
+ </mat-form-field>
1219
+
1220
+ <mat-form-field appearance="outline" subscriptSizing="dynamic" class="deb-full">
1221
+ <mat-label>Display fields</mat-label>
1222
+ <input
1223
+ matInput
1224
+ data-testid="entity-ref-display-fields"
1225
+ [ngModel]="displayFieldsText(f)"
1226
+ (ngModelChange)="setDisplayFields(f, $event)"
1227
+ />
1228
+ <mat-hint>Comma-separated record paths used to build each option label.</mat-hint>
1229
+ </mat-form-field>
1230
+
1231
+ <mat-form-field appearance="outline" subscriptSizing="dynamic" class="deb-full">
1232
+ <mat-label>Static filters (JSON)</mat-label>
1233
+ <input
1234
+ matInput
1235
+ data-testid="entity-ref-filters"
1236
+ [ngModel]="filtersText(f)"
1237
+ (ngModelChange)="setFilters(f, $event)"
1238
+ />
1239
+ @if (filtersError()) {
1240
+ <mat-hint class="deb-error-hint">{{ filtersError() }}</mat-hint>
1241
+ } @else {
1242
+ <mat-hint>Passed to the loader, e.g. &#123;"isEmployee": false&#125;.</mat-hint>
1243
+ }
1244
+ </mat-form-field>
1245
+
1246
+ <mat-divider></mat-divider>
1247
+
1248
+ <!-- Cascade -->
1249
+ <span class="deb-section-title">Cascade</span>
1250
+ <mat-form-field appearance="outline" subscriptSizing="dynamic" class="deb-full">
1251
+ <mat-label>Parent field</mat-label>
1252
+ <mat-select
1253
+ data-testid="entity-ref-parent"
1254
+ [ngModel]="ref(f).parentField ?? ''"
1255
+ (ngModelChange)="patchRef(f, { parentField: $event || undefined })"
1256
+ >
1257
+ <mat-option value="">None — load all options</mat-option>
1258
+ @for (candidate of parentCandidates(); track candidate.id) {
1259
+ <mat-option [value]="candidate.id">{{ fieldLabel(candidate) }} ({{ candidate.id }})</mat-option>
1260
+ }
1261
+ </mat-select>
1262
+ <mat-hint>This field's options reload whenever the parent changes.</mat-hint>
1263
+ </mat-form-field>
1264
+
1265
+ @if (ref(f).parentField) {
1266
+ <mat-form-field appearance="outline" subscriptSizing="dynamic" class="deb-full">
1267
+ <mat-label>Lookup filter path</mat-label>
1268
+ <input
1269
+ matInput
1270
+ data-testid="entity-ref-lookup-filter"
1271
+ [ngModel]="ref(f).lookupFilter ?? ''"
1272
+ (ngModelChange)="patchRef(f, { lookupFilter: $event || undefined })"
1273
+ />
1274
+ <mat-hint>Keep options whose record matches the parent value at this path.</mat-hint>
1275
+ </mat-form-field>
1276
+
1277
+ <mat-form-field appearance="outline" subscriptSizing="dynamic" class="deb-full">
1278
+ <mat-label>Lookup path (nested options)</mat-label>
1279
+ <input
1280
+ matInput
1281
+ data-testid="entity-ref-lookup-path"
1282
+ [ngModel]="ref(f).lookupPath ?? ''"
1283
+ (ngModelChange)="patchRef(f, { lookupPath: $event || undefined })"
1284
+ />
1285
+ <mat-hint>Take options from this array on the selected parent's record instead.</mat-hint>
1286
+ </mat-form-field>
1287
+ }
1288
+
1289
+ <mat-divider></mat-divider>
1290
+
1291
+ <!-- autoPatch -->
1292
+ <div class="deb-row deb-row--split">
1293
+ <span class="deb-section-title">Auto-patch on select</span>
1294
+ <button
1295
+ mat-stroked-button
1296
+ type="button"
1297
+ data-testid="add-auto-patch"
1298
+ (click)="store.addAutoPatchMapping(f.id)"
1299
+ >
1300
+ <mat-icon>add</mat-icon> Mapping
1301
+ </button>
1302
+ </div>
1303
+
1304
+ @if (f.autoPatch) {
1305
+ <mat-form-field appearance="outline" subscriptSizing="dynamic" class="deb-full">
1306
+ <mat-label>Target tab</mat-label>
1307
+ <mat-select
1308
+ data-testid="auto-patch-tab"
1309
+ [ngModel]="f.autoPatch.targetTab"
1310
+ (ngModelChange)="store.setAutoPatchTargetTab(f.id, $event)"
1311
+ >
1312
+ @for (tab of store.tabs(); track tab.id) {
1313
+ <mat-option [value]="tab.id">{{ tabLabel(tab.id) }}</mat-option>
1314
+ }
1315
+ </mat-select>
1316
+ </mat-form-field>
1317
+
1318
+ @for (mapping of f.autoPatch.mappings; track $index) {
1319
+ <div class="deb-option-row">
1320
+ <mat-form-field appearance="outline" subscriptSizing="dynamic">
1321
+ <mat-label>Source (linked record)</mat-label>
1322
+ <input
1323
+ matInput
1324
+ [ngModel]="mapping.source"
1325
+ (ngModelChange)="store.updateAutoPatchMapping(f.id, $index, { source: $event })"
1326
+ />
1327
+ </mat-form-field>
1328
+ <mat-form-field appearance="outline" subscriptSizing="dynamic">
1329
+ <mat-label>Target field</mat-label>
1330
+ <input
1331
+ matInput
1332
+ [ngModel]="mapping.target"
1333
+ (ngModelChange)="store.updateAutoPatchMapping(f.id, $index, { target: $event })"
1334
+ />
1335
+ </mat-form-field>
1336
+ <button
1337
+ mat-icon-button
1338
+ type="button"
1339
+ color="warn"
1340
+ matTooltip="Remove mapping"
1341
+ (click)="store.removeAutoPatchMapping(f.id, $index)"
1342
+ >
1343
+ <mat-icon>delete</mat-icon>
1344
+ </button>
1345
+ </div>
1346
+ }
1347
+ } @else {
1348
+ <p class="deb-hint">
1349
+ No auto-patch configured — selecting a reference will not copy any values.
1350
+ </p>
1351
+ }
1352
+ </div>
1353
+ }
1354
+ `, styles: [".deb-entity-ref{display:flex;flex-direction:column;gap:12px}.deb-full{width:100%}.deb-row{display:flex;gap:8px;align-items:center}.deb-row--split{justify-content:space-between}.deb-option-row{display:flex;gap:6px;align-items:center}.deb-option-row>mat-form-field{flex:1}.deb-error-hint{color:#b91c1c}\n"] }]
1355
+ }] });
1356
+
1357
+ /**
1358
+ * RuleFormComponent — dialog/panel form for creating or editing a FormRule.
1359
+ */
1360
+ class RuleFormComponent {
1361
+ rule = {
1362
+ formConfigId: 'form-1',
1363
+ fieldId: '',
1364
+ conditions: [{ operator: 'EQUAL', compareType: 'value', value: '' }],
1365
+ action: { type: 'visibility', value: false },
1366
+ targets: [],
1367
+ enabled: true,
1368
+ priority: 1,
1369
+ };
1370
+ save = new EventEmitter();
1371
+ cancel = new EventEmitter();
1372
+ operators = [
1373
+ 'EQUAL',
1374
+ 'NOT_EQUAL',
1375
+ 'CONTAINS',
1376
+ 'NOT_CONTAINS',
1377
+ 'STARTS_WITH',
1378
+ 'ENDS_WITH',
1379
+ 'IS_EMPTY',
1380
+ 'IS_NOT_EMPTY',
1381
+ 'LESS_THAN',
1382
+ 'MORE_THAN',
1383
+ 'LESS_THAN_EQUAL',
1384
+ 'MORE_THAN_EQUAL',
1385
+ 'DATE_BEFORE',
1386
+ 'DATE_AFTER',
1387
+ 'IN',
1388
+ 'NOT_IN',
1389
+ 'HAS_ITEMS',
1390
+ 'VALUE_CHANGED',
1391
+ ];
1392
+ addCondition() {
1393
+ this.rule.conditions.push({ operator: 'EQUAL', compareType: 'value', value: '' });
1394
+ }
1395
+ removeCondition(index) {
1396
+ this.rule.conditions.splice(index, 1);
1397
+ }
1398
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: RuleFormComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
1399
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "17.3.12", type: RuleFormComponent, isStandalone: true, selector: "ngx-rule-form", inputs: { rule: "rule" }, outputs: { save: "save", cancel: "cancel" }, ngImport: i0, template: `
1400
+ <div class="deb-rule-form">
1401
+ <h3 class="deb-rule-form__title">{{ rule.id ? 'Edit Rule' : 'New Form Rule' }}</h3>
1402
+
1403
+ <!-- Trigger Field -->
1404
+ <mat-form-field appearance="outline" subscriptSizing="dynamic" class="deb-full">
1405
+ <mat-label>Trigger Field ID</mat-label>
1406
+ <input matInput [(ngModel)]="rule.fieldId" placeholder="e.g. status" />
1407
+ </mat-form-field>
1408
+
1409
+ <!-- Conditions -->
1410
+ <div class="deb-rule-section">
1411
+ <h4>Conditions</h4>
1412
+ @for (cond of rule.conditions; track $index) {
1413
+ <div class="deb-rule-row">
1414
+ <mat-form-field appearance="outline" subscriptSizing="dynamic">
1415
+ <mat-label>Operator</mat-label>
1416
+ <mat-select [(ngModel)]="cond.operator">
1417
+ @for (op of operators; track op) {
1418
+ <mat-option [value]="op">{{ op }}</mat-option>
1419
+ }
1420
+ </mat-select>
1421
+ </mat-form-field>
1422
+ <mat-form-field appearance="outline" subscriptSizing="dynamic">
1423
+ <mat-label>Value</mat-label>
1424
+ <input matInput [(ngModel)]="cond.value" placeholder="Condition value" />
1425
+ </mat-form-field>
1426
+ <button mat-icon-button color="warn" type="button" (click)="removeCondition($index)">
1427
+ <mat-icon>delete</mat-icon>
1428
+ </button>
1429
+ </div>
1430
+ }
1431
+ <button mat-stroked-button type="button" (click)="addCondition()">
1432
+ <mat-icon>add</mat-icon> Add Condition
1433
+ </button>
1434
+ </div>
1435
+
1436
+ <!-- Action -->
1437
+ <div class="deb-rule-section">
1438
+ <h4>Action</h4>
1439
+ <div class="deb-rule-row">
1440
+ <mat-form-field appearance="outline" subscriptSizing="dynamic">
1441
+ <mat-label>Action Type</mat-label>
1442
+ <mat-select [(ngModel)]="rule.action.type">
1443
+ <mat-option value="visibility">Visibility (Show/Hide)</mat-option>
1444
+ <mat-option value="validation">Validation Message</mat-option>
1445
+ <mat-option value="info">Info Banner</mat-option>
1446
+ </mat-select>
1447
+ </mat-form-field>
1448
+ <mat-form-field appearance="outline" subscriptSizing="dynamic">
1449
+ <mat-label>Value / Message</mat-label>
1450
+ <input matInput [(ngModel)]="rule.action.value" placeholder="false to hide, or message text" />
1451
+ </mat-form-field>
1452
+ </div>
1453
+ </div>
1454
+
1455
+ <div class="deb-rule-form__actions">
1456
+ <button mat-button type="button" (click)="cancel.emit()">Cancel</button>
1457
+ <button mat-raised-button color="primary" type="button" (click)="save.emit(rule)">Save Rule</button>
1458
+ </div>
1459
+ </div>
1460
+ `, isInline: true, styles: [".deb-rule-form{display:flex;flex-direction:column;gap:12px;background:#fafafa;border:1px solid #e0e0e0;border-radius:8px;padding:16px}.deb-rule-form__title{margin:0 0 8px;font-size:16px;font-weight:600}.deb-full{width:100%}.deb-rule-section{display:flex;flex-direction:column;gap:8px}.deb-rule-section h4{margin:0;font-size:13px;font-weight:600;color:#555}.deb-rule-row{display:flex;gap:8px;align-items:center}.deb-rule-row>*{flex:1}.deb-rule-form__actions{display:flex;justify-content:flex-end;gap:8px;margin-top:8px}\n"], dependencies: [{ kind: "ngmodule", type: CommonModule }, { 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.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[mat-button], button[mat-raised-button], button[mat-flat-button], button[mat-stroked-button] ", exportAs: ["matButton"] }, { kind: "component", type: i2.MatIconButton, selector: "button[mat-icon-button]", exportAs: ["matButton"] }, { 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: "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"], 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"], outputs: ["openedChange", "opened", "closed", "selectionChange", "valueChange"], exportAs: ["matSelect"] }, { kind: "component", type: i9.MatOption, selector: "mat-option", inputs: ["value", "id", "disabled"], outputs: ["onSelectionChange"], exportAs: ["matOption"] }] });
1461
+ }
1462
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: RuleFormComponent, decorators: [{
1463
+ type: Component,
1464
+ args: [{ selector: 'ngx-rule-form', standalone: true, imports: [
1465
+ CommonModule,
1466
+ FormsModule,
1467
+ MatButtonModule,
1468
+ MatFormFieldModule,
1469
+ MatIconModule,
1470
+ MatInputModule,
1471
+ MatSelectModule,
1472
+ ], template: `
1473
+ <div class="deb-rule-form">
1474
+ <h3 class="deb-rule-form__title">{{ rule.id ? 'Edit Rule' : 'New Form Rule' }}</h3>
1475
+
1476
+ <!-- Trigger Field -->
1477
+ <mat-form-field appearance="outline" subscriptSizing="dynamic" class="deb-full">
1478
+ <mat-label>Trigger Field ID</mat-label>
1479
+ <input matInput [(ngModel)]="rule.fieldId" placeholder="e.g. status" />
1480
+ </mat-form-field>
1481
+
1482
+ <!-- Conditions -->
1483
+ <div class="deb-rule-section">
1484
+ <h4>Conditions</h4>
1485
+ @for (cond of rule.conditions; track $index) {
1486
+ <div class="deb-rule-row">
1487
+ <mat-form-field appearance="outline" subscriptSizing="dynamic">
1488
+ <mat-label>Operator</mat-label>
1489
+ <mat-select [(ngModel)]="cond.operator">
1490
+ @for (op of operators; track op) {
1491
+ <mat-option [value]="op">{{ op }}</mat-option>
1492
+ }
1493
+ </mat-select>
1494
+ </mat-form-field>
1495
+ <mat-form-field appearance="outline" subscriptSizing="dynamic">
1496
+ <mat-label>Value</mat-label>
1497
+ <input matInput [(ngModel)]="cond.value" placeholder="Condition value" />
1498
+ </mat-form-field>
1499
+ <button mat-icon-button color="warn" type="button" (click)="removeCondition($index)">
1500
+ <mat-icon>delete</mat-icon>
1501
+ </button>
1502
+ </div>
1503
+ }
1504
+ <button mat-stroked-button type="button" (click)="addCondition()">
1505
+ <mat-icon>add</mat-icon> Add Condition
1506
+ </button>
1507
+ </div>
1508
+
1509
+ <!-- Action -->
1510
+ <div class="deb-rule-section">
1511
+ <h4>Action</h4>
1512
+ <div class="deb-rule-row">
1513
+ <mat-form-field appearance="outline" subscriptSizing="dynamic">
1514
+ <mat-label>Action Type</mat-label>
1515
+ <mat-select [(ngModel)]="rule.action.type">
1516
+ <mat-option value="visibility">Visibility (Show/Hide)</mat-option>
1517
+ <mat-option value="validation">Validation Message</mat-option>
1518
+ <mat-option value="info">Info Banner</mat-option>
1519
+ </mat-select>
1520
+ </mat-form-field>
1521
+ <mat-form-field appearance="outline" subscriptSizing="dynamic">
1522
+ <mat-label>Value / Message</mat-label>
1523
+ <input matInput [(ngModel)]="rule.action.value" placeholder="false to hide, or message text" />
1524
+ </mat-form-field>
1525
+ </div>
1526
+ </div>
1527
+
1528
+ <div class="deb-rule-form__actions">
1529
+ <button mat-button type="button" (click)="cancel.emit()">Cancel</button>
1530
+ <button mat-raised-button color="primary" type="button" (click)="save.emit(rule)">Save Rule</button>
1531
+ </div>
1532
+ </div>
1533
+ `, styles: [".deb-rule-form{display:flex;flex-direction:column;gap:12px;background:#fafafa;border:1px solid #e0e0e0;border-radius:8px;padding:16px}.deb-rule-form__title{margin:0 0 8px;font-size:16px;font-weight:600}.deb-full{width:100%}.deb-rule-section{display:flex;flex-direction:column;gap:8px}.deb-rule-section h4{margin:0;font-size:13px;font-weight:600;color:#555}.deb-rule-row{display:flex;gap:8px;align-items:center}.deb-rule-row>*{flex:1}.deb-rule-form__actions{display:flex;justify-content:flex-end;gap:8px;margin-top:8px}\n"] }]
1534
+ }], propDecorators: { rule: [{
1535
+ type: Input
1536
+ }], save: [{
1537
+ type: Output
1538
+ }], cancel: [{
1539
+ type: Output
1540
+ }] } });
1541
+
1542
+ /**
1543
+ * FieldRulesListComponent — the rules attached to the selected field: view, reorder,
1544
+ * enable/disable, edit, delete. `RuleFormComponent` authors one rule; this owns the set.
1545
+ */
1546
+ class FieldRulesListComponent {
1547
+ store = inject(BuilderStore);
1548
+ /** The rule currently open in the editor — a new draft or a copy of an existing rule. */
1549
+ editing = signal(null);
1550
+ startCreate() {
1551
+ const fieldId = this.store.selectedFieldId();
1552
+ if (!fieldId)
1553
+ return;
1554
+ this.editing.set({
1555
+ formConfigId: this.store.config().entity || 'form-1',
1556
+ fieldId,
1557
+ conditions: [{ operator: 'EQUAL', compareType: 'value', value: '' }],
1558
+ action: { type: 'visibility', value: false },
1559
+ targets: [{ id: fieldId, type: 'field' }],
1560
+ enabled: true,
1561
+ priority: this.store.rules().length + 1,
1562
+ });
1563
+ }
1564
+ startEdit(rule) {
1565
+ this.editing.set(deepClone(rule));
1566
+ }
1567
+ commit(rule) {
1568
+ if (rule.id)
1569
+ this.store.updateRule(rule.id, rule);
1570
+ else
1571
+ this.store.addRule(rule);
1572
+ this.editing.set(null);
1573
+ }
1574
+ /** One-line human summary: `status EQUAL "archived" → hide`. */
1575
+ summarize(rule) {
1576
+ const cond = rule.conditions
1577
+ .map(c => {
1578
+ const rhs = c.compareType === 'field' ? c.compareToField : JSON.stringify(c.value ?? '');
1579
+ return `${c.operator} ${rhs ?? ''}`.trim();
1580
+ })
1581
+ .join(' AND ');
1582
+ const action = rule.action.type === 'visibility'
1583
+ ? rule.action.value === false || rule.action.value === 'false'
1584
+ ? 'hide'
1585
+ : 'show'
1586
+ : `${rule.action.type}: ${rule.action.value}`;
1587
+ return `${rule.fieldId} ${cond} → ${action}`;
1588
+ }
1589
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: FieldRulesListComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
1590
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "17.3.12", type: FieldRulesListComponent, isStandalone: true, selector: "ngx-field-rules-list", ngImport: i0, template: `
1591
+ <div class="deb-rules">
1592
+ <div class="deb-rules__head">
1593
+ <span class="deb-section-title">Rules</span>
1594
+ <button
1595
+ mat-stroked-button
1596
+ type="button"
1597
+ data-testid="add-rule"
1598
+ [disabled]="!store.selectedFieldId()"
1599
+ (click)="startCreate()"
1600
+ >
1601
+ <mat-icon>add</mat-icon> Rule
1602
+ </button>
1603
+ </div>
1604
+
1605
+ @if (editing(); as draft) {
1606
+ <ngx-rule-form [rule]="draft" (save)="commit($event)" (cancel)="editing.set(null)" />
1607
+ }
1608
+
1609
+ @if (store.rulesForSelectedField().length === 0 && !editing()) {
1610
+ <p class="deb-hint" data-testid="rules-empty">No rules on this field.</p>
1611
+ }
1612
+
1613
+ @for (rule of store.rulesForSelectedField(); track rule.id) {
1614
+ <div class="deb-rule-item" [class.deb-rule-item--off]="!rule.enabled" data-testid="rule-item">
1615
+ <div class="deb-rule-item__body">
1616
+ <span class="deb-rule-item__summary">{{ summarize(rule) }}</span>
1617
+ <span class="deb-rule-item__meta">
1618
+ priority {{ rule.priority }} · {{ rule.targets.length }} target(s)
1619
+ </span>
1620
+ </div>
1621
+ <div class="deb-rule-item__actions">
1622
+ <mat-slide-toggle
1623
+ [checked]="rule.enabled"
1624
+ matTooltip="Enable / disable"
1625
+ (change)="store.toggleRule(rule.id!, $event.checked)"
1626
+ />
1627
+ <button
1628
+ mat-icon-button
1629
+ type="button"
1630
+ matTooltip="Move up"
1631
+ [attr.data-testid]="'rule-up-' + rule.id"
1632
+ (click)="store.moveRule(rule.id!, -1)"
1633
+ >
1634
+ <mat-icon>arrow_upward</mat-icon>
1635
+ </button>
1636
+ <button
1637
+ mat-icon-button
1638
+ type="button"
1639
+ matTooltip="Move down"
1640
+ [attr.data-testid]="'rule-down-' + rule.id"
1641
+ (click)="store.moveRule(rule.id!, 1)"
1642
+ >
1643
+ <mat-icon>arrow_downward</mat-icon>
1644
+ </button>
1645
+ <button
1646
+ mat-icon-button
1647
+ type="button"
1648
+ matTooltip="Edit rule"
1649
+ [attr.data-testid]="'rule-edit-' + rule.id"
1650
+ (click)="startEdit(rule)"
1651
+ >
1652
+ <mat-icon>edit</mat-icon>
1653
+ </button>
1654
+ <button
1655
+ mat-icon-button
1656
+ type="button"
1657
+ color="warn"
1658
+ matTooltip="Delete rule"
1659
+ [attr.data-testid]="'rule-delete-' + rule.id"
1660
+ (click)="store.removeRule(rule.id!)"
1661
+ >
1662
+ <mat-icon>delete</mat-icon>
1663
+ </button>
1664
+ </div>
1665
+ </div>
1666
+ }
1667
+ </div>
1668
+ `, isInline: true, styles: [".deb-rules{display:flex;flex-direction:column;gap:8px}.deb-rules__head{display:flex;align-items:center;justify-content:space-between}.deb-rule-item{display:flex;align-items:center;justify-content:space-between;gap:8px;border:1px solid #e5e7eb;border-radius:8px;padding:8px 10px}.deb-rule-item--off{opacity:.55}.deb-rule-item__body{display:flex;flex-direction:column;min-width:0}.deb-rule-item__summary{font-size:13px;font-weight:600;overflow-wrap:anywhere}.deb-rule-item__meta{font-size:11px;color:#6b7280}.deb-rule-item__actions{display:flex;align-items:center;flex:0 0 auto}\n"], dependencies: [{ kind: "ngmodule", type: MatButtonModule }, { kind: "component", type: i2.MatButton, selector: " button[mat-button], button[mat-raised-button], button[mat-flat-button], button[mat-stroked-button] ", exportAs: ["matButton"] }, { kind: "component", type: i2.MatIconButton, selector: "button[mat-icon-button]", exportAs: ["matButton"] }, { kind: "ngmodule", type: MatIconModule }, { kind: "component", type: i4$1.MatIcon, selector: "mat-icon", inputs: ["color", "inline", "svgIcon", "fontSet", "fontIcon"], exportAs: ["matIcon"] }, { kind: "ngmodule", type: MatSlideToggleModule }, { kind: "component", type: i10.MatSlideToggle, selector: "mat-slide-toggle", inputs: ["name", "id", "labelPosition", "aria-label", "aria-labelledby", "aria-describedby", "required", "color", "disabled", "disableRipple", "tabIndex", "checked", "hideIcon"], 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: RuleFormComponent, selector: "ngx-rule-form", inputs: ["rule"], outputs: ["save", "cancel"] }] });
1669
+ }
1670
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: FieldRulesListComponent, decorators: [{
1671
+ type: Component,
1672
+ args: [{ selector: 'ngx-field-rules-list', standalone: true, imports: [MatButtonModule, MatIconModule, MatSlideToggleModule, MatTooltipModule, RuleFormComponent], template: `
1673
+ <div class="deb-rules">
1674
+ <div class="deb-rules__head">
1675
+ <span class="deb-section-title">Rules</span>
1676
+ <button
1677
+ mat-stroked-button
1678
+ type="button"
1679
+ data-testid="add-rule"
1680
+ [disabled]="!store.selectedFieldId()"
1681
+ (click)="startCreate()"
1682
+ >
1683
+ <mat-icon>add</mat-icon> Rule
1684
+ </button>
1685
+ </div>
1686
+
1687
+ @if (editing(); as draft) {
1688
+ <ngx-rule-form [rule]="draft" (save)="commit($event)" (cancel)="editing.set(null)" />
1689
+ }
1690
+
1691
+ @if (store.rulesForSelectedField().length === 0 && !editing()) {
1692
+ <p class="deb-hint" data-testid="rules-empty">No rules on this field.</p>
1693
+ }
1694
+
1695
+ @for (rule of store.rulesForSelectedField(); track rule.id) {
1696
+ <div class="deb-rule-item" [class.deb-rule-item--off]="!rule.enabled" data-testid="rule-item">
1697
+ <div class="deb-rule-item__body">
1698
+ <span class="deb-rule-item__summary">{{ summarize(rule) }}</span>
1699
+ <span class="deb-rule-item__meta">
1700
+ priority {{ rule.priority }} · {{ rule.targets.length }} target(s)
1701
+ </span>
1702
+ </div>
1703
+ <div class="deb-rule-item__actions">
1704
+ <mat-slide-toggle
1705
+ [checked]="rule.enabled"
1706
+ matTooltip="Enable / disable"
1707
+ (change)="store.toggleRule(rule.id!, $event.checked)"
1708
+ />
1709
+ <button
1710
+ mat-icon-button
1711
+ type="button"
1712
+ matTooltip="Move up"
1713
+ [attr.data-testid]="'rule-up-' + rule.id"
1714
+ (click)="store.moveRule(rule.id!, -1)"
1715
+ >
1716
+ <mat-icon>arrow_upward</mat-icon>
1717
+ </button>
1718
+ <button
1719
+ mat-icon-button
1720
+ type="button"
1721
+ matTooltip="Move down"
1722
+ [attr.data-testid]="'rule-down-' + rule.id"
1723
+ (click)="store.moveRule(rule.id!, 1)"
1724
+ >
1725
+ <mat-icon>arrow_downward</mat-icon>
1726
+ </button>
1727
+ <button
1728
+ mat-icon-button
1729
+ type="button"
1730
+ matTooltip="Edit rule"
1731
+ [attr.data-testid]="'rule-edit-' + rule.id"
1732
+ (click)="startEdit(rule)"
1733
+ >
1734
+ <mat-icon>edit</mat-icon>
1735
+ </button>
1736
+ <button
1737
+ mat-icon-button
1738
+ type="button"
1739
+ color="warn"
1740
+ matTooltip="Delete rule"
1741
+ [attr.data-testid]="'rule-delete-' + rule.id"
1742
+ (click)="store.removeRule(rule.id!)"
1743
+ >
1744
+ <mat-icon>delete</mat-icon>
1745
+ </button>
1746
+ </div>
1747
+ </div>
1748
+ }
1749
+ </div>
1750
+ `, styles: [".deb-rules{display:flex;flex-direction:column;gap:8px}.deb-rules__head{display:flex;align-items:center;justify-content:space-between}.deb-rule-item{display:flex;align-items:center;justify-content:space-between;gap:8px;border:1px solid #e5e7eb;border-radius:8px;padding:8px 10px}.deb-rule-item--off{opacity:.55}.deb-rule-item__body{display:flex;flex-direction:column;min-width:0}.deb-rule-item__summary{font-size:13px;font-weight:600;overflow-wrap:anywhere}.deb-rule-item__meta{font-size:11px;color:#6b7280}.deb-rule-item__actions{display:flex;align-items:center;flex:0 0 auto}\n"] }]
1751
+ }] });
1752
+
1753
+ class ReferencedFieldConfigComponent {
1754
+ store = inject(BuilderStore);
1755
+ configSource = inject(ConfigSourceService, { optional: true });
1756
+ field = computed(() => this.store.selectedField());
1757
+ toggleReferenced(field, enabled) {
1758
+ if (enabled) {
1759
+ this.store.updateField(field.id, { isReferenced: true });
1760
+ }
1761
+ else {
1762
+ this.store.unlinkReferencedField(field.id);
1763
+ }
1764
+ }
1765
+ updateEntityKey(field, key) {
1766
+ const trimmed = key.trim();
1767
+ this.store.updateField(field.id, { referencedEntityKey: trimmed || undefined });
1768
+ this.checkDriftForField(field.id);
1769
+ }
1770
+ updateFieldId(field, fieldId) {
1771
+ const trimmed = fieldId.trim();
1772
+ this.store.updateField(field.id, { referencedFieldId: trimmed || undefined });
1773
+ this.checkDriftForField(field.id);
1774
+ }
1775
+ async syncWithSource(field) {
1776
+ if (!field.referencedEntityKey || !field.referencedFieldId || !this.configSource)
1777
+ return;
1778
+ const sourceConfig = await this.configSource.getConfig(field.referencedEntityKey);
1779
+ if (!sourceConfig)
1780
+ return;
1781
+ const sourceField = this.findField(sourceConfig.tabs ?? [], field.referencedFieldId);
1782
+ if (sourceField) {
1783
+ this.store.syncReferencedField(field.id, sourceField);
1784
+ }
1785
+ }
1786
+ async checkDriftForField(fieldId) {
1787
+ const f = this.store.selectedField();
1788
+ if (!f || !f.isReferenced || !f.referencedEntityKey || !f.referencedFieldId || !this.configSource)
1789
+ return;
1790
+ const sourceConfig = await this.configSource.getConfig(f.referencedEntityKey);
1791
+ if (sourceConfig) {
1792
+ this.store.checkDrift({ [f.referencedEntityKey]: sourceConfig });
1793
+ }
1794
+ }
1795
+ findField(tabs, id) {
1796
+ for (const t of tabs) {
1797
+ for (const f of t.fields ?? []) {
1798
+ if (f.id === id)
1799
+ return f;
1800
+ if (f.children?.length) {
1801
+ const res = this.findFieldInList(f.children, id);
1802
+ if (res)
1803
+ return res;
1804
+ }
1805
+ }
1806
+ }
1807
+ return undefined;
1808
+ }
1809
+ findFieldInList(fields, id) {
1810
+ for (const f of fields) {
1811
+ if (f.id === id)
1812
+ return f;
1813
+ if (f.children?.length) {
1814
+ const res = this.findFieldInList(f.children, id);
1815
+ if (res)
1816
+ return res;
1817
+ }
1818
+ }
1819
+ return undefined;
1820
+ }
1821
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: ReferencedFieldConfigComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
1822
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "17.3.12", type: ReferencedFieldConfigComponent, isStandalone: true, selector: "ngx-referenced-field-config", ngImport: i0, template: `
1823
+ @if (field(); as f) {
1824
+ <div class="deb-referenced-field" style="margin-top: 16px;">
1825
+ <div class="deb-row" style="justify-content: space-between; align-items: center; margin-bottom: 8px;">
1826
+ <span class="deb-section-title" style="margin:0;">Referenced Field</span>
1827
+ <mat-slide-toggle
1828
+ data-testid="toggle-referenced"
1829
+ [ngModel]="f.isReferenced ?? false"
1830
+ (ngModelChange)="toggleReferenced(f, $event)"
1831
+ >
1832
+ Link
1833
+ </mat-slide-toggle>
1834
+ </div>
1835
+
1836
+ @if (f.isReferenced) {
1837
+ <mat-form-field appearance="outline" subscriptSizing="dynamic" class="deb-full" style="margin-bottom: 8px;">
1838
+ <mat-label>Source Entity Key</mat-label>
1839
+ <input
1840
+ matInput
1841
+ data-testid="referenced-entity-key"
1842
+ [ngModel]="f.referencedEntityKey ?? ''"
1843
+ (ngModelChange)="updateEntityKey(f, $event)"
1844
+ placeholder="e.g. individuals"
1845
+ />
1846
+ </mat-form-field>
1847
+
1848
+ <mat-form-field appearance="outline" subscriptSizing="dynamic" class="deb-full" style="margin-bottom: 8px;">
1849
+ <mat-label>Source Field ID</mat-label>
1850
+ <input
1851
+ matInput
1852
+ data-testid="referenced-field-id"
1853
+ [ngModel]="f.referencedFieldId ?? ''"
1854
+ (ngModelChange)="updateFieldId(f, $event)"
1855
+ placeholder="e.g. firstName"
1856
+ />
1857
+ </mat-form-field>
1858
+
1859
+ @if (f.hasDrift) {
1860
+ <div class="deb-drift-banner" data-testid="drift-banner" style="background: #fff3e0; border: 1px solid #ffe0b2; padding: 8px 12px; border-radius: 4px; margin-top: 8px;">
1861
+ <div class="deb-row" style="gap: 8px; align-items: center; color: #e65100; font-weight: 500;">
1862
+ <mat-icon style="font-size: 18px; width:18px; height:18px;">warning</mat-icon>
1863
+ <span>Source field definition has drifted!</span>
1864
+ </div>
1865
+ <p style="margin: 4px 0 8px 0; font-size: 12px; color: #666;">
1866
+ The upstream field configuration in "{{ f.referencedEntityKey }}" has evolved.
1867
+ </p>
1868
+ <button
1869
+ mat-flat-button
1870
+ color="warn"
1871
+ type="button"
1872
+ data-testid="sync-source-btn"
1873
+ (click)="syncWithSource(f)"
1874
+ >
1875
+ Sync with Source
1876
+ </button>
1877
+ </div>
1878
+ }
1879
+ }
1880
+ </div>
1881
+ }
1882
+ `, isInline: true, dependencies: [{ kind: "ngmodule", type: CommonModule }, { 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.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[mat-button], button[mat-raised-button], button[mat-flat-button], button[mat-stroked-button] ", exportAs: ["matButton"] }, { kind: "ngmodule", type: MatDividerModule }, { 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: "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"], exportAs: ["matInput"] }, { kind: "ngmodule", type: MatSlideToggleModule }, { kind: "component", type: i10.MatSlideToggle, selector: "mat-slide-toggle", inputs: ["name", "id", "labelPosition", "aria-label", "aria-labelledby", "aria-describedby", "required", "color", "disabled", "disableRipple", "tabIndex", "checked", "hideIcon"], outputs: ["change", "toggleChange"], exportAs: ["matSlideToggle"] }] });
1883
+ }
1884
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: ReferencedFieldConfigComponent, decorators: [{
1885
+ type: Component,
1886
+ args: [{
1887
+ selector: 'ngx-referenced-field-config',
1888
+ standalone: true,
1889
+ imports: [
1890
+ CommonModule,
1891
+ FormsModule,
1892
+ MatButtonModule,
1893
+ MatDividerModule,
1894
+ MatFormFieldModule,
1895
+ MatIconModule,
1896
+ MatInputModule,
1897
+ MatSlideToggleModule,
1898
+ ],
1899
+ template: `
1900
+ @if (field(); as f) {
1901
+ <div class="deb-referenced-field" style="margin-top: 16px;">
1902
+ <div class="deb-row" style="justify-content: space-between; align-items: center; margin-bottom: 8px;">
1903
+ <span class="deb-section-title" style="margin:0;">Referenced Field</span>
1904
+ <mat-slide-toggle
1905
+ data-testid="toggle-referenced"
1906
+ [ngModel]="f.isReferenced ?? false"
1907
+ (ngModelChange)="toggleReferenced(f, $event)"
1908
+ >
1909
+ Link
1910
+ </mat-slide-toggle>
1911
+ </div>
1912
+
1913
+ @if (f.isReferenced) {
1914
+ <mat-form-field appearance="outline" subscriptSizing="dynamic" class="deb-full" style="margin-bottom: 8px;">
1915
+ <mat-label>Source Entity Key</mat-label>
1916
+ <input
1917
+ matInput
1918
+ data-testid="referenced-entity-key"
1919
+ [ngModel]="f.referencedEntityKey ?? ''"
1920
+ (ngModelChange)="updateEntityKey(f, $event)"
1921
+ placeholder="e.g. individuals"
1922
+ />
1923
+ </mat-form-field>
1924
+
1925
+ <mat-form-field appearance="outline" subscriptSizing="dynamic" class="deb-full" style="margin-bottom: 8px;">
1926
+ <mat-label>Source Field ID</mat-label>
1927
+ <input
1928
+ matInput
1929
+ data-testid="referenced-field-id"
1930
+ [ngModel]="f.referencedFieldId ?? ''"
1931
+ (ngModelChange)="updateFieldId(f, $event)"
1932
+ placeholder="e.g. firstName"
1933
+ />
1934
+ </mat-form-field>
1935
+
1936
+ @if (f.hasDrift) {
1937
+ <div class="deb-drift-banner" data-testid="drift-banner" style="background: #fff3e0; border: 1px solid #ffe0b2; padding: 8px 12px; border-radius: 4px; margin-top: 8px;">
1938
+ <div class="deb-row" style="gap: 8px; align-items: center; color: #e65100; font-weight: 500;">
1939
+ <mat-icon style="font-size: 18px; width:18px; height:18px;">warning</mat-icon>
1940
+ <span>Source field definition has drifted!</span>
1941
+ </div>
1942
+ <p style="margin: 4px 0 8px 0; font-size: 12px; color: #666;">
1943
+ The upstream field configuration in "{{ f.referencedEntityKey }}" has evolved.
1944
+ </p>
1945
+ <button
1946
+ mat-flat-button
1947
+ color="warn"
1948
+ type="button"
1949
+ data-testid="sync-source-btn"
1950
+ (click)="syncWithSource(f)"
1951
+ >
1952
+ Sync with Source
1953
+ </button>
1954
+ </div>
1955
+ }
1956
+ }
1957
+ </div>
1958
+ }
1959
+ `,
1960
+ }]
1961
+ }] });
1962
+
1963
+ /**
1964
+ * FieldInspectorComponent — edits every property of the currently selected field.
1965
+ */
1966
+ class FieldInspectorComponent {
1967
+ store = inject(BuilderStore);
1968
+ field = this.store.selectedField;
1969
+ meta = computed(() => {
1970
+ const f = this.field();
1971
+ return f ? getFieldTypeMeta(f.type) : undefined;
1972
+ });
1973
+ lang() {
1974
+ return this.store.activeLanguage();
1975
+ }
1976
+ toNum(value) {
1977
+ if (value === '' || value === null || value === undefined)
1978
+ return null;
1979
+ const n = Number(value);
1980
+ return Number.isNaN(n) ? null : n;
1981
+ }
1982
+ labelValue(field) {
1983
+ return resolveLabel(field.label, this.lang());
1984
+ }
1985
+ placeholderValue(field) {
1986
+ return resolveLabel(field.placeholder, this.lang());
1987
+ }
1988
+ optionLabel(option) {
1989
+ return resolveOptionLabel(option, this.lang());
1990
+ }
1991
+ // ─── showWhen editing ───────────────────────────────────────────────────────
1992
+ showWhenEntries(field) {
1993
+ return Object.entries(field.showWhen ?? {}).map(([key, value]) => ({
1994
+ key,
1995
+ display: this.stringifyShowWhen(value),
1996
+ }));
1997
+ }
1998
+ addShowWhen(field) {
1999
+ const next = { ...(field.showWhen ?? {}) };
2000
+ let key = 'field';
2001
+ let n = 1;
2002
+ while (key in next)
2003
+ key = `field_${++n}`;
2004
+ next[key] = true;
2005
+ this.store.setShowWhen(field.id, next);
2006
+ }
2007
+ renameShowWhen(field, oldKey, newKey) {
2008
+ const trimmed = newKey.trim();
2009
+ if (!trimmed || trimmed === oldKey)
2010
+ return;
2011
+ const current = field.showWhen ?? {};
2012
+ if (trimmed in current)
2013
+ return;
2014
+ const next = {};
2015
+ // Rebuild in order so the row does not jump while the user is typing.
2016
+ for (const [k, v] of Object.entries(current))
2017
+ next[k === oldKey ? trimmed : k] = v;
2018
+ this.store.setShowWhen(field.id, next);
2019
+ }
2020
+ setShowWhenValue(field, key, raw) {
2021
+ this.store.setShowWhen(field.id, { ...(field.showWhen ?? {}), [key]: this.parseShowWhen(raw) });
2022
+ }
2023
+ removeShowWhen(field, key) {
2024
+ const next = { ...(field.showWhen ?? {}) };
2025
+ delete next[key];
2026
+ this.store.setShowWhen(field.id, next);
2027
+ }
2028
+ /**
2029
+ * `showWhen` compares with `!==`, so the authored type has to survive the round trip:
2030
+ * "true"/"false" become booleans and numeric text becomes a number.
2031
+ */
2032
+ parseShowWhen(raw) {
2033
+ const trimmed = raw.trim();
2034
+ if (trimmed === 'true')
2035
+ return true;
2036
+ if (trimmed === 'false')
2037
+ return false;
2038
+ if (trimmed === 'null')
2039
+ return null;
2040
+ if (trimmed !== '' && !Number.isNaN(Number(trimmed)))
2041
+ return Number(trimmed);
2042
+ return raw;
2043
+ }
2044
+ stringifyShowWhen(value) {
2045
+ if (value === null)
2046
+ return 'null';
2047
+ if (typeof value === 'object')
2048
+ return JSON.stringify(value);
2049
+ return String(value);
2050
+ }
2051
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: FieldInspectorComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
2052
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "17.3.12", type: FieldInspectorComponent, isStandalone: true, selector: "ngx-field-inspector", ngImport: i0, template: "@if (field(); as f) {\r\n <div class=\"deb-inspector\">\r\n <!-- Identity -->\r\n <div class=\"deb-row\">\r\n <mat-form-field appearance=\"outline\" subscriptSizing=\"dynamic\">\r\n <mat-label>Field id</mat-label>\r\n <input matInput data-testid=\"field-id\" [value]=\"f.id\" disabled />\r\n @if (store.hasManualId(f.id)) {\r\n <mat-hint>The stored data key for this saved field \u2014 fixed.</mat-hint>\r\n } @else {\r\n <mat-hint>Derived from the label. Used as the data key.</mat-hint>\r\n }\r\n </mat-form-field>\r\n <span class=\"deb-chip\">{{ meta()?.label || f.type }}</span>\r\n </div>\r\n\r\n <!-- Localised text (active language) -->\r\n <mat-form-field appearance=\"outline\" subscriptSizing=\"dynamic\" class=\"deb-full\">\r\n <mat-label>Label ({{ lang() }})</mat-label>\r\n <input\r\n matInput\r\n [ngModel]=\"labelValue(f)\"\r\n (ngModelChange)=\"store.setFieldLabel(f.id, lang(), $event)\"\r\n />\r\n </mat-form-field>\r\n\r\n @if (meta()?.supportsPlaceholder) {\r\n <mat-form-field appearance=\"outline\" subscriptSizing=\"dynamic\" class=\"deb-full\">\r\n <mat-label>Placeholder ({{ lang() }})</mat-label>\r\n <input\r\n matInput\r\n [ngModel]=\"placeholderValue(f)\"\r\n (ngModelChange)=\"store.setFieldPlaceholder(f.id, lang(), $event)\"\r\n />\r\n </mat-form-field>\r\n }\r\n\r\n @if (meta()?.supportsDefaultValue) {\r\n <mat-form-field appearance=\"outline\" subscriptSizing=\"dynamic\" class=\"deb-full\">\r\n <mat-label>Default value</mat-label>\r\n <input\r\n matInput\r\n [ngModel]=\"f.defaultValue ?? ''\"\r\n (ngModelChange)=\"store.updateField(f.id, { defaultValue: $event })\"\r\n />\r\n </mat-form-field>\r\n }\r\n\r\n <!-- Entity reference, cascade, and autoPatch -->\r\n @if (meta()?.isEntityRef) {\r\n <mat-divider></mat-divider>\r\n <ngx-entity-reference-config />\r\n }\r\n\r\n <mat-divider></mat-divider>\r\n\r\n <!-- Validators -->\r\n @if (meta(); as m) {\r\n @if (m.flagValidators.length || m.paramValidators.length) {\r\n <span class=\"deb-section-title\">Validation</span>\r\n <div class=\"deb-toggles\">\r\n @for (v of m.flagValidators; track v) {\r\n <mat-checkbox\r\n [ngModel]=\"store.hasFlagValidator(f, v)\"\r\n (ngModelChange)=\"store.toggleFlagValidator(f.id, v, $event)\"\r\n >{{ v }}</mat-checkbox>\r\n }\r\n </div>\r\n @for (v of m.paramValidators; track v) {\r\n <mat-form-field appearance=\"outline\" subscriptSizing=\"dynamic\" class=\"deb-full\">\r\n <mat-label>{{ v }}</mat-label>\r\n <input\r\n matInput\r\n type=\"number\"\r\n [ngModel]=\"store.getParamValidator(f, v)\"\r\n (ngModelChange)=\"store.setParamValidator(f.id, v, toNum($event))\"\r\n />\r\n </mat-form-field>\r\n }\r\n }\r\n }\r\n\r\n <!-- Options (dropdown / radio / multiSelect) -->\r\n @if (meta()?.hasOptions) {\r\n <mat-divider></mat-divider>\r\n <span class=\"deb-section-title\">Data source</span>\r\n <mat-form-field appearance=\"outline\" subscriptSizing=\"dynamic\" class=\"deb-full\">\r\n <mat-label>Options come from</mat-label>\r\n <mat-select\r\n data-testid=\"data-source\"\r\n [ngModel]=\"store.fieldDataSource(f)\"\r\n (ngModelChange)=\"store.setFieldDataSource(f.id, $event)\"\r\n >\r\n <mat-option value=\"none\">Nothing yet</mat-option>\r\n <mat-option value=\"manual\">Options authored here</mat-option>\r\n <mat-option value=\"lookup\">A named list</mat-option>\r\n </mat-select>\r\n </mat-form-field>\r\n }\r\n\r\n <!-- Named lookup list -->\r\n @if (meta()?.hasOptions && store.fieldDataSource(f) === 'lookup') {\r\n <mat-form-field appearance=\"outline\" subscriptSizing=\"dynamic\" class=\"deb-full\">\r\n <mat-label>List name</mat-label>\r\n <input\r\n matInput\r\n data-testid=\"list-name\"\r\n [ngModel]=\"f.listName ?? ''\"\r\n (ngModelChange)=\"store.setListName(f.id, $event)\"\r\n />\r\n </mat-form-field>\r\n <p class=\"deb-hint\">\r\n The consuming app resolves this name through its lookup registry. Options are not\r\n authored here.\r\n </p>\r\n }\r\n\r\n <!-- Inline options -->\r\n @if (meta()?.hasOptions && store.fieldDataSource(f) !== 'lookup') {\r\n <div class=\"deb-row\" style=\"justify-content: space-between;\">\r\n <span class=\"deb-section-title\">Options</span>\r\n <button mat-stroked-button type=\"button\" (click)=\"store.addOption(f.id)\">\r\n <mat-icon>add</mat-icon> Option\r\n </button>\r\n </div>\r\n @for (opt of f.options ?? []; track $index) {\r\n <div class=\"deb-option-row\" data-testid=\"option-row\">\r\n <!-- One input: the displayed text is the stored value. -->\r\n <mat-form-field appearance=\"outline\" subscriptSizing=\"dynamic\" class=\"deb-full\">\r\n <mat-label>Option ({{ lang() }})</mat-label>\r\n <input\r\n matInput\r\n [attr.data-testid]=\"'option-' + $index\"\r\n [ngModel]=\"optionLabel(opt)\"\r\n (ngModelChange)=\"store.setOptionLabel(f.id, $index, lang(), $event)\"\r\n />\r\n </mat-form-field>\r\n <button mat-icon-button type=\"button\" color=\"warn\"\r\n (click)=\"store.removeOption(f.id, $index)\" matTooltip=\"Remove option\">\r\n <mat-icon>delete</mat-icon>\r\n </button>\r\n </div>\r\n }\r\n @if (!(f.options ?? []).length) {\r\n <p class=\"deb-hint\">No options yet \u2014 add at least one.</p>\r\n }\r\n }\r\n\r\n <mat-divider></mat-divider>\r\n\r\n <!-- Flags -->\r\n <span class=\"deb-section-title\">Display</span>\r\n <div class=\"deb-toggles\">\r\n <mat-slide-toggle\r\n [ngModel]=\"f.visibility ?? true\"\r\n (ngModelChange)=\"store.updateField(f.id, { visibility: $event })\"\r\n >Visible</mat-slide-toggle>\r\n <mat-slide-toggle\r\n [ngModel]=\"f.table?.visible ?? false\"\r\n (ngModelChange)=\"store.updateField(f.id, { table: { visible: $event } })\"\r\n >Table column</mat-slide-toggle>\r\n <mat-slide-toggle\r\n [ngModel]=\"f.readonly ?? false\"\r\n (ngModelChange)=\"store.updateField(f.id, { readonly: $event })\"\r\n >Read-only</mat-slide-toggle>\r\n <mat-slide-toggle\r\n [ngModel]=\"f.disabled ?? false\"\r\n (ngModelChange)=\"store.updateField(f.id, { disabled: $event })\"\r\n >Disabled</mat-slide-toggle>\r\n <mat-slide-toggle\r\n [ngModel]=\"f.maskData ?? false\"\r\n (ngModelChange)=\"store.updateField(f.id, { maskData: $event })\"\r\n >Mask data (RBAC)</mat-slide-toggle>\r\n <mat-slide-toggle\r\n data-testid=\"toggle-critical\"\r\n [ngModel]=\"f.criticalField ?? false\"\r\n (ngModelChange)=\"store.updateField(f.id, { criticalField: $event })\"\r\n matTooltip=\"Renders locked; editing it announces a change against the session baseline.\"\r\n >Critical field</mat-slide-toggle>\r\n <mat-slide-toggle\r\n [ngModel]=\"f.showOnMinimize ?? false\"\r\n (ngModelChange)=\"store.updateField(f.id, { showOnMinimize: $event })\"\r\n matTooltip=\"Shown in the record editor's summary panel.\"\r\n >Show in summary</mat-slide-toggle>\r\n </div>\r\n\r\n <mat-divider></mat-divider>\r\n\r\n <!-- Conditional visibility (static showWhen) -->\r\n <div class=\"deb-row deb-row--split\">\r\n <span class=\"deb-section-title\">Show when</span>\r\n <button mat-stroked-button type=\"button\" data-testid=\"add-show-when\" (click)=\"addShowWhen(f)\">\r\n <mat-icon>add</mat-icon> Condition\r\n </button>\r\n </div>\r\n @for (entry of showWhenEntries(f); track entry.key) {\r\n <div class=\"deb-option-row\" data-testid=\"option-row\">\r\n <mat-form-field appearance=\"outline\" subscriptSizing=\"dynamic\">\r\n <mat-label>Field id</mat-label>\r\n <input matInput [ngModel]=\"entry.key\" (ngModelChange)=\"renameShowWhen(f, entry.key, $event)\" />\r\n </mat-form-field>\r\n <mat-form-field appearance=\"outline\" subscriptSizing=\"dynamic\">\r\n <mat-label>Equals</mat-label>\r\n <input matInput [ngModel]=\"entry.display\" (ngModelChange)=\"setShowWhenValue(f, entry.key, $event)\" />\r\n </mat-form-field>\r\n <button mat-icon-button type=\"button\" color=\"warn\" matTooltip=\"Remove condition\"\r\n (click)=\"removeShowWhen(f, entry.key)\">\r\n <mat-icon>delete</mat-icon>\r\n </button>\r\n </div>\r\n }\r\n @if (!showWhenEntries(f).length) {\r\n <p class=\"deb-hint\">Always visible. Add a condition to show this field only for certain values.</p>\r\n }\r\n\r\n <!-- patchOnTrue (boolean/checkbox only) -->\r\n @if (f.type === 'boolean' || f.type === 'checkbox') {\r\n <mat-divider></mat-divider>\r\n <div class=\"deb-row deb-row--split\">\r\n <span class=\"deb-section-title\">Copy on true</span>\r\n <button mat-stroked-button type=\"button\" data-testid=\"add-patch-on-true\"\r\n (click)=\"store.addPatchOnTrueMapping(f.id)\">\r\n <mat-icon>add</mat-icon> Mapping\r\n </button>\r\n </div>\r\n @for (mapping of f.patchOnTrue ?? []; track $index) {\r\n <div class=\"deb-option-row\" data-testid=\"option-row\">\r\n <mat-form-field appearance=\"outline\" subscriptSizing=\"dynamic\">\r\n <mat-label>From field</mat-label>\r\n <input matInput [ngModel]=\"mapping.from\"\r\n (ngModelChange)=\"store.updatePatchOnTrueMapping(f.id, $index, { from: $event })\" />\r\n </mat-form-field>\r\n <mat-form-field appearance=\"outline\" subscriptSizing=\"dynamic\">\r\n <mat-label>To field</mat-label>\r\n <input matInput [ngModel]=\"mapping.to\"\r\n (ngModelChange)=\"store.updatePatchOnTrueMapping(f.id, $index, { to: $event })\" />\r\n </mat-form-field>\r\n <button mat-icon-button type=\"button\" color=\"warn\" matTooltip=\"Remove mapping\"\r\n (click)=\"store.removePatchOnTrueMapping(f.id, $index)\">\r\n <mat-icon>delete</mat-icon>\r\n </button>\r\n </div>\r\n }\r\n @if (!(f.patchOnTrue ?? []).length) {\r\n <p class=\"deb-hint\">When this flips to true, copy one field's value into another.</p>\r\n }\r\n }\r\n\r\n <mat-divider></mat-divider>\r\n\r\n <!-- Rules attached to this field -->\r\n <ngx-field-rules-list />\r\n\r\n <mat-divider></mat-divider>\r\n\r\n <!-- Cross-entity field reference & drift management (Phase 8) -->\r\n <ngx-referenced-field-config />\r\n </div>\r\n} @else {\r\n <div class=\"deb-inspector__empty\">\r\n <mat-icon>touch_app</mat-icon>\r\n <p>Select a field to edit its properties.</p>\r\n </div>\r\n}\r\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[mat-button], button[mat-raised-button], button[mat-flat-button], button[mat-stroked-button] ", exportAs: ["matButton"] }, { kind: "component", type: i2.MatIconButton, selector: "button[mat-icon-button]", exportAs: ["matButton"] }, { kind: "ngmodule", type: MatCheckboxModule }, { kind: "component", type: i3$1.MatCheckbox, selector: "mat-checkbox", inputs: ["aria-label", "aria-labelledby", "aria-describedby", "id", "required", "labelPosition", "name", "value", "disableRipple", "tabIndex", "color", "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"], 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"], outputs: ["openedChange", "opened", "closed", "selectionChange", "valueChange"], exportAs: ["matSelect"] }, { kind: "component", type: i9.MatOption, selector: "mat-option", inputs: ["value", "id", "disabled"], outputs: ["onSelectionChange"], exportAs: ["matOption"] }, { kind: "ngmodule", type: MatSlideToggleModule }, { kind: "component", type: i10.MatSlideToggle, selector: "mat-slide-toggle", inputs: ["name", "id", "labelPosition", "aria-label", "aria-labelledby", "aria-describedby", "required", "color", "disabled", "disableRipple", "tabIndex", "checked", "hideIcon"], 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" }] });
2053
+ }
2054
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: FieldInspectorComponent, decorators: [{
2055
+ type: Component,
2056
+ args: [{ selector: 'ngx-field-inspector', standalone: true, imports: [
2057
+ FormsModule,
2058
+ MatButtonModule,
2059
+ MatCheckboxModule,
2060
+ MatDividerModule,
2061
+ MatFormFieldModule,
2062
+ MatIconModule,
2063
+ MatInputModule,
2064
+ MatSelectModule,
2065
+ MatSlideToggleModule,
2066
+ MatTooltipModule,
2067
+ EntityReferenceConfigComponent,
2068
+ FieldRulesListComponent,
2069
+ ReferencedFieldConfigComponent,
2070
+ ], template: "@if (field(); as f) {\r\n <div class=\"deb-inspector\">\r\n <!-- Identity -->\r\n <div class=\"deb-row\">\r\n <mat-form-field appearance=\"outline\" subscriptSizing=\"dynamic\">\r\n <mat-label>Field id</mat-label>\r\n <input matInput data-testid=\"field-id\" [value]=\"f.id\" disabled />\r\n @if (store.hasManualId(f.id)) {\r\n <mat-hint>The stored data key for this saved field \u2014 fixed.</mat-hint>\r\n } @else {\r\n <mat-hint>Derived from the label. Used as the data key.</mat-hint>\r\n }\r\n </mat-form-field>\r\n <span class=\"deb-chip\">{{ meta()?.label || f.type }}</span>\r\n </div>\r\n\r\n <!-- Localised text (active language) -->\r\n <mat-form-field appearance=\"outline\" subscriptSizing=\"dynamic\" class=\"deb-full\">\r\n <mat-label>Label ({{ lang() }})</mat-label>\r\n <input\r\n matInput\r\n [ngModel]=\"labelValue(f)\"\r\n (ngModelChange)=\"store.setFieldLabel(f.id, lang(), $event)\"\r\n />\r\n </mat-form-field>\r\n\r\n @if (meta()?.supportsPlaceholder) {\r\n <mat-form-field appearance=\"outline\" subscriptSizing=\"dynamic\" class=\"deb-full\">\r\n <mat-label>Placeholder ({{ lang() }})</mat-label>\r\n <input\r\n matInput\r\n [ngModel]=\"placeholderValue(f)\"\r\n (ngModelChange)=\"store.setFieldPlaceholder(f.id, lang(), $event)\"\r\n />\r\n </mat-form-field>\r\n }\r\n\r\n @if (meta()?.supportsDefaultValue) {\r\n <mat-form-field appearance=\"outline\" subscriptSizing=\"dynamic\" class=\"deb-full\">\r\n <mat-label>Default value</mat-label>\r\n <input\r\n matInput\r\n [ngModel]=\"f.defaultValue ?? ''\"\r\n (ngModelChange)=\"store.updateField(f.id, { defaultValue: $event })\"\r\n />\r\n </mat-form-field>\r\n }\r\n\r\n <!-- Entity reference, cascade, and autoPatch -->\r\n @if (meta()?.isEntityRef) {\r\n <mat-divider></mat-divider>\r\n <ngx-entity-reference-config />\r\n }\r\n\r\n <mat-divider></mat-divider>\r\n\r\n <!-- Validators -->\r\n @if (meta(); as m) {\r\n @if (m.flagValidators.length || m.paramValidators.length) {\r\n <span class=\"deb-section-title\">Validation</span>\r\n <div class=\"deb-toggles\">\r\n @for (v of m.flagValidators; track v) {\r\n <mat-checkbox\r\n [ngModel]=\"store.hasFlagValidator(f, v)\"\r\n (ngModelChange)=\"store.toggleFlagValidator(f.id, v, $event)\"\r\n >{{ v }}</mat-checkbox>\r\n }\r\n </div>\r\n @for (v of m.paramValidators; track v) {\r\n <mat-form-field appearance=\"outline\" subscriptSizing=\"dynamic\" class=\"deb-full\">\r\n <mat-label>{{ v }}</mat-label>\r\n <input\r\n matInput\r\n type=\"number\"\r\n [ngModel]=\"store.getParamValidator(f, v)\"\r\n (ngModelChange)=\"store.setParamValidator(f.id, v, toNum($event))\"\r\n />\r\n </mat-form-field>\r\n }\r\n }\r\n }\r\n\r\n <!-- Options (dropdown / radio / multiSelect) -->\r\n @if (meta()?.hasOptions) {\r\n <mat-divider></mat-divider>\r\n <span class=\"deb-section-title\">Data source</span>\r\n <mat-form-field appearance=\"outline\" subscriptSizing=\"dynamic\" class=\"deb-full\">\r\n <mat-label>Options come from</mat-label>\r\n <mat-select\r\n data-testid=\"data-source\"\r\n [ngModel]=\"store.fieldDataSource(f)\"\r\n (ngModelChange)=\"store.setFieldDataSource(f.id, $event)\"\r\n >\r\n <mat-option value=\"none\">Nothing yet</mat-option>\r\n <mat-option value=\"manual\">Options authored here</mat-option>\r\n <mat-option value=\"lookup\">A named list</mat-option>\r\n </mat-select>\r\n </mat-form-field>\r\n }\r\n\r\n <!-- Named lookup list -->\r\n @if (meta()?.hasOptions && store.fieldDataSource(f) === 'lookup') {\r\n <mat-form-field appearance=\"outline\" subscriptSizing=\"dynamic\" class=\"deb-full\">\r\n <mat-label>List name</mat-label>\r\n <input\r\n matInput\r\n data-testid=\"list-name\"\r\n [ngModel]=\"f.listName ?? ''\"\r\n (ngModelChange)=\"store.setListName(f.id, $event)\"\r\n />\r\n </mat-form-field>\r\n <p class=\"deb-hint\">\r\n The consuming app resolves this name through its lookup registry. Options are not\r\n authored here.\r\n </p>\r\n }\r\n\r\n <!-- Inline options -->\r\n @if (meta()?.hasOptions && store.fieldDataSource(f) !== 'lookup') {\r\n <div class=\"deb-row\" style=\"justify-content: space-between;\">\r\n <span class=\"deb-section-title\">Options</span>\r\n <button mat-stroked-button type=\"button\" (click)=\"store.addOption(f.id)\">\r\n <mat-icon>add</mat-icon> Option\r\n </button>\r\n </div>\r\n @for (opt of f.options ?? []; track $index) {\r\n <div class=\"deb-option-row\" data-testid=\"option-row\">\r\n <!-- One input: the displayed text is the stored value. -->\r\n <mat-form-field appearance=\"outline\" subscriptSizing=\"dynamic\" class=\"deb-full\">\r\n <mat-label>Option ({{ lang() }})</mat-label>\r\n <input\r\n matInput\r\n [attr.data-testid]=\"'option-' + $index\"\r\n [ngModel]=\"optionLabel(opt)\"\r\n (ngModelChange)=\"store.setOptionLabel(f.id, $index, lang(), $event)\"\r\n />\r\n </mat-form-field>\r\n <button mat-icon-button type=\"button\" color=\"warn\"\r\n (click)=\"store.removeOption(f.id, $index)\" matTooltip=\"Remove option\">\r\n <mat-icon>delete</mat-icon>\r\n </button>\r\n </div>\r\n }\r\n @if (!(f.options ?? []).length) {\r\n <p class=\"deb-hint\">No options yet \u2014 add at least one.</p>\r\n }\r\n }\r\n\r\n <mat-divider></mat-divider>\r\n\r\n <!-- Flags -->\r\n <span class=\"deb-section-title\">Display</span>\r\n <div class=\"deb-toggles\">\r\n <mat-slide-toggle\r\n [ngModel]=\"f.visibility ?? true\"\r\n (ngModelChange)=\"store.updateField(f.id, { visibility: $event })\"\r\n >Visible</mat-slide-toggle>\r\n <mat-slide-toggle\r\n [ngModel]=\"f.table?.visible ?? false\"\r\n (ngModelChange)=\"store.updateField(f.id, { table: { visible: $event } })\"\r\n >Table column</mat-slide-toggle>\r\n <mat-slide-toggle\r\n [ngModel]=\"f.readonly ?? false\"\r\n (ngModelChange)=\"store.updateField(f.id, { readonly: $event })\"\r\n >Read-only</mat-slide-toggle>\r\n <mat-slide-toggle\r\n [ngModel]=\"f.disabled ?? false\"\r\n (ngModelChange)=\"store.updateField(f.id, { disabled: $event })\"\r\n >Disabled</mat-slide-toggle>\r\n <mat-slide-toggle\r\n [ngModel]=\"f.maskData ?? false\"\r\n (ngModelChange)=\"store.updateField(f.id, { maskData: $event })\"\r\n >Mask data (RBAC)</mat-slide-toggle>\r\n <mat-slide-toggle\r\n data-testid=\"toggle-critical\"\r\n [ngModel]=\"f.criticalField ?? false\"\r\n (ngModelChange)=\"store.updateField(f.id, { criticalField: $event })\"\r\n matTooltip=\"Renders locked; editing it announces a change against the session baseline.\"\r\n >Critical field</mat-slide-toggle>\r\n <mat-slide-toggle\r\n [ngModel]=\"f.showOnMinimize ?? false\"\r\n (ngModelChange)=\"store.updateField(f.id, { showOnMinimize: $event })\"\r\n matTooltip=\"Shown in the record editor's summary panel.\"\r\n >Show in summary</mat-slide-toggle>\r\n </div>\r\n\r\n <mat-divider></mat-divider>\r\n\r\n <!-- Conditional visibility (static showWhen) -->\r\n <div class=\"deb-row deb-row--split\">\r\n <span class=\"deb-section-title\">Show when</span>\r\n <button mat-stroked-button type=\"button\" data-testid=\"add-show-when\" (click)=\"addShowWhen(f)\">\r\n <mat-icon>add</mat-icon> Condition\r\n </button>\r\n </div>\r\n @for (entry of showWhenEntries(f); track entry.key) {\r\n <div class=\"deb-option-row\" data-testid=\"option-row\">\r\n <mat-form-field appearance=\"outline\" subscriptSizing=\"dynamic\">\r\n <mat-label>Field id</mat-label>\r\n <input matInput [ngModel]=\"entry.key\" (ngModelChange)=\"renameShowWhen(f, entry.key, $event)\" />\r\n </mat-form-field>\r\n <mat-form-field appearance=\"outline\" subscriptSizing=\"dynamic\">\r\n <mat-label>Equals</mat-label>\r\n <input matInput [ngModel]=\"entry.display\" (ngModelChange)=\"setShowWhenValue(f, entry.key, $event)\" />\r\n </mat-form-field>\r\n <button mat-icon-button type=\"button\" color=\"warn\" matTooltip=\"Remove condition\"\r\n (click)=\"removeShowWhen(f, entry.key)\">\r\n <mat-icon>delete</mat-icon>\r\n </button>\r\n </div>\r\n }\r\n @if (!showWhenEntries(f).length) {\r\n <p class=\"deb-hint\">Always visible. Add a condition to show this field only for certain values.</p>\r\n }\r\n\r\n <!-- patchOnTrue (boolean/checkbox only) -->\r\n @if (f.type === 'boolean' || f.type === 'checkbox') {\r\n <mat-divider></mat-divider>\r\n <div class=\"deb-row deb-row--split\">\r\n <span class=\"deb-section-title\">Copy on true</span>\r\n <button mat-stroked-button type=\"button\" data-testid=\"add-patch-on-true\"\r\n (click)=\"store.addPatchOnTrueMapping(f.id)\">\r\n <mat-icon>add</mat-icon> Mapping\r\n </button>\r\n </div>\r\n @for (mapping of f.patchOnTrue ?? []; track $index) {\r\n <div class=\"deb-option-row\" data-testid=\"option-row\">\r\n <mat-form-field appearance=\"outline\" subscriptSizing=\"dynamic\">\r\n <mat-label>From field</mat-label>\r\n <input matInput [ngModel]=\"mapping.from\"\r\n (ngModelChange)=\"store.updatePatchOnTrueMapping(f.id, $index, { from: $event })\" />\r\n </mat-form-field>\r\n <mat-form-field appearance=\"outline\" subscriptSizing=\"dynamic\">\r\n <mat-label>To field</mat-label>\r\n <input matInput [ngModel]=\"mapping.to\"\r\n (ngModelChange)=\"store.updatePatchOnTrueMapping(f.id, $index, { to: $event })\" />\r\n </mat-form-field>\r\n <button mat-icon-button type=\"button\" color=\"warn\" matTooltip=\"Remove mapping\"\r\n (click)=\"store.removePatchOnTrueMapping(f.id, $index)\">\r\n <mat-icon>delete</mat-icon>\r\n </button>\r\n </div>\r\n }\r\n @if (!(f.patchOnTrue ?? []).length) {\r\n <p class=\"deb-hint\">When this flips to true, copy one field's value into another.</p>\r\n }\r\n }\r\n\r\n <mat-divider></mat-divider>\r\n\r\n <!-- Rules attached to this field -->\r\n <ngx-field-rules-list />\r\n\r\n <mat-divider></mat-divider>\r\n\r\n <!-- Cross-entity field reference & drift management (Phase 8) -->\r\n <ngx-referenced-field-config />\r\n </div>\r\n} @else {\r\n <div class=\"deb-inspector__empty\">\r\n <mat-icon>touch_app</mat-icon>\r\n <p>Select a field to edit its properties.</p>\r\n </div>\r\n}\r\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"] }]
2071
+ }] });
2072
+
2073
+ /**
2074
+ * FieldPaletteComponent — the list of buildable field types (Angular Material).
2075
+ */
2076
+ class FieldPaletteComponent {
2077
+ catalog = FIELD_TYPE_CATALOG;
2078
+ pick = new EventEmitter();
2079
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: FieldPaletteComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
2080
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "17.3.12", type: FieldPaletteComponent, isStandalone: true, selector: "ngx-field-palette", outputs: { pick: "pick" }, ngImport: i0, template: `
2081
+ <div class="deb-palette">
2082
+ @for (meta of catalog; track meta.type) {
2083
+ <button
2084
+ mat-stroked-button
2085
+ type="button"
2086
+ class="deb-palette__item"
2087
+ [attr.data-testid]="'palette-' + meta.type"
2088
+ [matTooltip]="meta.description"
2089
+ (click)="pick.emit(meta.type)"
2090
+ >
2091
+ <mat-icon>{{ meta.icon }}</mat-icon>
2092
+ <span>{{ meta.label }}</span>
2093
+ </button>
2094
+ }
2095
+ </div>
2096
+ `, isInline: true, styles: [".deb-palette{display:grid;grid-template-columns:1fr 1fr;gap:8px}.deb-palette__item{justify-content:flex-start;overflow:hidden;border-radius:8px!important;border:1px solid var(--border, #e2e8f0)!important;padding:8px 12px!important;font-family:var(--font-heading, inherit)!important;font-weight:600!important;font-size:13px!important;transition:all .18s cubic-bezier(.16,1,.3,1)!important;background:#fff!important}.deb-palette__item:hover{border-color:#6366f1!important;background:#f5f3ff!important;color:#4f46e5!important;transform:translateY(-1px);box-shadow:0 4px 12px #6366f126!important}.deb-palette__item mat-icon{color:#6366f1;margin-right:6px;font-size:18px;width:18px;height:18px}.deb-palette__item span{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}\n"], dependencies: [{ kind: "ngmodule", type: MatButtonModule }, { kind: "component", type: i2.MatButton, selector: " button[mat-button], button[mat-raised-button], button[mat-flat-button], button[mat-stroked-button] ", exportAs: ["matButton"] }, { kind: "ngmodule", type: MatIconModule }, { kind: "component", type: i4$1.MatIcon, selector: "mat-icon", inputs: ["color", "inline", "svgIcon", "fontSet", "fontIcon"], exportAs: ["matIcon"] }, { kind: "ngmodule", type: MatTooltipModule }, { kind: "directive", type: i4$2.MatTooltip, selector: "[matTooltip]", inputs: ["matTooltipPosition", "matTooltipPositionAtOrigin", "matTooltipDisabled", "matTooltipShowDelay", "matTooltipHideDelay", "matTooltipTouchGestures", "matTooltip", "matTooltipClass"], exportAs: ["matTooltip"] }] });
2097
+ }
2098
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: FieldPaletteComponent, decorators: [{
2099
+ type: Component,
2100
+ args: [{ selector: 'ngx-field-palette', standalone: true, imports: [MatButtonModule, MatIconModule, MatTooltipModule], template: `
2101
+ <div class="deb-palette">
2102
+ @for (meta of catalog; track meta.type) {
2103
+ <button
2104
+ mat-stroked-button
2105
+ type="button"
2106
+ class="deb-palette__item"
2107
+ [attr.data-testid]="'palette-' + meta.type"
2108
+ [matTooltip]="meta.description"
2109
+ (click)="pick.emit(meta.type)"
2110
+ >
2111
+ <mat-icon>{{ meta.icon }}</mat-icon>
2112
+ <span>{{ meta.label }}</span>
2113
+ </button>
2114
+ }
2115
+ </div>
2116
+ `, styles: [".deb-palette{display:grid;grid-template-columns:1fr 1fr;gap:8px}.deb-palette__item{justify-content:flex-start;overflow:hidden;border-radius:8px!important;border:1px solid var(--border, #e2e8f0)!important;padding:8px 12px!important;font-family:var(--font-heading, inherit)!important;font-weight:600!important;font-size:13px!important;transition:all .18s cubic-bezier(.16,1,.3,1)!important;background:#fff!important}.deb-palette__item:hover{border-color:#6366f1!important;background:#f5f3ff!important;color:#4f46e5!important;transform:translateY(-1px);box-shadow:0 4px 12px #6366f126!important}.deb-palette__item mat-icon{color:#6366f1;margin-right:6px;font-size:18px;width:18px;height:18px}.deb-palette__item span{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}\n"] }]
2117
+ }], propDecorators: { pick: [{
2118
+ type: Output
2119
+ }] } });
2120
+
2121
+ const SYSTEM_DEFAULT_CAN_EDIT = new InjectionToken('SYSTEM_DEFAULT_CAN_EDIT');
2122
+ /**
2123
+ * TabManagerComponent — add / rename / reorder / remove tabs & sub-tabs with systemDefault protection.
2124
+ */
2125
+ class TabManagerComponent {
2126
+ store = inject(BuilderStore);
2127
+ canEditSystemDefaultsFn = inject(SYSTEM_DEFAULT_CAN_EDIT, { optional: true });
2128
+ lang() {
2129
+ return this.store.activeLanguage();
2130
+ }
2131
+ tabLabel(tab) {
2132
+ return resolveLabel(tab.label, this.lang());
2133
+ }
2134
+ canEditTab(tab) {
2135
+ if (!tab.systemDefault)
2136
+ return true;
2137
+ if (this.canEditSystemDefaultsFn) {
2138
+ return this.canEditSystemDefaultsFn([]);
2139
+ }
2140
+ return true;
2141
+ }
2142
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: TabManagerComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
2143
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "17.3.12", type: TabManagerComponent, isStandalone: true, selector: "ngx-tab-manager", ngImport: i0, template: `
2144
+ <div class="deb-tabs">
2145
+ <div class="deb-tabs__head">
2146
+ <span class="deb-section-title">Tabs Manager</span>
2147
+ <button mat-stroked-button type="button" (click)="store.addTab()">
2148
+ <mat-icon>add</mat-icon> Add Tab
2149
+ </button>
2150
+ </div>
2151
+
2152
+ @if (store.tabs().length === 0) {
2153
+ <p class="deb-hint">No tabs — all fields render in a single section.</p>
2154
+ }
2155
+
2156
+ @for (tab of store.tabs(); track tab.id; let i = $index, count = $count) {
2157
+ <div class="deb-tab-card" [class.deb-tab-card--system]="tab.systemDefault">
2158
+ <div class="deb-tabs__row" [attr.data-testid]="'tab-row-' + tab.id">
2159
+ <mat-form-field appearance="outline" subscriptSizing="dynamic" class="deb-grow">
2160
+ <mat-label>{{ tab.id }} {{ tab.systemDefault ? '(System)' : '' }}</mat-label>
2161
+ <input
2162
+ matInput
2163
+ [disabled]="!canEditTab(tab)"
2164
+ [ngModel]="tabLabel(tab)"
2165
+ (ngModelChange)="store.setTabLabel(tab.id, lang(), $event)"
2166
+ />
2167
+ </mat-form-field>
2168
+ <button
2169
+ mat-icon-button
2170
+ type="button"
2171
+ [disabled]="i === 0 || !canEditTab(tab)"
2172
+ (click)="store.moveTab(tab.id, -1)"
2173
+ matTooltip="Move up"
2174
+ >
2175
+ <mat-icon>arrow_upward</mat-icon>
2176
+ </button>
2177
+ <button
2178
+ mat-icon-button
2179
+ type="button"
2180
+ [disabled]="i === count - 1 || !canEditTab(tab)"
2181
+ (click)="store.moveTab(tab.id, 1)"
2182
+ matTooltip="Move down"
2183
+ >
2184
+ <mat-icon>arrow_downward</mat-icon>
2185
+ </button>
2186
+ <button
2187
+ mat-icon-button
2188
+ type="button"
2189
+ color="warn"
2190
+ [disabled]="!canEditTab(tab)"
2191
+ (click)="store.removeTab(tab.id)"
2192
+ matTooltip="Remove tab"
2193
+ >
2194
+ <mat-icon>delete</mat-icon>
2195
+ </button>
2196
+ </div>
2197
+
2198
+ <!-- Tab settings row -->
2199
+ <div class="deb-tab-settings">
2200
+ <mat-checkbox
2201
+ [disabled]="!canEditTab(tab)"
2202
+ [ngModel]="tab.flatData"
2203
+ (ngModelChange)="store.updateTab(tab.id, { flatData: $event })"
2204
+ >
2205
+ Flat Data
2206
+ </mat-checkbox>
2207
+ <mat-checkbox
2208
+ [disabled]="!canEditTab(tab)"
2209
+ [ngModel]="tab.isPrimaryTab"
2210
+ (ngModelChange)="$event ? store.setPrimaryTab(tab.id) : store.updateTab(tab.id, { isPrimaryTab: false })"
2211
+ >
2212
+ Primary Tab
2213
+ </mat-checkbox>
2214
+ <mat-checkbox
2215
+ [disabled]="!canEditTab(tab)"
2216
+ [ngModel]="tab.maskData"
2217
+ (ngModelChange)="store.updateTab(tab.id, { maskData: $event })"
2218
+ >
2219
+ Mask Tab Data
2220
+ </mat-checkbox>
2221
+ <mat-checkbox
2222
+ [disabled]="!canEditTab(tab)"
2223
+ [ngModel]="tab.systemDefault"
2224
+ (ngModelChange)="store.updateTab(tab.id, { systemDefault: $event })"
2225
+ >
2226
+ System Default
2227
+ </mat-checkbox>
2228
+ <button
2229
+ mat-button
2230
+ type="button"
2231
+ color="primary"
2232
+ [disabled]="!canEditTab(tab)"
2233
+ (click)="store.addSubTab(tab.id)"
2234
+ >
2235
+ + Sub-tab
2236
+ </button>
2237
+ </div>
2238
+
2239
+ <!-- Module tab configuration -->
2240
+ <div class="deb-tab-module">
2241
+ <mat-form-field appearance="outline" subscriptSizing="dynamic" class="deb-grow">
2242
+ <mat-label>Consumer Module Name (Optional)</mat-label>
2243
+ <input
2244
+ matInput
2245
+ placeholder="e.g. documents-view"
2246
+ [disabled]="!canEditTab(tab)"
2247
+ [ngModel]="tab.moduleName"
2248
+ (ngModelChange)="store.updateTab(tab.id, { moduleName: $event || undefined })"
2249
+ />
2250
+ </mat-form-field>
2251
+ </div>
2252
+
2253
+ <!-- Sub-tabs recursive render (Level 2) -->
2254
+ @if (tab.children && tab.children.length > 0) {
2255
+ <div class="deb-subtabs">
2256
+ <span class="deb-subtabs__title">Sub-tabs:</span>
2257
+ @for (sub of tab.children; track sub.id) {
2258
+ <div class="deb-tabs__row deb-subtab-row" [attr.data-testid]="'subtab-row-' + sub.id">
2259
+ <mat-form-field appearance="outline" subscriptSizing="dynamic" class="deb-grow">
2260
+ <mat-label>{{ sub.id }}</mat-label>
2261
+ <input
2262
+ matInput
2263
+ [disabled]="!canEditTab(sub)"
2264
+ [ngModel]="tabLabel(sub)"
2265
+ (ngModelChange)="store.setTabLabel(sub.id, lang(), $event)"
2266
+ />
2267
+ </mat-form-field>
2268
+ <button
2269
+ mat-icon-button
2270
+ type="button"
2271
+ color="warn"
2272
+ [disabled]="!canEditTab(sub)"
2273
+ (click)="store.removeTab(sub.id)"
2274
+ matTooltip="Remove sub-tab"
2275
+ >
2276
+ <mat-icon>delete</mat-icon>
2277
+ </button>
2278
+ </div>
2279
+ }
2280
+ </div>
2281
+ }
2282
+ </div>
2283
+ }
2284
+ </div>
2285
+ `, isInline: true, styles: [".deb-tabs__head{display:flex;align-items:center;justify-content:space-between;margin-bottom:12px}.deb-tab-card{border:1px solid var(--deb-border, #e5e7eb);border-radius:8px;padding:10px;margin-bottom:10px;background:var(--deb-bg-surface, #ffffff)}.deb-tab-card--system{border-left:4px solid var(--deb-accent, #6366f1)}.deb-tabs__row{display:flex;align-items:center;gap:4px}.deb-tab-settings{display:flex;flex-wrap:wrap;gap:8px 16px;align-items:center;margin-top:8px;font-size:13px}.deb-tab-module{margin-top:8px}.deb-subtabs{margin-top:8px;padding-left:16px;border-left:2px dashed var(--deb-border, #e5e7eb)}.deb-subtabs__title{font-size:12px;font-weight:600;color:var(--deb-muted, #6b7280)}.deb-subtab-row{margin-top:6px}.deb-grow{flex:1}\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.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[mat-button], button[mat-raised-button], button[mat-flat-button], button[mat-stroked-button] ", exportAs: ["matButton"] }, { kind: "component", type: i2.MatIconButton, selector: "button[mat-icon-button]", exportAs: ["matButton"] }, { kind: "ngmodule", type: MatCheckboxModule }, { kind: "component", type: i3$1.MatCheckbox, selector: "mat-checkbox", inputs: ["aria-label", "aria-labelledby", "aria-describedby", "id", "required", "labelPosition", "name", "value", "disableRipple", "tabIndex", "color", "checked", "disabled", "indeterminate"], outputs: ["change", "indeterminateChange"], exportAs: ["matCheckbox"] }, { 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: "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"], exportAs: ["matInput"] }, { kind: "ngmodule", type: MatSlideToggleModule }, { kind: "ngmodule", type: MatTooltipModule }, { kind: "directive", type: i4$2.MatTooltip, selector: "[matTooltip]", inputs: ["matTooltipPosition", "matTooltipPositionAtOrigin", "matTooltipDisabled", "matTooltipShowDelay", "matTooltipHideDelay", "matTooltipTouchGestures", "matTooltip", "matTooltipClass"], exportAs: ["matTooltip"] }] });
2286
+ }
2287
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: TabManagerComponent, decorators: [{
2288
+ type: Component,
2289
+ args: [{ selector: 'ngx-tab-manager', standalone: true, imports: [
2290
+ FormsModule,
2291
+ MatButtonModule,
2292
+ MatCheckboxModule,
2293
+ MatFormFieldModule,
2294
+ MatIconModule,
2295
+ MatInputModule,
2296
+ MatSlideToggleModule,
2297
+ MatTooltipModule,
2298
+ ], template: `
2299
+ <div class="deb-tabs">
2300
+ <div class="deb-tabs__head">
2301
+ <span class="deb-section-title">Tabs Manager</span>
2302
+ <button mat-stroked-button type="button" (click)="store.addTab()">
2303
+ <mat-icon>add</mat-icon> Add Tab
2304
+ </button>
2305
+ </div>
2306
+
2307
+ @if (store.tabs().length === 0) {
2308
+ <p class="deb-hint">No tabs — all fields render in a single section.</p>
2309
+ }
2310
+
2311
+ @for (tab of store.tabs(); track tab.id; let i = $index, count = $count) {
2312
+ <div class="deb-tab-card" [class.deb-tab-card--system]="tab.systemDefault">
2313
+ <div class="deb-tabs__row" [attr.data-testid]="'tab-row-' + tab.id">
2314
+ <mat-form-field appearance="outline" subscriptSizing="dynamic" class="deb-grow">
2315
+ <mat-label>{{ tab.id }} {{ tab.systemDefault ? '(System)' : '' }}</mat-label>
2316
+ <input
2317
+ matInput
2318
+ [disabled]="!canEditTab(tab)"
2319
+ [ngModel]="tabLabel(tab)"
2320
+ (ngModelChange)="store.setTabLabel(tab.id, lang(), $event)"
2321
+ />
2322
+ </mat-form-field>
2323
+ <button
2324
+ mat-icon-button
2325
+ type="button"
2326
+ [disabled]="i === 0 || !canEditTab(tab)"
2327
+ (click)="store.moveTab(tab.id, -1)"
2328
+ matTooltip="Move up"
2329
+ >
2330
+ <mat-icon>arrow_upward</mat-icon>
2331
+ </button>
2332
+ <button
2333
+ mat-icon-button
2334
+ type="button"
2335
+ [disabled]="i === count - 1 || !canEditTab(tab)"
2336
+ (click)="store.moveTab(tab.id, 1)"
2337
+ matTooltip="Move down"
2338
+ >
2339
+ <mat-icon>arrow_downward</mat-icon>
2340
+ </button>
2341
+ <button
2342
+ mat-icon-button
2343
+ type="button"
2344
+ color="warn"
2345
+ [disabled]="!canEditTab(tab)"
2346
+ (click)="store.removeTab(tab.id)"
2347
+ matTooltip="Remove tab"
2348
+ >
2349
+ <mat-icon>delete</mat-icon>
2350
+ </button>
2351
+ </div>
2352
+
2353
+ <!-- Tab settings row -->
2354
+ <div class="deb-tab-settings">
2355
+ <mat-checkbox
2356
+ [disabled]="!canEditTab(tab)"
2357
+ [ngModel]="tab.flatData"
2358
+ (ngModelChange)="store.updateTab(tab.id, { flatData: $event })"
2359
+ >
2360
+ Flat Data
2361
+ </mat-checkbox>
2362
+ <mat-checkbox
2363
+ [disabled]="!canEditTab(tab)"
2364
+ [ngModel]="tab.isPrimaryTab"
2365
+ (ngModelChange)="$event ? store.setPrimaryTab(tab.id) : store.updateTab(tab.id, { isPrimaryTab: false })"
2366
+ >
2367
+ Primary Tab
2368
+ </mat-checkbox>
2369
+ <mat-checkbox
2370
+ [disabled]="!canEditTab(tab)"
2371
+ [ngModel]="tab.maskData"
2372
+ (ngModelChange)="store.updateTab(tab.id, { maskData: $event })"
2373
+ >
2374
+ Mask Tab Data
2375
+ </mat-checkbox>
2376
+ <mat-checkbox
2377
+ [disabled]="!canEditTab(tab)"
2378
+ [ngModel]="tab.systemDefault"
2379
+ (ngModelChange)="store.updateTab(tab.id, { systemDefault: $event })"
2380
+ >
2381
+ System Default
2382
+ </mat-checkbox>
2383
+ <button
2384
+ mat-button
2385
+ type="button"
2386
+ color="primary"
2387
+ [disabled]="!canEditTab(tab)"
2388
+ (click)="store.addSubTab(tab.id)"
2389
+ >
2390
+ + Sub-tab
2391
+ </button>
2392
+ </div>
2393
+
2394
+ <!-- Module tab configuration -->
2395
+ <div class="deb-tab-module">
2396
+ <mat-form-field appearance="outline" subscriptSizing="dynamic" class="deb-grow">
2397
+ <mat-label>Consumer Module Name (Optional)</mat-label>
2398
+ <input
2399
+ matInput
2400
+ placeholder="e.g. documents-view"
2401
+ [disabled]="!canEditTab(tab)"
2402
+ [ngModel]="tab.moduleName"
2403
+ (ngModelChange)="store.updateTab(tab.id, { moduleName: $event || undefined })"
2404
+ />
2405
+ </mat-form-field>
2406
+ </div>
2407
+
2408
+ <!-- Sub-tabs recursive render (Level 2) -->
2409
+ @if (tab.children && tab.children.length > 0) {
2410
+ <div class="deb-subtabs">
2411
+ <span class="deb-subtabs__title">Sub-tabs:</span>
2412
+ @for (sub of tab.children; track sub.id) {
2413
+ <div class="deb-tabs__row deb-subtab-row" [attr.data-testid]="'subtab-row-' + sub.id">
2414
+ <mat-form-field appearance="outline" subscriptSizing="dynamic" class="deb-grow">
2415
+ <mat-label>{{ sub.id }}</mat-label>
2416
+ <input
2417
+ matInput
2418
+ [disabled]="!canEditTab(sub)"
2419
+ [ngModel]="tabLabel(sub)"
2420
+ (ngModelChange)="store.setTabLabel(sub.id, lang(), $event)"
2421
+ />
2422
+ </mat-form-field>
2423
+ <button
2424
+ mat-icon-button
2425
+ type="button"
2426
+ color="warn"
2427
+ [disabled]="!canEditTab(sub)"
2428
+ (click)="store.removeTab(sub.id)"
2429
+ matTooltip="Remove sub-tab"
2430
+ >
2431
+ <mat-icon>delete</mat-icon>
2432
+ </button>
2433
+ </div>
2434
+ }
2435
+ </div>
2436
+ }
2437
+ </div>
2438
+ }
2439
+ </div>
2440
+ `, styles: [".deb-tabs__head{display:flex;align-items:center;justify-content:space-between;margin-bottom:12px}.deb-tab-card{border:1px solid var(--deb-border, #e5e7eb);border-radius:8px;padding:10px;margin-bottom:10px;background:var(--deb-bg-surface, #ffffff)}.deb-tab-card--system{border-left:4px solid var(--deb-accent, #6366f1)}.deb-tabs__row{display:flex;align-items:center;gap:4px}.deb-tab-settings{display:flex;flex-wrap:wrap;gap:8px 16px;align-items:center;margin-top:8px;font-size:13px}.deb-tab-module{margin-top:8px}.deb-subtabs{margin-top:8px;padding-left:16px;border-left:2px dashed var(--deb-border, #e5e7eb)}.deb-subtabs__title{font-size:12px;font-weight:600;color:var(--deb-muted, #6b7280)}.deb-subtab-row{margin-top:6px}.deb-grow{flex:1}\n"] }]
2441
+ }] });
2442
+
2443
+ class EntityBuilderTreeNodeComponent {
2444
+ store = inject(BuilderStore);
2445
+ field;
2446
+ index;
2447
+ totalCount;
2448
+ fieldTypeLabel(type) {
2449
+ return getFieldTypeMeta(type)?.label ?? type;
2450
+ }
2451
+ fieldTypeIcon(type) {
2452
+ return getFieldTypeMeta(type)?.icon ?? 'help_outline';
2453
+ }
2454
+ fieldLabel(field) {
2455
+ const lang = this.store.activeLanguage();
2456
+ return resolveLabel(field.label, lang) || field.id;
2457
+ }
2458
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: EntityBuilderTreeNodeComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
2459
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "17.3.12", type: EntityBuilderTreeNodeComponent, isStandalone: true, selector: "ngx-entity-builder-tree-node", inputs: { field: "field", index: "index", totalCount: "totalCount" }, ngImport: i0, template: `
2460
+ <div
2461
+ cdkDrag
2462
+ class="deb-field-row"
2463
+ data-testid="builder-field-row"
2464
+ [class.deb-field-row--active]="store.selectedFieldId() === field.id"
2465
+ (click)="store.selectField(field.id)"
2466
+ >
2467
+ <mat-icon class="deb-drag-handle" cdkDragHandle title="Drag to reorder">drag_indicator</mat-icon>
2468
+ <mat-icon class="deb-field-type" [matTooltip]="fieldTypeLabel(field.type)">{{ fieldTypeIcon(field.type) }}</mat-icon>
2469
+ <div class="deb-field-meta">
2470
+ <div class="deb-row" style="gap:6px">
2471
+ <span class="deb-field-label" [attr.data-testid]="'row-label-' + field.id">{{ fieldLabel(field) }}</span>
2472
+ <span class="deb-type-badge">{{ field.type }}</span>
2473
+ @if (field.isReferenced) {
2474
+ <mat-icon style="font-size: 16px; width: 16px; height: 16px; color: #1976d2;" matTooltip="Referenced field (linked to {{ field.referencedEntityKey }})">link</mat-icon>
2475
+ }
2476
+ @if (field.hasDrift) {
2477
+ <mat-icon style="font-size: 16px; width: 16px; height: 16px; color: #d32f2f;" data-testid="drift-warning-icon" matTooltip="Drift detected: source field has evolved">sync_problem</mat-icon>
2478
+ }
2479
+ </div>
2480
+ <span class="deb-field-id" [attr.data-testid]="'row-id-' + field.id">{{ field.id }}</span>
2481
+ </div>
2482
+ @if (field.validators?.required) { <span class="deb-req" title="Required">*</span> }
2483
+
2484
+ <button mat-icon-button type="button" [disabled]="index === 0"
2485
+ [attr.data-testid]="'row-up-' + field.id"
2486
+ (click)="store.moveField(field.id, -1); $event.stopPropagation()" matTooltip="Move up">
2487
+ <mat-icon>arrow_upward</mat-icon>
2488
+ </button>
2489
+ <button mat-icon-button type="button" [disabled]="index === totalCount - 1"
2490
+ [attr.data-testid]="'row-down-' + field.id"
2491
+ (click)="store.moveField(field.id, 1); $event.stopPropagation()" matTooltip="Move down">
2492
+ <mat-icon>arrow_downward</mat-icon>
2493
+ </button>
2494
+ <button mat-icon-button type="button"
2495
+ [attr.data-testid]="'row-duplicate-' + field.id"
2496
+ (click)="store.duplicateField(field.id); $event.stopPropagation()" matTooltip="Duplicate">
2497
+ <mat-icon>content_copy</mat-icon>
2498
+ </button>
2499
+ <button mat-icon-button type="button" color="warn"
2500
+ [attr.data-testid]="'row-delete-' + field.id"
2501
+ (click)="store.removeField(field.id); $event.stopPropagation()" matTooltip="Delete">
2502
+ <mat-icon>delete</mat-icon>
2503
+ </button>
2504
+ </div>
2505
+
2506
+ @if (field.children && field.children.length > 0) {
2507
+ <div class="deb-tree-children" style="padding-left: 20px; border-left: 2px solid rgba(0,0,0,0.08); margin-left: 12px; margin-top: 4px;">
2508
+ @for (child of field.children; track child.id; let ci = $index, cCount = $count) {
2509
+ <ngx-entity-builder-tree-node
2510
+ [field]="child"
2511
+ [index]="ci"
2512
+ [totalCount]="cCount"
2513
+ />
2514
+ }
2515
+ </div>
2516
+ }
2517
+ `, isInline: true, dependencies: [{ kind: "component", type: EntityBuilderTreeNodeComponent, selector: "ngx-entity-builder-tree-node", inputs: ["field", "index", "totalCount"] }, { kind: "ngmodule", type: CommonModule }, { kind: "ngmodule", type: DragDropModule }, { kind: "directive", type: i1$1.CdkDrag, selector: "[cdkDrag]", inputs: ["cdkDragData", "cdkDragLockAxis", "cdkDragRootElement", "cdkDragBoundary", "cdkDragStartDelay", "cdkDragFreeDragPosition", "cdkDragDisabled", "cdkDragConstrainPosition", "cdkDragPreviewClass", "cdkDragPreviewContainer"], outputs: ["cdkDragStarted", "cdkDragReleased", "cdkDragEnded", "cdkDragEntered", "cdkDragExited", "cdkDragDropped", "cdkDragMoved"], exportAs: ["cdkDrag"] }, { kind: "directive", type: i1$1.CdkDragHandle, selector: "[cdkDragHandle]", inputs: ["cdkDragHandleDisabled"] }, { kind: "ngmodule", type: MatButtonModule }, { kind: "component", type: i2.MatIconButton, selector: "button[mat-icon-button]", exportAs: ["matButton"] }, { kind: "ngmodule", type: MatIconModule }, { kind: "component", type: i4$1.MatIcon, selector: "mat-icon", inputs: ["color", "inline", "svgIcon", "fontSet", "fontIcon"], exportAs: ["matIcon"] }, { kind: "ngmodule", type: MatTooltipModule }, { kind: "directive", type: i4$2.MatTooltip, selector: "[matTooltip]", inputs: ["matTooltipPosition", "matTooltipPositionAtOrigin", "matTooltipDisabled", "matTooltipShowDelay", "matTooltipHideDelay", "matTooltipTouchGestures", "matTooltip", "matTooltipClass"], exportAs: ["matTooltip"] }] });
2518
+ }
2519
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: EntityBuilderTreeNodeComponent, decorators: [{
2520
+ type: Component,
2521
+ args: [{
2522
+ selector: 'ngx-entity-builder-tree-node',
2523
+ standalone: true,
2524
+ imports: [
2525
+ CommonModule,
2526
+ DragDropModule,
2527
+ MatButtonModule,
2528
+ MatIconModule,
2529
+ MatTooltipModule,
2530
+ ],
2531
+ template: `
2532
+ <div
2533
+ cdkDrag
2534
+ class="deb-field-row"
2535
+ data-testid="builder-field-row"
2536
+ [class.deb-field-row--active]="store.selectedFieldId() === field.id"
2537
+ (click)="store.selectField(field.id)"
2538
+ >
2539
+ <mat-icon class="deb-drag-handle" cdkDragHandle title="Drag to reorder">drag_indicator</mat-icon>
2540
+ <mat-icon class="deb-field-type" [matTooltip]="fieldTypeLabel(field.type)">{{ fieldTypeIcon(field.type) }}</mat-icon>
2541
+ <div class="deb-field-meta">
2542
+ <div class="deb-row" style="gap:6px">
2543
+ <span class="deb-field-label" [attr.data-testid]="'row-label-' + field.id">{{ fieldLabel(field) }}</span>
2544
+ <span class="deb-type-badge">{{ field.type }}</span>
2545
+ @if (field.isReferenced) {
2546
+ <mat-icon style="font-size: 16px; width: 16px; height: 16px; color: #1976d2;" matTooltip="Referenced field (linked to {{ field.referencedEntityKey }})">link</mat-icon>
2547
+ }
2548
+ @if (field.hasDrift) {
2549
+ <mat-icon style="font-size: 16px; width: 16px; height: 16px; color: #d32f2f;" data-testid="drift-warning-icon" matTooltip="Drift detected: source field has evolved">sync_problem</mat-icon>
2550
+ }
2551
+ </div>
2552
+ <span class="deb-field-id" [attr.data-testid]="'row-id-' + field.id">{{ field.id }}</span>
2553
+ </div>
2554
+ @if (field.validators?.required) { <span class="deb-req" title="Required">*</span> }
2555
+
2556
+ <button mat-icon-button type="button" [disabled]="index === 0"
2557
+ [attr.data-testid]="'row-up-' + field.id"
2558
+ (click)="store.moveField(field.id, -1); $event.stopPropagation()" matTooltip="Move up">
2559
+ <mat-icon>arrow_upward</mat-icon>
2560
+ </button>
2561
+ <button mat-icon-button type="button" [disabled]="index === totalCount - 1"
2562
+ [attr.data-testid]="'row-down-' + field.id"
2563
+ (click)="store.moveField(field.id, 1); $event.stopPropagation()" matTooltip="Move down">
2564
+ <mat-icon>arrow_downward</mat-icon>
2565
+ </button>
2566
+ <button mat-icon-button type="button"
2567
+ [attr.data-testid]="'row-duplicate-' + field.id"
2568
+ (click)="store.duplicateField(field.id); $event.stopPropagation()" matTooltip="Duplicate">
2569
+ <mat-icon>content_copy</mat-icon>
2570
+ </button>
2571
+ <button mat-icon-button type="button" color="warn"
2572
+ [attr.data-testid]="'row-delete-' + field.id"
2573
+ (click)="store.removeField(field.id); $event.stopPropagation()" matTooltip="Delete">
2574
+ <mat-icon>delete</mat-icon>
2575
+ </button>
2576
+ </div>
2577
+
2578
+ @if (field.children && field.children.length > 0) {
2579
+ <div class="deb-tree-children" style="padding-left: 20px; border-left: 2px solid rgba(0,0,0,0.08); margin-left: 12px; margin-top: 4px;">
2580
+ @for (child of field.children; track child.id; let ci = $index, cCount = $count) {
2581
+ <ngx-entity-builder-tree-node
2582
+ [field]="child"
2583
+ [index]="ci"
2584
+ [totalCount]="cCount"
2585
+ />
2586
+ }
2587
+ </div>
2588
+ }
2589
+ `,
2590
+ }]
2591
+ }], propDecorators: { field: [{
2592
+ type: Input,
2593
+ args: [{ required: true }]
2594
+ }], index: [{
2595
+ type: Input,
2596
+ args: [{ required: true }]
2597
+ }], totalCount: [{
2598
+ type: Input,
2599
+ args: [{ required: true }]
2600
+ }] } });
2601
+
2602
+ class EntityBuilderCanvasComponent {
2603
+ store = inject(BuilderStore);
2604
+ onDrop(event) {
2605
+ this.store.reorderField(event.previousIndex, event.currentIndex);
2606
+ }
2607
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: EntityBuilderCanvasComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
2608
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "17.3.12", type: EntityBuilderCanvasComponent, isStandalone: true, selector: "ngx-entity-builder-canvas", ngImport: i0, template: `
2609
+ <mat-card class="deb-canvas">
2610
+ <mat-card-header>
2611
+ <mat-card-title>Fields ({{ store.fields().length }})</mat-card-title>
2612
+ </mat-card-header>
2613
+ <mat-card-content>
2614
+ @if (store.fields().length === 0) {
2615
+ <div class="deb-empty">
2616
+ <mat-icon>widgets</mat-icon>
2617
+ <p>No fields yet. Pick a type from <strong>Add field</strong> to get started.</p>
2618
+ </div>
2619
+ }
2620
+
2621
+ <div cdkDropList class="deb-field-list" data-testid="builder-field-list" (cdkDropListDropped)="onDrop($event)">
2622
+ @for (f of store.fields(); track f.id; let i = $index, count = $count) {
2623
+ <ngx-entity-builder-tree-node
2624
+ [field]="f"
2625
+ [index]="i"
2626
+ [totalCount]="count"
2627
+ />
2628
+ }
2629
+ </div>
2630
+ </mat-card-content>
2631
+ </mat-card>
2632
+ `, isInline: true, dependencies: [{ kind: "ngmodule", type: CommonModule }, { kind: "ngmodule", type: DragDropModule }, { kind: "directive", type: i1$1.CdkDropList, selector: "[cdkDropList], cdk-drop-list", inputs: ["cdkDropListConnectedTo", "cdkDropListData", "cdkDropListOrientation", "id", "cdkDropListLockAxis", "cdkDropListDisabled", "cdkDropListSortingDisabled", "cdkDropListEnterPredicate", "cdkDropListSortPredicate", "cdkDropListAutoScrollDisabled", "cdkDropListAutoScrollStep"], outputs: ["cdkDropListDropped", "cdkDropListEntered", "cdkDropListExited", "cdkDropListSorted"], exportAs: ["cdkDropList"] }, { kind: "ngmodule", type: MatCardModule }, { kind: "component", type: i2$1.MatCard, selector: "mat-card", inputs: ["appearance"], exportAs: ["matCard"] }, { kind: "directive", type: i2$1.MatCardContent, selector: "mat-card-content" }, { kind: "component", type: i2$1.MatCardHeader, selector: "mat-card-header" }, { kind: "directive", type: i2$1.MatCardTitle, selector: "mat-card-title, [mat-card-title], [matCardTitle]" }, { kind: "ngmodule", type: MatIconModule }, { kind: "component", type: i4$1.MatIcon, selector: "mat-icon", inputs: ["color", "inline", "svgIcon", "fontSet", "fontIcon"], exportAs: ["matIcon"] }, { kind: "component", type: EntityBuilderTreeNodeComponent, selector: "ngx-entity-builder-tree-node", inputs: ["field", "index", "totalCount"] }] });
2633
+ }
2634
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: EntityBuilderCanvasComponent, decorators: [{
2635
+ type: Component,
2636
+ args: [{
2637
+ selector: 'ngx-entity-builder-canvas',
2638
+ standalone: true,
2639
+ imports: [
2640
+ CommonModule,
2641
+ DragDropModule,
2642
+ MatCardModule,
2643
+ MatIconModule,
2644
+ EntityBuilderTreeNodeComponent,
2645
+ ],
2646
+ template: `
2647
+ <mat-card class="deb-canvas">
2648
+ <mat-card-header>
2649
+ <mat-card-title>Fields ({{ store.fields().length }})</mat-card-title>
2650
+ </mat-card-header>
2651
+ <mat-card-content>
2652
+ @if (store.fields().length === 0) {
2653
+ <div class="deb-empty">
2654
+ <mat-icon>widgets</mat-icon>
2655
+ <p>No fields yet. Pick a type from <strong>Add field</strong> to get started.</p>
2656
+ </div>
2657
+ }
2658
+
2659
+ <div cdkDropList class="deb-field-list" data-testid="builder-field-list" (cdkDropListDropped)="onDrop($event)">
2660
+ @for (f of store.fields(); track f.id; let i = $index, count = $count) {
2661
+ <ngx-entity-builder-tree-node
2662
+ [field]="f"
2663
+ [index]="i"
2664
+ [totalCount]="count"
2665
+ />
2666
+ }
2667
+ </div>
2668
+ </mat-card-content>
2669
+ </mat-card>
2670
+ `,
2671
+ }]
2672
+ }] });
2673
+
2674
+ /** RBAC actions surfaced in the settings panel. */
2675
+ const RBAC_ACTIONS = ['view', 'edit', 'delete'];
2676
+ /** Shared stable empty array — never allocate a fresh [] per change-detection (mat-select loops). */
2677
+ const EMPTY_ROLES = Object.freeze([]);
2678
+ /**
2679
+ * EntityBuilderComponent — the top-level visual builder for an EntityFormConfig.
2680
+ */
2681
+ class EntityBuilderComponent {
2682
+ store = inject(BuilderStore);
2683
+ /** Existing config to edit. When omitted, the builder starts blank. */
2684
+ config;
2685
+ /** Languages available for label/placeholder editing. First entry is the default. */
2686
+ languages = ['en'];
2687
+ /** Optional role list — enables multi-select role pickers for RBAC instead of free text. */
2688
+ availableRoles = [];
2689
+ /** Optional common-module definitions for module tabs; defaults to the built-in list from core. */
2690
+ commonModules = COMMON_MODULES;
2691
+ /** Emitted on every change to the working config. */
2692
+ configChange = new EventEmitter();
2693
+ /** Emitted when the user clicks Save. Carries a clean, deep-cloned config. */
2694
+ save = new EventEmitter();
2695
+ rbacActions = RBAC_ACTIONS;
2696
+ constructor() {
2697
+ effect(() => this.configChange.emit(this.store.config()), { allowSignalWrites: true });
2698
+ }
2699
+ ngOnChanges(changes) {
2700
+ if (changes['config'] && changes['config'].currentValue !== changes['config'].previousValue) {
2701
+ if (this.config)
2702
+ this.store.load(this.config);
2703
+ else if (changes['config'].isFirstChange())
2704
+ this.store.reset();
2705
+ }
2706
+ if (changes['languages'] && this.languages.length) {
2707
+ const active = this.store.activeLanguage();
2708
+ if (!this.languages.includes(active))
2709
+ this.store.setActiveLanguage(this.languages[0]);
2710
+ }
2711
+ }
2712
+ // ─── Field canvas ─────────────────────────────────────────────────────────
2713
+ onDrop(event) {
2714
+ this.store.reorderField(event.previousIndex, event.currentIndex);
2715
+ }
2716
+ fieldTypeLabel(type) {
2717
+ return getFieldTypeMeta(type)?.label ?? type;
2718
+ }
2719
+ fieldTypeIcon(type) {
2720
+ return getFieldTypeMeta(type)?.icon ?? 'help_outline';
2721
+ }
2722
+ fieldLabel(field) {
2723
+ const lang = this.store.activeLanguage();
2724
+ return resolveLabel(field.label, lang) || field.id;
2725
+ }
2726
+ // ─── Toolbar actions ──────────────────────────────────────────────────────
2727
+ doSave() {
2728
+ this.save.emit(this.store.exportConfig());
2729
+ }
2730
+ get json() {
2731
+ return JSON.stringify(this.store.exportConfig(), null, 2);
2732
+ }
2733
+ copyJson() {
2734
+ if (typeof navigator !== 'undefined' && navigator.clipboard) {
2735
+ void navigator.clipboard.writeText(this.json);
2736
+ }
2737
+ }
2738
+ // ─── RBAC settings ────────────────────────────────────────────────────────
2739
+ rolesFor(action) {
2740
+ // Return the stored array (stable ref between mutations) or a shared empty — NOT a fresh [],
2741
+ // which would make the bound mat-select re-evaluate every CD and loop forever.
2742
+ return this.store.config().permissions?.[action] ?? EMPTY_ROLES;
2743
+ }
2744
+ setRoles(action, roles) {
2745
+ this.store.setPermission(action, roles);
2746
+ }
2747
+ /** Parse a comma-separated role string into a trimmed, de-duplicated array. */
2748
+ setRolesFromText(action, text) {
2749
+ const roles = Array.from(new Set(text
2750
+ .split(',')
2751
+ .map(r => r.trim())
2752
+ .filter(Boolean)));
2753
+ this.store.setPermission(action, roles);
2754
+ }
2755
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: EntityBuilderComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
2756
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "17.3.12", type: EntityBuilderComponent, isStandalone: true, selector: "ngx-entity-builder", inputs: { config: "config", languages: "languages", availableRoles: "availableRoles", commonModules: "commonModules" }, outputs: { configChange: "configChange", save: "save" }, providers: [BuilderStore], usesOnChanges: true, ngImport: i0, template: "<mat-toolbar class=\"deb-toolbar\" color=\"primary\">\r\n <mat-icon>dashboard_customize</mat-icon>\r\n <span class=\"deb-toolbar__title\">Entity Builder</span>\r\n <span class=\"deb-spacer\"></span>\r\n\r\n @if (store.problems().length) {\r\n <button\r\n mat-button\r\n type=\"button\"\r\n class=\"deb-problems\"\r\n [class.deb-problems--error]=\"!store.isValid()\"\r\n [matTooltip]=\"store.problems().length + ' issue(s) \u2014 hover items in the list'\"\r\n >\r\n <mat-icon>{{ store.isValid() ? 'warning' : 'error' }}</mat-icon>\r\n {{ store.errors().length }} / {{ store.problems().length }}\r\n </button>\r\n }\r\n\r\n <button mat-button type=\"button\" (click)=\"copyJson()\">\r\n <mat-icon>content_copy</mat-icon> Copy JSON\r\n </button>\r\n <button mat-flat-button color=\"accent\" type=\"button\" [disabled]=\"!store.isValid()\" (click)=\"doSave()\">\r\n <mat-icon>save</mat-icon> Save\r\n </button>\r\n</mat-toolbar>\r\n\r\n<div class=\"deb-body\">\r\n <!-- \u2500\u2500\u2500 Left: settings, palette, tabs \u2500\u2500\u2500 -->\r\n <div class=\"deb-col deb-col--left\">\r\n <mat-card>\r\n <mat-card-content>\r\n <mat-form-field appearance=\"outline\" subscriptSizing=\"dynamic\" class=\"deb-full\">\r\n <mat-label>Entity name</mat-label>\r\n <input\r\n matInput\r\n [ngModel]=\"store.config().entity\"\r\n (ngModelChange)=\"store.setEntityName($event)\"\r\n placeholder=\"e.g. clients\"\r\n />\r\n </mat-form-field>\r\n\r\n <div class=\"deb-row\">\r\n <mat-form-field appearance=\"outline\" subscriptSizing=\"dynamic\" class=\"deb-grow\">\r\n <mat-label>Editing language</mat-label>\r\n <mat-select\r\n [ngModel]=\"store.activeLanguage()\"\r\n (ngModelChange)=\"store.setActiveLanguage($event)\"\r\n >\r\n @for (l of languages; track l) {\r\n <mat-option [value]=\"l\">{{ l }}</mat-option>\r\n }\r\n </mat-select>\r\n </mat-form-field>\r\n </div>\r\n\r\n <mat-slide-toggle\r\n [ngModel]=\"store.config().maskData ?? false\"\r\n (ngModelChange)=\"store.setMaskData($event)\"\r\n >Mask this entity (RBAC)</mat-slide-toggle>\r\n\r\n <mat-expansion-panel class=\"deb-rbac\">\r\n <mat-expansion-panel-header>\r\n <mat-panel-title>Permissions</mat-panel-title>\r\n </mat-expansion-panel-header>\r\n @for (action of rbacActions; track action) {\r\n @if (availableRoles.length) {\r\n <mat-form-field appearance=\"outline\" subscriptSizing=\"dynamic\" class=\"deb-full\">\r\n <mat-label>{{ action }} roles</mat-label>\r\n <mat-select multiple [ngModel]=\"rolesFor(action)\" (ngModelChange)=\"setRoles(action, $event)\">\r\n @for (role of availableRoles; track role) {\r\n <mat-option [value]=\"role\">{{ role }}</mat-option>\r\n }\r\n </mat-select>\r\n </mat-form-field>\r\n } @else {\r\n <mat-form-field appearance=\"outline\" subscriptSizing=\"dynamic\" class=\"deb-full\">\r\n <mat-label>{{ action }} roles (comma-separated)</mat-label>\r\n <input\r\n matInput\r\n [ngModel]=\"rolesFor(action).join(', ')\"\r\n (ngModelChange)=\"setRolesFromText(action, $event)\"\r\n />\r\n </mat-form-field>\r\n }\r\n }\r\n <p class=\"deb-hint\">Empty = no restriction for that action.</p>\r\n </mat-expansion-panel>\r\n </mat-card-content>\r\n </mat-card>\r\n\r\n <mat-card>\r\n <mat-card-header><mat-card-title>Add field</mat-card-title></mat-card-header>\r\n <mat-card-content>\r\n <ngx-field-palette (pick)=\"store.addField($event)\"></ngx-field-palette>\r\n </mat-card-content>\r\n </mat-card>\r\n\r\n <mat-card>\r\n <mat-card-content>\r\n <ngx-tab-manager></ngx-tab-manager>\r\n </mat-card-content>\r\n </mat-card>\r\n </div>\r\n\r\n <!-- \u2500\u2500\u2500 Center: field canvas \u2500\u2500\u2500 -->\r\n <div class=\"deb-col deb-col--center\">\r\n <ngx-entity-builder-canvas />\r\n </div>\r\n\r\n <!-- \u2500\u2500\u2500 Right: inspector \u2500\u2500\u2500 -->\r\n <div class=\"deb-col deb-col--right\">\r\n <mat-card>\r\n <mat-card-header><mat-card-title>Field properties</mat-card-title></mat-card-header>\r\n <mat-card-content>\r\n <ngx-field-inspector></ngx-field-inspector>\r\n </mat-card-content>\r\n </mat-card>\r\n </div>\r\n</div>\r\n\r\n<!-- \u2500\u2500\u2500 Footer: JSON + optional live preview \u2500\u2500\u2500 -->\r\n<div class=\"deb-footer\">\r\n <mat-expansion-panel>\r\n <mat-expansion-panel-header>\r\n <mat-panel-title><mat-icon>data_object</mat-icon>&nbsp;Config JSON</mat-panel-title>\r\n </mat-expansion-panel-header>\r\n <pre class=\"deb-json\">{{ json }}</pre>\r\n </mat-expansion-panel>\r\n\r\n <div class=\"deb-preview-slot\">\r\n <ng-content select=\"[ngxBuilderPreview]\"></ng-content>\r\n </div>\r\n</div>\r\n", styles: [":host{--deb-accent: #6366f1;--deb-accent-hover: #4f46e5;--deb-accent-soft: #eeef2;--deb-border: #e2e8f0;--deb-muted: #64748b;--deb-surface: #ffffff;display:block}.deb-toolbar__title{margin-left:8px;font-family:var(--font-heading, sans-serif);font-size:16px;font-weight:700;color:#0f172a}.deb-spacer{flex:1 1 auto}.deb-problems--error{color:#ef4444;font-weight:600}.deb-full{width:100%}.deb-grow{flex:1}.deb-row{display:flex;gap:8px;align-items:center}.deb-section-title{font-family:var(--font-heading, sans-serif);font-size:12px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--deb-muted)}.deb-hint{margin:4px 0 0;font-size:12px;color:var(--deb-muted)}.deb-chip{display:inline-block;padding:3px 10px;border-radius:9999px;background:var(--deb-accent-soft);color:var(--deb-accent-hover);font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.03em}.deb-chip--muted{background:#f1f5f9;color:var(--deb-muted)}.deb-body{display:grid;grid-template-columns:320px minmax(0,1fr) 380px;gap:20px;padding:20px;align-items:start}.deb-col{display:flex;flex-direction:column;gap:16px;min-width:0}.deb-col--right{position:sticky;top:20px}@media (max-width: 1100px){.deb-body{grid-template-columns:1fr}.deb-col--right{position:static}}.deb-field-list{display:flex;flex-direction:column;gap:10px}.deb-field-row{display:flex;align-items:center;gap:10px;padding:10px 14px;border:1px solid var(--deb-border);border-radius:10px;background:var(--deb-surface);cursor:pointer;box-shadow:0 1px 3px #00000008;transition:all .18s cubic-bezier(.16,1,.3,1)}.deb-field-row:hover{border-color:#a5b4fc;background:#fcfaff;transform:translateY(-1px);box-shadow:0 4px 12px #6366f114}.deb-field-row--active{border-color:var(--deb-accent);border-left:4px solid var(--deb-accent);background:#f5f3ff;box-shadow:0 4px 16px #6366f12e}.deb-type-badge{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;padding:2px 7px;border-radius:6px;background:#e0e7ff;color:#4338ca}.deb-drag-handle{cursor:grab;color:#94a3b8;transition:color .15s}.deb-drag-handle:hover,.deb-field-type{color:var(--deb-accent)}.deb-field-meta{display:flex;flex-direction:column;min-width:0;flex:1}.deb-field-label{font-family:var(--font-heading, sans-serif);font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;color:#0f172a;font-size:14px}.deb-field-id{font-size:11px;color:var(--deb-muted);font-family:ui-monospace,SFMono-Regular,monospace}.deb-req{color:#ef4444;font-weight:700}.deb-empty,.deb-inspector__empty{text-align:center;color:var(--deb-muted);padding:40px 16px}.deb-empty mat-icon{transform:scale(1.8);margin-bottom:16px;color:#cbd5e1}.cdk-drag-preview{box-shadow:0 12px 28px #4f46e538;border-radius:10px;border:1px solid var(--deb-accent);background:#fff}.cdk-drag-placeholder{opacity:.35}.cdk-drag-animating{transition:transform .2s cubic-bezier(0,0,.2,1)}.deb-field-list.cdk-drop-list-dragging .deb-field-row:not(.cdk-drag-placeholder){transition:transform .2s cubic-bezier(0,0,.2,1)}.deb-footer{padding:0 20px 20px;display:flex;flex-direction:column;gap:16px}.deb-json{margin:0;max-height:320px;overflow:auto;font-family:ui-monospace,SFMono-Regular,monospace;font-size:12px;line-height:1.5;padding:14px;background:#0f172a;color:#e2e8f0;border-radius:10px}\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.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: DragDropModule }, { kind: "ngmodule", type: MatButtonModule }, { kind: "component", type: i2.MatButton, selector: " button[mat-button], button[mat-raised-button], button[mat-flat-button], button[mat-stroked-button] ", exportAs: ["matButton"] }, { kind: "ngmodule", type: MatCardModule }, { kind: "component", type: i2$1.MatCard, selector: "mat-card", inputs: ["appearance"], exportAs: ["matCard"] }, { kind: "directive", type: i2$1.MatCardContent, selector: "mat-card-content" }, { kind: "component", type: i2$1.MatCardHeader, selector: "mat-card-header" }, { kind: "directive", type: i2$1.MatCardTitle, selector: "mat-card-title, [mat-card-title], [matCardTitle]" }, { kind: "ngmodule", type: MatCheckboxModule }, { kind: "ngmodule", type: MatExpansionModule }, { kind: "component", type: i4$3.MatExpansionPanel, selector: "mat-expansion-panel", inputs: ["hideToggle", "togglePosition"], outputs: ["afterExpand", "afterCollapse"], exportAs: ["matExpansionPanel"] }, { kind: "component", type: i4$3.MatExpansionPanelHeader, selector: "mat-expansion-panel-header", inputs: ["expandedHeight", "collapsedHeight", "tabIndex"] }, { kind: "directive", type: i4$3.MatExpansionPanelTitle, selector: "mat-panel-title" }, { 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: "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"], 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"], outputs: ["openedChange", "opened", "closed", "selectionChange", "valueChange"], exportAs: ["matSelect"] }, { kind: "component", type: i9.MatOption, selector: "mat-option", inputs: ["value", "id", "disabled"], outputs: ["onSelectionChange"], exportAs: ["matOption"] }, { kind: "ngmodule", type: MatSlideToggleModule }, { kind: "component", type: i10.MatSlideToggle, selector: "mat-slide-toggle", inputs: ["name", "id", "labelPosition", "aria-label", "aria-labelledby", "aria-describedby", "required", "color", "disabled", "disableRipple", "tabIndex", "checked", "hideIcon"], outputs: ["change", "toggleChange"], exportAs: ["matSlideToggle"] }, { kind: "ngmodule", type: MatToolbarModule }, { kind: "component", type: i11.MatToolbar, selector: "mat-toolbar", inputs: ["color"], exportAs: ["matToolbar"] }, { 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: FieldPaletteComponent, selector: "ngx-field-palette", outputs: ["pick"] }, { kind: "component", type: TabManagerComponent, selector: "ngx-tab-manager" }, { kind: "component", type: FieldInspectorComponent, selector: "ngx-field-inspector" }, { kind: "component", type: EntityBuilderCanvasComponent, selector: "ngx-entity-builder-canvas" }] });
2757
+ }
2758
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: EntityBuilderComponent, decorators: [{
2759
+ type: Component,
2760
+ args: [{ selector: 'ngx-entity-builder', standalone: true, providers: [BuilderStore], imports: [
2761
+ FormsModule,
2762
+ DragDropModule,
2763
+ MatButtonModule,
2764
+ MatCardModule,
2765
+ MatCheckboxModule,
2766
+ MatExpansionModule,
2767
+ MatFormFieldModule,
2768
+ MatIconModule,
2769
+ MatInputModule,
2770
+ MatSelectModule,
2771
+ MatSlideToggleModule,
2772
+ MatToolbarModule,
2773
+ MatTooltipModule,
2774
+ FieldPaletteComponent,
2775
+ TabManagerComponent,
2776
+ FieldInspectorComponent,
2777
+ EntityBuilderCanvasComponent,
2778
+ ], template: "<mat-toolbar class=\"deb-toolbar\" color=\"primary\">\r\n <mat-icon>dashboard_customize</mat-icon>\r\n <span class=\"deb-toolbar__title\">Entity Builder</span>\r\n <span class=\"deb-spacer\"></span>\r\n\r\n @if (store.problems().length) {\r\n <button\r\n mat-button\r\n type=\"button\"\r\n class=\"deb-problems\"\r\n [class.deb-problems--error]=\"!store.isValid()\"\r\n [matTooltip]=\"store.problems().length + ' issue(s) \u2014 hover items in the list'\"\r\n >\r\n <mat-icon>{{ store.isValid() ? 'warning' : 'error' }}</mat-icon>\r\n {{ store.errors().length }} / {{ store.problems().length }}\r\n </button>\r\n }\r\n\r\n <button mat-button type=\"button\" (click)=\"copyJson()\">\r\n <mat-icon>content_copy</mat-icon> Copy JSON\r\n </button>\r\n <button mat-flat-button color=\"accent\" type=\"button\" [disabled]=\"!store.isValid()\" (click)=\"doSave()\">\r\n <mat-icon>save</mat-icon> Save\r\n </button>\r\n</mat-toolbar>\r\n\r\n<div class=\"deb-body\">\r\n <!-- \u2500\u2500\u2500 Left: settings, palette, tabs \u2500\u2500\u2500 -->\r\n <div class=\"deb-col deb-col--left\">\r\n <mat-card>\r\n <mat-card-content>\r\n <mat-form-field appearance=\"outline\" subscriptSizing=\"dynamic\" class=\"deb-full\">\r\n <mat-label>Entity name</mat-label>\r\n <input\r\n matInput\r\n [ngModel]=\"store.config().entity\"\r\n (ngModelChange)=\"store.setEntityName($event)\"\r\n placeholder=\"e.g. clients\"\r\n />\r\n </mat-form-field>\r\n\r\n <div class=\"deb-row\">\r\n <mat-form-field appearance=\"outline\" subscriptSizing=\"dynamic\" class=\"deb-grow\">\r\n <mat-label>Editing language</mat-label>\r\n <mat-select\r\n [ngModel]=\"store.activeLanguage()\"\r\n (ngModelChange)=\"store.setActiveLanguage($event)\"\r\n >\r\n @for (l of languages; track l) {\r\n <mat-option [value]=\"l\">{{ l }}</mat-option>\r\n }\r\n </mat-select>\r\n </mat-form-field>\r\n </div>\r\n\r\n <mat-slide-toggle\r\n [ngModel]=\"store.config().maskData ?? false\"\r\n (ngModelChange)=\"store.setMaskData($event)\"\r\n >Mask this entity (RBAC)</mat-slide-toggle>\r\n\r\n <mat-expansion-panel class=\"deb-rbac\">\r\n <mat-expansion-panel-header>\r\n <mat-panel-title>Permissions</mat-panel-title>\r\n </mat-expansion-panel-header>\r\n @for (action of rbacActions; track action) {\r\n @if (availableRoles.length) {\r\n <mat-form-field appearance=\"outline\" subscriptSizing=\"dynamic\" class=\"deb-full\">\r\n <mat-label>{{ action }} roles</mat-label>\r\n <mat-select multiple [ngModel]=\"rolesFor(action)\" (ngModelChange)=\"setRoles(action, $event)\">\r\n @for (role of availableRoles; track role) {\r\n <mat-option [value]=\"role\">{{ role }}</mat-option>\r\n }\r\n </mat-select>\r\n </mat-form-field>\r\n } @else {\r\n <mat-form-field appearance=\"outline\" subscriptSizing=\"dynamic\" class=\"deb-full\">\r\n <mat-label>{{ action }} roles (comma-separated)</mat-label>\r\n <input\r\n matInput\r\n [ngModel]=\"rolesFor(action).join(', ')\"\r\n (ngModelChange)=\"setRolesFromText(action, $event)\"\r\n />\r\n </mat-form-field>\r\n }\r\n }\r\n <p class=\"deb-hint\">Empty = no restriction for that action.</p>\r\n </mat-expansion-panel>\r\n </mat-card-content>\r\n </mat-card>\r\n\r\n <mat-card>\r\n <mat-card-header><mat-card-title>Add field</mat-card-title></mat-card-header>\r\n <mat-card-content>\r\n <ngx-field-palette (pick)=\"store.addField($event)\"></ngx-field-palette>\r\n </mat-card-content>\r\n </mat-card>\r\n\r\n <mat-card>\r\n <mat-card-content>\r\n <ngx-tab-manager></ngx-tab-manager>\r\n </mat-card-content>\r\n </mat-card>\r\n </div>\r\n\r\n <!-- \u2500\u2500\u2500 Center: field canvas \u2500\u2500\u2500 -->\r\n <div class=\"deb-col deb-col--center\">\r\n <ngx-entity-builder-canvas />\r\n </div>\r\n\r\n <!-- \u2500\u2500\u2500 Right: inspector \u2500\u2500\u2500 -->\r\n <div class=\"deb-col deb-col--right\">\r\n <mat-card>\r\n <mat-card-header><mat-card-title>Field properties</mat-card-title></mat-card-header>\r\n <mat-card-content>\r\n <ngx-field-inspector></ngx-field-inspector>\r\n </mat-card-content>\r\n </mat-card>\r\n </div>\r\n</div>\r\n\r\n<!-- \u2500\u2500\u2500 Footer: JSON + optional live preview \u2500\u2500\u2500 -->\r\n<div class=\"deb-footer\">\r\n <mat-expansion-panel>\r\n <mat-expansion-panel-header>\r\n <mat-panel-title><mat-icon>data_object</mat-icon>&nbsp;Config JSON</mat-panel-title>\r\n </mat-expansion-panel-header>\r\n <pre class=\"deb-json\">{{ json }}</pre>\r\n </mat-expansion-panel>\r\n\r\n <div class=\"deb-preview-slot\">\r\n <ng-content select=\"[ngxBuilderPreview]\"></ng-content>\r\n </div>\r\n</div>\r\n", styles: [":host{--deb-accent: #6366f1;--deb-accent-hover: #4f46e5;--deb-accent-soft: #eeef2;--deb-border: #e2e8f0;--deb-muted: #64748b;--deb-surface: #ffffff;display:block}.deb-toolbar__title{margin-left:8px;font-family:var(--font-heading, sans-serif);font-size:16px;font-weight:700;color:#0f172a}.deb-spacer{flex:1 1 auto}.deb-problems--error{color:#ef4444;font-weight:600}.deb-full{width:100%}.deb-grow{flex:1}.deb-row{display:flex;gap:8px;align-items:center}.deb-section-title{font-family:var(--font-heading, sans-serif);font-size:12px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--deb-muted)}.deb-hint{margin:4px 0 0;font-size:12px;color:var(--deb-muted)}.deb-chip{display:inline-block;padding:3px 10px;border-radius:9999px;background:var(--deb-accent-soft);color:var(--deb-accent-hover);font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.03em}.deb-chip--muted{background:#f1f5f9;color:var(--deb-muted)}.deb-body{display:grid;grid-template-columns:320px minmax(0,1fr) 380px;gap:20px;padding:20px;align-items:start}.deb-col{display:flex;flex-direction:column;gap:16px;min-width:0}.deb-col--right{position:sticky;top:20px}@media (max-width: 1100px){.deb-body{grid-template-columns:1fr}.deb-col--right{position:static}}.deb-field-list{display:flex;flex-direction:column;gap:10px}.deb-field-row{display:flex;align-items:center;gap:10px;padding:10px 14px;border:1px solid var(--deb-border);border-radius:10px;background:var(--deb-surface);cursor:pointer;box-shadow:0 1px 3px #00000008;transition:all .18s cubic-bezier(.16,1,.3,1)}.deb-field-row:hover{border-color:#a5b4fc;background:#fcfaff;transform:translateY(-1px);box-shadow:0 4px 12px #6366f114}.deb-field-row--active{border-color:var(--deb-accent);border-left:4px solid var(--deb-accent);background:#f5f3ff;box-shadow:0 4px 16px #6366f12e}.deb-type-badge{font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;padding:2px 7px;border-radius:6px;background:#e0e7ff;color:#4338ca}.deb-drag-handle{cursor:grab;color:#94a3b8;transition:color .15s}.deb-drag-handle:hover,.deb-field-type{color:var(--deb-accent)}.deb-field-meta{display:flex;flex-direction:column;min-width:0;flex:1}.deb-field-label{font-family:var(--font-heading, sans-serif);font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;color:#0f172a;font-size:14px}.deb-field-id{font-size:11px;color:var(--deb-muted);font-family:ui-monospace,SFMono-Regular,monospace}.deb-req{color:#ef4444;font-weight:700}.deb-empty,.deb-inspector__empty{text-align:center;color:var(--deb-muted);padding:40px 16px}.deb-empty mat-icon{transform:scale(1.8);margin-bottom:16px;color:#cbd5e1}.cdk-drag-preview{box-shadow:0 12px 28px #4f46e538;border-radius:10px;border:1px solid var(--deb-accent);background:#fff}.cdk-drag-placeholder{opacity:.35}.cdk-drag-animating{transition:transform .2s cubic-bezier(0,0,.2,1)}.deb-field-list.cdk-drop-list-dragging .deb-field-row:not(.cdk-drag-placeholder){transition:transform .2s cubic-bezier(0,0,.2,1)}.deb-footer{padding:0 20px 20px;display:flex;flex-direction:column;gap:16px}.deb-json{margin:0;max-height:320px;overflow:auto;font-family:ui-monospace,SFMono-Regular,monospace;font-size:12px;line-height:1.5;padding:14px;background:#0f172a;color:#e2e8f0;border-radius:10px}\n"] }]
2779
+ }], ctorParameters: () => [], propDecorators: { config: [{
2780
+ type: Input
2781
+ }], languages: [{
2782
+ type: Input
2783
+ }], availableRoles: [{
2784
+ type: Input
2785
+ }], commonModules: [{
2786
+ type: Input
2787
+ }], configChange: [{
2788
+ type: Output
2789
+ }], save: [{
2790
+ type: Output
2791
+ }] } });
2792
+
2793
+ /**
2794
+ * ConnectionSourceConfigComponent — allows configuring connection source settings
2795
+ * for an entity or field (Phase 7.2).
2796
+ */
2797
+ class ConnectionSourceConfigComponent {
2798
+ store = inject(BuilderStore);
2799
+ field = computed(() => this.store.selectedField());
2800
+ connectionSourceEntity(field) {
2801
+ return field.connectionSource?.entity ?? '';
2802
+ }
2803
+ setConnectionSourceEntity(field, entity) {
2804
+ const trimmed = entity.trim();
2805
+ const existing = field.connectionSource ?? {};
2806
+ if (!trimmed && !existing.targetField) {
2807
+ this.store.updateField(field.id, { connectionSource: undefined });
2808
+ }
2809
+ else {
2810
+ this.store.updateField(field.id, {
2811
+ connectionSource: { ...existing, entity: trimmed },
2812
+ });
2813
+ }
2814
+ }
2815
+ connectionTargetField(field) {
2816
+ return field.connectionSource?.targetField ?? '';
2817
+ }
2818
+ setConnectionTargetField(field, targetField) {
2819
+ const trimmed = targetField.trim();
2820
+ const existing = field.connectionSource ?? {};
2821
+ if (!trimmed && !existing.entity) {
2822
+ this.store.updateField(field.id, { connectionSource: undefined });
2823
+ }
2824
+ else {
2825
+ this.store.updateField(field.id, {
2826
+ connectionSource: { ...existing, targetField: trimmed },
2827
+ });
2828
+ }
2829
+ }
2830
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: ConnectionSourceConfigComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
2831
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "17.3.12", type: ConnectionSourceConfigComponent, isStandalone: true, selector: "ngx-connection-source-config", ngImport: i0, template: `
2832
+ @if (field(); as f) {
2833
+ <div class="deb-connection-source">
2834
+ <span class="deb-section-title">Connection Source</span>
2835
+ <mat-form-field appearance="outline" subscriptSizing="dynamic" class="deb-full">
2836
+ <mat-label>Source Entity</mat-label>
2837
+ <input
2838
+ matInput
2839
+ data-testid="connection-source-entity"
2840
+ [ngModel]="connectionSourceEntity(f)"
2841
+ (ngModelChange)="setConnectionSourceEntity(f, $event)"
2842
+ placeholder="e.g. organizations"
2843
+ />
2844
+ </mat-form-field>
2845
+
2846
+ <mat-form-field appearance="outline" subscriptSizing="dynamic" class="deb-full">
2847
+ <mat-label>Target Field ID</mat-label>
2848
+ <input
2849
+ matInput
2850
+ data-testid="connection-target-field"
2851
+ [ngModel]="connectionTargetField(f)"
2852
+ (ngModelChange)="setConnectionTargetField(f, $event)"
2853
+ placeholder="e.g. companyId"
2854
+ />
2855
+ </mat-form-field>
2856
+ </div>
2857
+ }
2858
+ `, isInline: true, dependencies: [{ kind: "ngmodule", type: CommonModule }, { 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.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: 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: "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"], exportAs: ["matInput"] }, { kind: "ngmodule", type: MatSelectModule }] });
2859
+ }
2860
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.12", ngImport: i0, type: ConnectionSourceConfigComponent, decorators: [{
2861
+ type: Component,
2862
+ args: [{
2863
+ selector: 'ngx-connection-source-config',
2864
+ standalone: true,
2865
+ imports: [
2866
+ CommonModule,
2867
+ FormsModule,
2868
+ MatFormFieldModule,
2869
+ MatInputModule,
2870
+ MatSelectModule,
2871
+ ],
2872
+ template: `
2873
+ @if (field(); as f) {
2874
+ <div class="deb-connection-source">
2875
+ <span class="deb-section-title">Connection Source</span>
2876
+ <mat-form-field appearance="outline" subscriptSizing="dynamic" class="deb-full">
2877
+ <mat-label>Source Entity</mat-label>
2878
+ <input
2879
+ matInput
2880
+ data-testid="connection-source-entity"
2881
+ [ngModel]="connectionSourceEntity(f)"
2882
+ (ngModelChange)="setConnectionSourceEntity(f, $event)"
2883
+ placeholder="e.g. organizations"
2884
+ />
2885
+ </mat-form-field>
2886
+
2887
+ <mat-form-field appearance="outline" subscriptSizing="dynamic" class="deb-full">
2888
+ <mat-label>Target Field ID</mat-label>
2889
+ <input
2890
+ matInput
2891
+ data-testid="connection-target-field"
2892
+ [ngModel]="connectionTargetField(f)"
2893
+ (ngModelChange)="setConnectionTargetField(f, $event)"
2894
+ placeholder="e.g. companyId"
2895
+ />
2896
+ </mat-form-field>
2897
+ </div>
2898
+ }
2899
+ `,
2900
+ }]
2901
+ }] });
2902
+
2903
+ // Main component
2904
+
2905
+ /**
2906
+ * Generated bundle index. Do not edit.
2907
+ */
2908
+
2909
+ export { BuilderStore, ConnectionSourceConfigComponent, EntityBuilderCanvasComponent, EntityBuilderComponent, EntityBuilderTreeNodeComponent, EntityReferenceConfigComponent, FieldInspectorComponent, FieldPaletteComponent, FieldRulesListComponent, RuleFormComponent, SYSTEM_DEFAULT_CAN_EDIT, TabManagerComponent };
2910
+ //# sourceMappingURL=ngx-dynamic-entity-builder.mjs.map