ngx-dynamic-entity-builder 1.6.0 → 1.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import * as i0 from '@angular/core';
|
|
2
|
-
import { signal, computed, Injectable, inject, ChangeDetectionStrategy, Component, EventEmitter, Output, Input, effect } from '@angular/core';
|
|
2
|
+
import { signal, computed, Injectable, inject, ChangeDetectionStrategy, Component, EventEmitter, Output, Input, effect, HostListener } from '@angular/core';
|
|
3
3
|
import * as i1 from '@angular/forms';
|
|
4
4
|
import { FormsModule } from '@angular/forms';
|
|
5
5
|
import * as i2 from '@angular/material/button';
|
|
@@ -92,6 +92,125 @@ class BuilderStore {
|
|
|
92
92
|
* made any role-checking predicate answer false for everyone.
|
|
93
93
|
*/
|
|
94
94
|
_userRoles = signal([], ...(ngDevMode ? [{ debugName: "_userRoles" }] : /* istanbul ignore next */ []));
|
|
95
|
+
// ─── Undo / redo ────────────────────────────────────────────────────────────
|
|
96
|
+
//
|
|
97
|
+
// History is a list of {config, rules} pairs, because the builder's state is two signals
|
|
98
|
+
// and undoing one without the other would leave a rule pointing at a field that no longer
|
|
99
|
+
// exists.
|
|
100
|
+
//
|
|
101
|
+
// Entries hold **references, not clones**. `mutate` builds a new config and `mutateField`
|
|
102
|
+
// shares structure along the unchanged path, so every snapshot already refers to immutable
|
|
103
|
+
// objects and unchanged subtrees are shared between entries. That also removes the usual
|
|
104
|
+
// re-entrancy problem for free: undo puts the stored object back, so the recording effect
|
|
105
|
+
// sees the exact reference already sitting at the cursor and skips it. No suppression flag
|
|
106
|
+
// to get out of step.
|
|
107
|
+
history = [];
|
|
108
|
+
cursor = signal(-1, ...(ngDevMode ? [{ debugName: "cursor" }] : /* istanbul ignore next */ []));
|
|
109
|
+
historyLength = signal(0, ...(ngDevMode ? [{ debugName: "historyLength" }] : /* istanbul ignore next */ []));
|
|
110
|
+
lastEditAt = 0;
|
|
111
|
+
/** Consecutive edits closer together than this fold into one undo step. */
|
|
112
|
+
static COALESCE_MS = 400;
|
|
113
|
+
canUndo = computed(() => this.cursor() > 0, ...(ngDevMode ? [{ debugName: "canUndo" }] : /* istanbul ignore next */ []));
|
|
114
|
+
canRedo = computed(() => this.cursor() < this.historyLength() - 1, ...(ngDevMode ? [{ debugName: "canRedo" }] : /* istanbul ignore next */ []));
|
|
115
|
+
/**
|
|
116
|
+
* Fold the current state into history.
|
|
117
|
+
*
|
|
118
|
+
* Called explicitly by the write paths rather than from an `effect`. An effect would have
|
|
119
|
+
* been fewer call sites, but it requires an injection context: `BuilderStore` had no
|
|
120
|
+
* constructor, so `new BuilderStore()` was legal, and adding one broke every caller that
|
|
121
|
+
* did it with NG0203. Explicit calls also make the timing deterministic — a test can
|
|
122
|
+
* assert straight after an edit instead of flushing effects first.
|
|
123
|
+
*
|
|
124
|
+
* Calling it twice for one operation is harmless: the second call sees the same signal
|
|
125
|
+
* references already at the cursor and returns.
|
|
126
|
+
*
|
|
127
|
+
* `setFieldLabel` is bound to a keystroke, so recording every emission would make undo
|
|
128
|
+
* walk back one character at a time. Two consecutive edits merge when they land inside
|
|
129
|
+
* `COALESCE_MS` *and* the structure is unchanged — a rename coalesces, while adding,
|
|
130
|
+
* removing or moving anything always earns its own step however fast it is clicked.
|
|
131
|
+
*/
|
|
132
|
+
record() {
|
|
133
|
+
const config = this._config();
|
|
134
|
+
const rules = this._rules();
|
|
135
|
+
const at = this.cursor();
|
|
136
|
+
const top = at >= 0 ? this.history[at] : undefined;
|
|
137
|
+
if (top && top.config === config && top.rules === rules)
|
|
138
|
+
return;
|
|
139
|
+
const now = Date.now();
|
|
140
|
+
const snapshot = { config, rules, shape: this.shapeOf(config, rules) };
|
|
141
|
+
if (top &&
|
|
142
|
+
now - this.lastEditAt < BuilderStore.COALESCE_MS &&
|
|
143
|
+
top.shape === snapshot.shape &&
|
|
144
|
+
at === this.history.length - 1) {
|
|
145
|
+
this.history[at] = snapshot;
|
|
146
|
+
this.lastEditAt = now;
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
// A new edit after an undo discards the redo branch, which is what every editor does.
|
|
150
|
+
this.history.length = at + 1;
|
|
151
|
+
this.history.push(snapshot);
|
|
152
|
+
this.cursor.set(this.history.length - 1);
|
|
153
|
+
this.historyLength.set(this.history.length);
|
|
154
|
+
this.lastEditAt = now;
|
|
155
|
+
}
|
|
156
|
+
/** Counts that distinguish a structural edit from a value edit. */
|
|
157
|
+
shapeOf(config, rules) {
|
|
158
|
+
let fields = 0;
|
|
159
|
+
let tabs = 0;
|
|
160
|
+
const walkFields = (list) => {
|
|
161
|
+
for (const f of list ?? []) {
|
|
162
|
+
fields += 1;
|
|
163
|
+
walkFields(f.children);
|
|
164
|
+
}
|
|
165
|
+
};
|
|
166
|
+
const walkTabs = (list) => {
|
|
167
|
+
for (const t of list ?? []) {
|
|
168
|
+
tabs += 1;
|
|
169
|
+
walkFields(t.fields);
|
|
170
|
+
walkTabs(t.children);
|
|
171
|
+
}
|
|
172
|
+
};
|
|
173
|
+
walkTabs(config.tabs);
|
|
174
|
+
return `${tabs}:${fields}:${rules.length}`;
|
|
175
|
+
}
|
|
176
|
+
/** Start history again from the state just loaded. Nothing before it is undoable. */
|
|
177
|
+
resetHistory() {
|
|
178
|
+
this.history.length = 0;
|
|
179
|
+
this.history.push({
|
|
180
|
+
config: this._config(),
|
|
181
|
+
rules: this._rules(),
|
|
182
|
+
shape: this.shapeOf(this._config(), this._rules()),
|
|
183
|
+
});
|
|
184
|
+
this.cursor.set(0);
|
|
185
|
+
this.historyLength.set(1);
|
|
186
|
+
this.lastEditAt = 0;
|
|
187
|
+
}
|
|
188
|
+
undo() {
|
|
189
|
+
if (!this.canUndo())
|
|
190
|
+
return;
|
|
191
|
+
this.applyHistory(this.cursor() - 1);
|
|
192
|
+
}
|
|
193
|
+
redo() {
|
|
194
|
+
if (!this.canRedo())
|
|
195
|
+
return;
|
|
196
|
+
this.applyHistory(this.cursor() + 1);
|
|
197
|
+
}
|
|
198
|
+
applyHistory(index) {
|
|
199
|
+
const snapshot = this.history[index];
|
|
200
|
+
if (!snapshot)
|
|
201
|
+
return;
|
|
202
|
+
this.cursor.set(index);
|
|
203
|
+
// Order matters only in that both land before the effect runs; it then sees references
|
|
204
|
+
// identical to this entry and records nothing.
|
|
205
|
+
this._config.set(snapshot.config);
|
|
206
|
+
this._rules.set(snapshot.rules);
|
|
207
|
+
this._isDirty.set(true);
|
|
208
|
+
// The selected field may not exist in the state being restored.
|
|
209
|
+
const selected = this._selectedFieldId();
|
|
210
|
+
if (selected && !this.findFieldInTabs(snapshot.config.tabs, selected)) {
|
|
211
|
+
this._selectedFieldId.set(null);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
95
214
|
config = this._config.asReadonly();
|
|
96
215
|
selectedFieldId = this._selectedFieldId.asReadonly();
|
|
97
216
|
activeLanguage = this._activeLanguage.asReadonly();
|
|
@@ -166,6 +285,7 @@ class BuilderStore {
|
|
|
166
285
|
for (const field of this.getAllFields(next.tabs))
|
|
167
286
|
this.manualIds.add(field.id);
|
|
168
287
|
this._isDirty.set(false);
|
|
288
|
+
this.resetHistory();
|
|
169
289
|
}
|
|
170
290
|
reset(entity = '') {
|
|
171
291
|
this._config.set(this.emptyConfig(entity));
|
|
@@ -173,6 +293,7 @@ class BuilderStore {
|
|
|
173
293
|
this._rules.set([]);
|
|
174
294
|
this.manualIds.clear();
|
|
175
295
|
this._isDirty.set(false);
|
|
296
|
+
this.resetHistory();
|
|
176
297
|
}
|
|
177
298
|
resetDirty() {
|
|
178
299
|
this._isDirty.set(false);
|
|
@@ -264,21 +385,62 @@ class BuilderStore {
|
|
|
264
385
|
walkFields(tab.fields);
|
|
265
386
|
return ids;
|
|
266
387
|
}
|
|
267
|
-
|
|
388
|
+
/**
|
|
389
|
+
* Resolve a field by key, where a key is either its `refererField` path or a bare id.
|
|
390
|
+
*
|
|
391
|
+
* Every resolution in this store funnels through here, which is why a bare id was enough
|
|
392
|
+
* to edit the wrong field: with `personal.address` and `work.address` both present, the
|
|
393
|
+
* first match won, so selecting or editing the second one silently addressed the first.
|
|
394
|
+
*
|
|
395
|
+
* The path is tried across the whole tree before any bare id is, so an exact key never
|
|
396
|
+
* loses to a nearer-but-wrong match. Bare ids still resolve — every existing caller and
|
|
397
|
+
* spec passes one, and for an unambiguous id it is the same field either way.
|
|
398
|
+
*/
|
|
399
|
+
findFieldInTabs(tabs = [], key) {
|
|
400
|
+
return this.findFieldBy(tabs, f => f.refererField === key) ?? this.findFieldBy(tabs, f => f.id === key);
|
|
401
|
+
}
|
|
402
|
+
findFieldBy(tabs = [], match) {
|
|
268
403
|
for (const t of tabs) {
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
return found;
|
|
273
|
-
}
|
|
404
|
+
const found = t.fields?.find(match);
|
|
405
|
+
if (found)
|
|
406
|
+
return found;
|
|
274
407
|
if (t.children) {
|
|
275
|
-
const
|
|
276
|
-
if (
|
|
277
|
-
return
|
|
408
|
+
const nested = this.findFieldBy(t.children, match);
|
|
409
|
+
if (nested)
|
|
410
|
+
return nested;
|
|
278
411
|
}
|
|
279
412
|
}
|
|
280
413
|
return null;
|
|
281
414
|
}
|
|
415
|
+
/**
|
|
416
|
+
* The key a caller should hand back to address this exact field. Templates use it so a
|
|
417
|
+
* click on the second of two same-named fields selects that one.
|
|
418
|
+
*/
|
|
419
|
+
keyOf(field) {
|
|
420
|
+
return field.refererField || field.id;
|
|
421
|
+
}
|
|
422
|
+
/**
|
|
423
|
+
* Whether this exact field is the selected one.
|
|
424
|
+
*
|
|
425
|
+
* Not `selectedFieldId() === field.id`: selection may hold a bare id or a path depending
|
|
426
|
+
* on who set it, and with two fields sharing an id a bare comparison lights up both rows
|
|
427
|
+
* — or neither, once the click starts storing a path. Resolving both sides answers for
|
|
428
|
+
* the field itself.
|
|
429
|
+
*/
|
|
430
|
+
isSelected(field) {
|
|
431
|
+
const key = this._selectedFieldId();
|
|
432
|
+
if (!key)
|
|
433
|
+
return false;
|
|
434
|
+
// Same key, same field — and this is the path a row click takes, so it is the common
|
|
435
|
+
// case. It also covers a field handed in as an @Input that the store's own config has
|
|
436
|
+
// no object for, which identity alone cannot.
|
|
437
|
+
if (key === this.keyOf(field))
|
|
438
|
+
return true;
|
|
439
|
+
// Otherwise the selection is in the other form (a bare id against a field that carries a
|
|
440
|
+
// path, or vice versa), so let it resolve and compare the field it names.
|
|
441
|
+
const resolved = this.findFieldInTabs(this._config().tabs, key);
|
|
442
|
+
return !!resolved && resolved === field;
|
|
443
|
+
}
|
|
282
444
|
// ─── Field CRUD ─────────────────────────────────────────────────────────────
|
|
283
445
|
addField(type, targetTabId) {
|
|
284
446
|
const meta = getFieldTypeMeta(type);
|
|
@@ -321,7 +483,7 @@ class BuilderStore {
|
|
|
321
483
|
return;
|
|
322
484
|
if (!this.findFieldInTabs(this._config().tabs, oldId))
|
|
323
485
|
return;
|
|
324
|
-
this.applyRename(oldId, trimmed);
|
|
486
|
+
this.applyRename(oldId, oldId, trimmed);
|
|
325
487
|
this.manualIds.delete(oldId);
|
|
326
488
|
this.manualIds.add(trimmed);
|
|
327
489
|
}
|
|
@@ -337,9 +499,23 @@ class BuilderStore {
|
|
|
337
499
|
* That was survivable while renaming was a rare manual act; label-derived ids make it
|
|
338
500
|
* routine, so the references move with it.
|
|
339
501
|
*/
|
|
340
|
-
|
|
502
|
+
/**
|
|
503
|
+
* `key` addresses the field to rename; `oldId` is the bare id every *reference* to it
|
|
504
|
+
* carries. They were one parameter, which worked only while an id was unique across the
|
|
505
|
+
* config: resolving needs the path, while `showWhen`, `parentField`, `autoPatch` targets
|
|
506
|
+
* and rules are all keyed by bare id and never match a path.
|
|
507
|
+
*/
|
|
508
|
+
applyRename(key, oldId, newId) {
|
|
509
|
+
// Selection is held as whatever the caller passed — `addField` stores a bare id while a
|
|
510
|
+
// canvas click stores a path — so comparing the two strings said "not selected" for a
|
|
511
|
+
// field that plainly was, and the inspector emptied itself mid-rename. Compare the
|
|
512
|
+
// fields the two keys resolve to instead.
|
|
513
|
+
const selectedKey = this._selectedFieldId();
|
|
514
|
+
const tabsNow = this._config().tabs;
|
|
515
|
+
const wasSelected = !!selectedKey &&
|
|
516
|
+
this.findFieldInTabs(tabsNow, selectedKey) === this.findFieldInTabs(tabsNow, key);
|
|
341
517
|
this.mutate(draft => {
|
|
342
|
-
const field = this.findFieldInTabs(draft.tabs,
|
|
518
|
+
const field = this.findFieldInTabs(draft.tabs, key);
|
|
343
519
|
if (!field)
|
|
344
520
|
return;
|
|
345
521
|
field.id = newId;
|
|
@@ -386,19 +562,41 @@ class BuilderStore {
|
|
|
386
562
|
fieldId: rule.fieldId === oldId ? newId : rule.fieldId,
|
|
387
563
|
targets: rule.targets.map(t => t.type === 'field' && t.id === oldId ? { ...t, id: newId } : t),
|
|
388
564
|
})));
|
|
389
|
-
|
|
390
|
-
|
|
565
|
+
// `mutate` restamps `refererField`, so the key's last segment moves with the id. Without
|
|
566
|
+
// this the selection points at a path nothing resolves to any more, and the inspector
|
|
567
|
+
// empties itself mid-keystroke as soon as a typed label derives a new id.
|
|
568
|
+
this.record();
|
|
569
|
+
if (wasSelected) {
|
|
570
|
+
// Keep the shape the selection already had: a bare id stays bare, a path keeps its
|
|
571
|
+
// scope and moves only its last segment.
|
|
572
|
+
const cut = selectedKey.lastIndexOf('.');
|
|
573
|
+
this._selectedFieldId.set(cut === -1 ? newId : `${selectedKey.slice(0, cut + 1)}${newId}`);
|
|
574
|
+
}
|
|
391
575
|
}
|
|
392
|
-
removeField(
|
|
576
|
+
removeField(key) {
|
|
577
|
+
let removedId = null;
|
|
393
578
|
this.mutate(draft => {
|
|
579
|
+
// Resolve inside the draft, then remove that one by identity. Filtering on
|
|
580
|
+
// `f.id !== id` deleted every field sharing the id, so removing `work.address` took
|
|
581
|
+
// `personal.address` with it.
|
|
582
|
+
const target = this.findFieldInTabs(draft.tabs, key);
|
|
583
|
+
if (!target)
|
|
584
|
+
return;
|
|
585
|
+
removedId = target.id;
|
|
394
586
|
for (const tab of this.flattenTabs(draft.tabs)) {
|
|
395
587
|
if (tab.fields) {
|
|
396
|
-
tab.fields = tab.fields.filter(f => f
|
|
588
|
+
tab.fields = tab.fields.filter(f => f !== target);
|
|
397
589
|
}
|
|
398
590
|
}
|
|
399
591
|
});
|
|
400
|
-
|
|
401
|
-
|
|
592
|
+
if (removedId === null)
|
|
593
|
+
return;
|
|
594
|
+
// `manualIds` is keyed by bare id and shared by every field carrying it, so it is only
|
|
595
|
+
// safe to forget once no field uses that id any more.
|
|
596
|
+
if (!this.getAllFields(this._config().tabs ?? []).some(f => f.id === removedId)) {
|
|
597
|
+
this.manualIds.delete(removedId);
|
|
598
|
+
}
|
|
599
|
+
if (this._selectedFieldId() === key)
|
|
402
600
|
this._selectedFieldId.set(null);
|
|
403
601
|
}
|
|
404
602
|
duplicateField(id) {
|
|
@@ -425,11 +623,17 @@ class BuilderStore {
|
|
|
425
623
|
this._selectedFieldId.set(newId);
|
|
426
624
|
return newId;
|
|
427
625
|
}
|
|
428
|
-
moveField(
|
|
626
|
+
moveField(key, direction) {
|
|
429
627
|
this.mutate(draft => {
|
|
628
|
+
// Resolve by key, then locate that object — `f.id === id` moved whichever same-named
|
|
629
|
+
// field the walk reached first, which for two `address` fields was never the one the
|
|
630
|
+
// arrow button belonged to.
|
|
631
|
+
const wanted = this.findFieldInTabs(draft.tabs, key);
|
|
632
|
+
if (!wanted)
|
|
633
|
+
return;
|
|
430
634
|
for (const tab of this.flattenTabs(draft.tabs)) {
|
|
431
635
|
const fields = tab.fields ?? [];
|
|
432
|
-
const from = fields.findIndex(f => f
|
|
636
|
+
const from = fields.findIndex(f => f === wanted);
|
|
433
637
|
if (from !== -1) {
|
|
434
638
|
const to = from + direction;
|
|
435
639
|
if (to < 0 || to >= fields.length)
|
|
@@ -461,8 +665,13 @@ class BuilderStore {
|
|
|
461
665
|
const targetTab = this.flattenTabs(draft.tabs).find(t => t.id === targetTabId);
|
|
462
666
|
if (!targetTab)
|
|
463
667
|
return;
|
|
668
|
+
// Resolve by key first so a path names one field, then locate that object. Matching
|
|
669
|
+
// `f.id === id` moved whichever same-named field came first in the walk.
|
|
670
|
+
const wanted = this.findFieldInTabs(draft.tabs, id);
|
|
671
|
+
if (!wanted)
|
|
672
|
+
return;
|
|
464
673
|
for (const tab of this.flattenTabs(draft.tabs)) {
|
|
465
|
-
const index = (tab.fields ?? []).findIndex(f => f
|
|
674
|
+
const index = (tab.fields ?? []).findIndex(f => f === wanted);
|
|
466
675
|
if (index === -1)
|
|
467
676
|
continue;
|
|
468
677
|
if (tab.id === targetTabId)
|
|
@@ -509,17 +718,25 @@ class BuilderStore {
|
|
|
509
718
|
* Only the config's default language drives the id; translating a label must not rename
|
|
510
719
|
* anything.
|
|
511
720
|
*/
|
|
512
|
-
setFieldLabel(
|
|
721
|
+
setFieldLabel(key, language, value) {
|
|
513
722
|
// Bound to a keystroke event, so this uses the structural-sharing path.
|
|
514
|
-
this.mutateField(
|
|
723
|
+
this.mutateField(key, field => ({ ...field, label: { ...field.label, [language]: value } }));
|
|
515
724
|
if (language !== (this._config().defaultLanguage ?? 'en'))
|
|
516
725
|
return;
|
|
517
|
-
|
|
726
|
+
// The mutation is addressed by key, but everything below is about the *id*: `manualIds`
|
|
727
|
+
// records bare ids, `availableId` compares bare ids, and a rename replaces one. Reading
|
|
728
|
+
// the field's own id keeps the two straight — passing the path into `manualIds.has`
|
|
729
|
+
// would never match, so a pinned id would silently start following the label again.
|
|
730
|
+
const field = this.findFieldInTabs(this._config().tabs, key);
|
|
731
|
+
if (!field)
|
|
732
|
+
return;
|
|
733
|
+
const currentId = field.id;
|
|
734
|
+
if (this.manualIds.has(currentId))
|
|
518
735
|
return;
|
|
519
736
|
const derived = labelToId(value);
|
|
520
|
-
if (!derived || derived ===
|
|
737
|
+
if (!derived || derived === currentId)
|
|
521
738
|
return;
|
|
522
|
-
this.applyRename(
|
|
739
|
+
this.applyRename(this.keyOf(field), currentId, this.availableId(derived, currentId));
|
|
523
740
|
}
|
|
524
741
|
/** `derived`, or `derived_2`, `derived_3`… if another field already holds it. */
|
|
525
742
|
availableId(derived, currentId) {
|
|
@@ -909,17 +1126,21 @@ class BuilderStore {
|
|
|
909
1126
|
}, ...(ngDevMode ? [{ debugName: "rulesForSelectedField" }] : /* istanbul ignore next */ []));
|
|
910
1127
|
loadRules(rules) {
|
|
911
1128
|
this._rules.set(clone(rules));
|
|
1129
|
+
this.record();
|
|
912
1130
|
}
|
|
913
1131
|
addRule(rule) {
|
|
914
1132
|
const id = rule.id?.trim() || this.uniqueId('rule', new Set(this._rules().map(r => r.id ?? '')));
|
|
915
1133
|
this._rules.update(rules => [...rules, { ...clone(rule), id }]);
|
|
1134
|
+
this.record();
|
|
916
1135
|
return id;
|
|
917
1136
|
}
|
|
918
1137
|
updateRule(id, patch) {
|
|
919
1138
|
this._rules.update(rules => rules.map(r => (r.id === id ? { ...r, ...clone(patch), id } : r)));
|
|
1139
|
+
this.record();
|
|
920
1140
|
}
|
|
921
1141
|
removeRule(id) {
|
|
922
1142
|
this._rules.update(rules => rules.filter(r => r.id !== id));
|
|
1143
|
+
this.record();
|
|
923
1144
|
}
|
|
924
1145
|
toggleRule(id, enabled) {
|
|
925
1146
|
this.updateRule(id, { enabled });
|
|
@@ -1055,6 +1276,7 @@ class BuilderStore {
|
|
|
1055
1276
|
this.repointRulesForMovedFields(before, this.refsById(draft));
|
|
1056
1277
|
this._config.set(draft);
|
|
1057
1278
|
this._isDirty.set(true);
|
|
1279
|
+
this.record();
|
|
1058
1280
|
}
|
|
1059
1281
|
/**
|
|
1060
1282
|
* Rewrites the address of every field the builder owns, leaving declared overrides alone.
|
|
@@ -1129,14 +1351,21 @@ class BuilderStore {
|
|
|
1129
1351
|
*
|
|
1130
1352
|
* Returns without touching anything when the field is not found.
|
|
1131
1353
|
*/
|
|
1132
|
-
mutateField(
|
|
1354
|
+
mutateField(key, update) {
|
|
1133
1355
|
let found = false;
|
|
1356
|
+
// Resolve once, then match on object identity. Matching on `field.id === id` inside the
|
|
1357
|
+
// walk updated *every* field carrying that id, so renaming `work.address` also rewrote
|
|
1358
|
+
// `personal.address` — both rows changed, and the second write was silent. Identity
|
|
1359
|
+
// cannot be ambiguous the way a bare id can.
|
|
1360
|
+
const target = this.findFieldInTabs(this._config().tabs, key);
|
|
1361
|
+
if (!target)
|
|
1362
|
+
return;
|
|
1134
1363
|
const visitFields = (fields) => {
|
|
1135
1364
|
if (!fields)
|
|
1136
1365
|
return fields;
|
|
1137
1366
|
let changed = false;
|
|
1138
1367
|
const next = fields.map(field => {
|
|
1139
|
-
if (field
|
|
1368
|
+
if (field === target) {
|
|
1140
1369
|
found = true;
|
|
1141
1370
|
changed = true;
|
|
1142
1371
|
return update(field);
|
|
@@ -1173,6 +1402,7 @@ class BuilderStore {
|
|
|
1173
1402
|
return;
|
|
1174
1403
|
this._config.set({ ...current, tabs: tabs ?? [] });
|
|
1175
1404
|
this._isDirty.set(true);
|
|
1405
|
+
this.record();
|
|
1176
1406
|
}
|
|
1177
1407
|
uniqueId(prefix, taken) {
|
|
1178
1408
|
let n = 1;
|
|
@@ -1235,24 +1465,37 @@ class BuilderStore {
|
|
|
1235
1465
|
});
|
|
1236
1466
|
}
|
|
1237
1467
|
}
|
|
1238
|
-
// Id uniqueness is a
|
|
1239
|
-
//
|
|
1240
|
-
//
|
|
1241
|
-
//
|
|
1242
|
-
//
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1468
|
+
// Id uniqueness is a *per-scope* invariant, not a whole-config one — this counted into
|
|
1469
|
+
// one flat map keyed by bare id, so it rejected `personal.address` alongside
|
|
1470
|
+
// `work.address` and made every such config unopenable: Save is disabled while an error
|
|
1471
|
+
// stands, so unrelated edits could not be saved either. The runtime, the record shape
|
|
1472
|
+
// and `validateConfig` have all accepted per-scope ids since 1.4.0; this was the last
|
|
1473
|
+
// component still enforcing the old rule, and it enforced it against configs the rest
|
|
1474
|
+
// of the stack calls valid.
|
|
1475
|
+
//
|
|
1476
|
+
// The scope rule itself comes from core rather than being reimplemented here, so the
|
|
1477
|
+
// builder and the validator cannot drift apart on what "same scope" means.
|
|
1478
|
+
const scopeCounts = new Map();
|
|
1479
|
+
for (const entry of collectFieldScopes(config)) {
|
|
1480
|
+
if (!entry.field.id)
|
|
1481
|
+
continue;
|
|
1482
|
+
const key = `${entry.scope}::${entry.field.id}`;
|
|
1483
|
+
const seen = scopeCounts.get(key);
|
|
1484
|
+
if (seen)
|
|
1485
|
+
seen.count += 1;
|
|
1486
|
+
else
|
|
1487
|
+
scopeCounts.set(key, { id: entry.field.id, count: 1 });
|
|
1488
|
+
}
|
|
1489
|
+
for (const [key, { id, count }] of scopeCounts) {
|
|
1254
1490
|
if (count > 1) {
|
|
1255
|
-
|
|
1491
|
+
// Still an error, and it must stay one: two fields in the same scope share a single
|
|
1492
|
+
// control and a single record key, so the second silently overwrites the first.
|
|
1493
|
+
const scope = key.slice(0, key.lastIndexOf('::'));
|
|
1494
|
+
problems.push({
|
|
1495
|
+
level: 'error',
|
|
1496
|
+
message: `Duplicate field id "${id}" (${count}×) in ${scope}. Two fields in one scope share a control and a record key.`,
|
|
1497
|
+
fieldId: id,
|
|
1498
|
+
});
|
|
1256
1499
|
}
|
|
1257
1500
|
}
|
|
1258
1501
|
return problems;
|
|
@@ -1341,7 +1584,7 @@ class EntityReferenceConfigComponent {
|
|
|
1341
1584
|
return field.entityReference ?? { enabled: true };
|
|
1342
1585
|
}
|
|
1343
1586
|
patchRef(field, patch) {
|
|
1344
|
-
this.store.updateEntityReference(field
|
|
1587
|
+
this.store.updateEntityReference(this.store.keyOf(field), patch);
|
|
1345
1588
|
}
|
|
1346
1589
|
fieldLabel(field) {
|
|
1347
1590
|
return resolveLabel(field.label, this.store.activeLanguage());
|
|
@@ -1482,7 +1725,7 @@ class EntityReferenceConfigComponent {
|
|
|
1482
1725
|
mat-stroked-button
|
|
1483
1726
|
type="button"
|
|
1484
1727
|
data-testid="add-auto-patch"
|
|
1485
|
-
(click)="store.addAutoPatchMapping(f
|
|
1728
|
+
(click)="store.addAutoPatchMapping(store.keyOf(f))"
|
|
1486
1729
|
>
|
|
1487
1730
|
<mat-icon>add</mat-icon> Mapping
|
|
1488
1731
|
</button>
|
|
@@ -1494,7 +1737,7 @@ class EntityReferenceConfigComponent {
|
|
|
1494
1737
|
<mat-select
|
|
1495
1738
|
data-testid="auto-patch-tab"
|
|
1496
1739
|
[ngModel]="f.autoPatch.targetTab"
|
|
1497
|
-
(ngModelChange)="store.setAutoPatchTargetTab(f
|
|
1740
|
+
(ngModelChange)="store.setAutoPatchTargetTab(store.keyOf(f), $event)"
|
|
1498
1741
|
>
|
|
1499
1742
|
@for (tab of store.tabs(); track tab.id) {
|
|
1500
1743
|
<mat-option [value]="tab.id">{{ tabLabel(tab.id) }}</mat-option>
|
|
@@ -1509,7 +1752,7 @@ class EntityReferenceConfigComponent {
|
|
|
1509
1752
|
<input
|
|
1510
1753
|
matInput
|
|
1511
1754
|
[ngModel]="mapping.source"
|
|
1512
|
-
(ngModelChange)="store.updateAutoPatchMapping(f
|
|
1755
|
+
(ngModelChange)="store.updateAutoPatchMapping(store.keyOf(f), $index, { source: $event })"
|
|
1513
1756
|
/>
|
|
1514
1757
|
</mat-form-field>
|
|
1515
1758
|
<mat-form-field appearance="outline" subscriptSizing="dynamic">
|
|
@@ -1517,7 +1760,7 @@ class EntityReferenceConfigComponent {
|
|
|
1517
1760
|
<mat-select
|
|
1518
1761
|
data-testid="auto-patch-target"
|
|
1519
1762
|
[ngModel]="mapping.target"
|
|
1520
|
-
(ngModelChange)="store.updateAutoPatchMapping(f
|
|
1763
|
+
(ngModelChange)="store.updateAutoPatchMapping(store.keyOf(f), $index, { target: $event })"
|
|
1521
1764
|
>
|
|
1522
1765
|
@for (option of optionsWith(mapping.target); track option.value) {
|
|
1523
1766
|
<mat-option [value]="option.value">
|
|
@@ -1531,7 +1774,7 @@ class EntityReferenceConfigComponent {
|
|
|
1531
1774
|
type="button"
|
|
1532
1775
|
color="warn"
|
|
1533
1776
|
matTooltip="Remove mapping"
|
|
1534
|
-
(click)="store.removeAutoPatchMapping(f
|
|
1777
|
+
(click)="store.removeAutoPatchMapping(store.keyOf(f), $index)"
|
|
1535
1778
|
>
|
|
1536
1779
|
<mat-icon>delete</mat-icon>
|
|
1537
1780
|
</button>
|
|
@@ -1654,7 +1897,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.22", ngImpo
|
|
|
1654
1897
|
mat-stroked-button
|
|
1655
1898
|
type="button"
|
|
1656
1899
|
data-testid="add-auto-patch"
|
|
1657
|
-
(click)="store.addAutoPatchMapping(f
|
|
1900
|
+
(click)="store.addAutoPatchMapping(store.keyOf(f))"
|
|
1658
1901
|
>
|
|
1659
1902
|
<mat-icon>add</mat-icon> Mapping
|
|
1660
1903
|
</button>
|
|
@@ -1666,7 +1909,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.22", ngImpo
|
|
|
1666
1909
|
<mat-select
|
|
1667
1910
|
data-testid="auto-patch-tab"
|
|
1668
1911
|
[ngModel]="f.autoPatch.targetTab"
|
|
1669
|
-
(ngModelChange)="store.setAutoPatchTargetTab(f
|
|
1912
|
+
(ngModelChange)="store.setAutoPatchTargetTab(store.keyOf(f), $event)"
|
|
1670
1913
|
>
|
|
1671
1914
|
@for (tab of store.tabs(); track tab.id) {
|
|
1672
1915
|
<mat-option [value]="tab.id">{{ tabLabel(tab.id) }}</mat-option>
|
|
@@ -1681,7 +1924,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.22", ngImpo
|
|
|
1681
1924
|
<input
|
|
1682
1925
|
matInput
|
|
1683
1926
|
[ngModel]="mapping.source"
|
|
1684
|
-
(ngModelChange)="store.updateAutoPatchMapping(f
|
|
1927
|
+
(ngModelChange)="store.updateAutoPatchMapping(store.keyOf(f), $index, { source: $event })"
|
|
1685
1928
|
/>
|
|
1686
1929
|
</mat-form-field>
|
|
1687
1930
|
<mat-form-field appearance="outline" subscriptSizing="dynamic">
|
|
@@ -1689,7 +1932,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.22", ngImpo
|
|
|
1689
1932
|
<mat-select
|
|
1690
1933
|
data-testid="auto-patch-target"
|
|
1691
1934
|
[ngModel]="mapping.target"
|
|
1692
|
-
(ngModelChange)="store.updateAutoPatchMapping(f
|
|
1935
|
+
(ngModelChange)="store.updateAutoPatchMapping(store.keyOf(f), $index, { target: $event })"
|
|
1693
1936
|
>
|
|
1694
1937
|
@for (option of optionsWith(mapping.target); track option.value) {
|
|
1695
1938
|
<mat-option [value]="option.value">
|
|
@@ -1703,7 +1946,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.22", ngImpo
|
|
|
1703
1946
|
type="button"
|
|
1704
1947
|
color="warn"
|
|
1705
1948
|
matTooltip="Remove mapping"
|
|
1706
|
-
(click)="store.removeAutoPatchMapping(f
|
|
1949
|
+
(click)="store.removeAutoPatchMapping(store.keyOf(f), $index)"
|
|
1707
1950
|
>
|
|
1708
1951
|
<mat-icon>delete</mat-icon>
|
|
1709
1952
|
</button>
|
|
@@ -2212,20 +2455,20 @@ class ReferencedFieldConfigComponent {
|
|
|
2212
2455
|
field = computed(() => this.store.selectedField(), ...(ngDevMode ? [{ debugName: "field" }] : /* istanbul ignore next */ []));
|
|
2213
2456
|
toggleReferenced(field, enabled) {
|
|
2214
2457
|
if (enabled) {
|
|
2215
|
-
this.store.updateField(field
|
|
2458
|
+
this.store.updateField(this.store.keyOf(field), { isReferenced: true });
|
|
2216
2459
|
}
|
|
2217
2460
|
else {
|
|
2218
|
-
this.store.unlinkReferencedField(field
|
|
2461
|
+
this.store.unlinkReferencedField(this.store.keyOf(field));
|
|
2219
2462
|
}
|
|
2220
2463
|
}
|
|
2221
2464
|
updateEntityKey(field, key) {
|
|
2222
2465
|
const trimmed = key.trim();
|
|
2223
|
-
this.store.updateField(field
|
|
2466
|
+
this.store.updateField(this.store.keyOf(field), { referencedEntityKey: trimmed || undefined });
|
|
2224
2467
|
this.checkDriftForField(field.id);
|
|
2225
2468
|
}
|
|
2226
2469
|
updateFieldId(field, fieldId) {
|
|
2227
2470
|
const trimmed = fieldId.trim();
|
|
2228
|
-
this.store.updateField(field
|
|
2471
|
+
this.store.updateField(this.store.keyOf(field), { referencedFieldId: trimmed || undefined });
|
|
2229
2472
|
this.checkDriftForField(field.id);
|
|
2230
2473
|
}
|
|
2231
2474
|
async syncWithSource(field) {
|
|
@@ -2236,7 +2479,7 @@ class ReferencedFieldConfigComponent {
|
|
|
2236
2479
|
return;
|
|
2237
2480
|
const sourceField = this.findField(sourceConfig.tabs ?? [], field.referencedFieldId);
|
|
2238
2481
|
if (sourceField) {
|
|
2239
|
-
this.store.syncReferencedField(field
|
|
2482
|
+
this.store.syncReferencedField(this.store.keyOf(field), sourceField);
|
|
2240
2483
|
}
|
|
2241
2484
|
}
|
|
2242
2485
|
/**
|
|
@@ -2479,7 +2722,7 @@ class FieldInspectorComponent {
|
|
|
2479
2722
|
}
|
|
2480
2723
|
moveToTab(field, tabId) {
|
|
2481
2724
|
if (tabId && tabId !== this.currentTabId(field))
|
|
2482
|
-
this.store.moveFieldToTab(field
|
|
2725
|
+
this.store.moveFieldToTab(this.store.keyOf(field), tabId);
|
|
2483
2726
|
}
|
|
2484
2727
|
/**
|
|
2485
2728
|
* Fields offered wherever this inspector names one — the `showWhen` key and both ends of a
|
|
@@ -2516,7 +2759,7 @@ class FieldInspectorComponent {
|
|
|
2516
2759
|
while (key in next)
|
|
2517
2760
|
key = `field_${++n}`;
|
|
2518
2761
|
next[key] = true;
|
|
2519
|
-
this.store.setShowWhen(field
|
|
2762
|
+
this.store.setShowWhen(this.store.keyOf(field), next);
|
|
2520
2763
|
}
|
|
2521
2764
|
renameShowWhen(field, oldKey, newKey) {
|
|
2522
2765
|
const trimmed = newKey.trim();
|
|
@@ -2529,15 +2772,15 @@ class FieldInspectorComponent {
|
|
|
2529
2772
|
// Rebuild in order so the row does not jump while the user is typing.
|
|
2530
2773
|
for (const [k, v] of Object.entries(current))
|
|
2531
2774
|
next[k === oldKey ? trimmed : k] = v;
|
|
2532
|
-
this.store.setShowWhen(field
|
|
2775
|
+
this.store.setShowWhen(this.store.keyOf(field), next);
|
|
2533
2776
|
}
|
|
2534
2777
|
setShowWhenValue(field, key, raw) {
|
|
2535
|
-
this.store.setShowWhen(field
|
|
2778
|
+
this.store.setShowWhen(this.store.keyOf(field), { ...(field.showWhen ?? {}), [key]: this.parseShowWhen(raw) });
|
|
2536
2779
|
}
|
|
2537
2780
|
removeShowWhen(field, key) {
|
|
2538
2781
|
const next = { ...(field.showWhen ?? {}) };
|
|
2539
2782
|
delete next[key];
|
|
2540
|
-
this.store.setShowWhen(field
|
|
2783
|
+
this.store.setShowWhen(this.store.keyOf(field), next);
|
|
2541
2784
|
}
|
|
2542
2785
|
/**
|
|
2543
2786
|
* `showWhen` compares with `!==`, so the authored type has to survive the round trip:
|
|
@@ -2563,7 +2806,7 @@ class FieldInspectorComponent {
|
|
|
2563
2806
|
return String(value);
|
|
2564
2807
|
}
|
|
2565
2808
|
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.22", ngImport: i0, type: FieldInspectorComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
|
|
2566
|
-
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.22", type: FieldInspectorComponent, isStandalone: true, selector: "ngx-field-inspector", ngImport: i0, template: "@if (field(); as f) {\n <div class=\"deb-inspector\">\n <!-- Identity -->\n <div class=\"deb-row\">\n <mat-form-field appearance=\"outline\" subscriptSizing=\"dynamic\">\n <mat-label>Field id</mat-label>\n <input matInput data-testid=\"field-id\" [value]=\"f.id\" disabled />\n @if (store.hasManualId(f.id)) {\n <mat-hint>The stored data key for this saved field \u2014 fixed.</mat-hint>\n } @else {\n <mat-hint>Derived from the label. Used as the data key.</mat-hint>\n }\n </mat-form-field>\n <span class=\"deb-chip\">{{ meta()?.label || f.type }}</span>\n </div>\n\n @if (tabOptions().length > 1) {\n <mat-form-field appearance=\"outline\" subscriptSizing=\"dynamic\" class=\"deb-full\">\n <mat-label>Tab</mat-label>\n <mat-select data-testid=\"field-tab\"\n [ngModel]=\"currentTabId(f)\" (ngModelChange)=\"moveToTab(f, $event)\">\n @for (tab of tabOptions(); track tab.id) {\n <mat-option [value]=\"tab.id\">{{ tab.label }}</mat-option>\n }\n </mat-select>\n <mat-hint>Moving the field rewrites its path and repoints the rules that named it.</mat-hint>\n </mat-form-field>\n }\n\n\n <!-- Localised text (active language) -->\n <mat-form-field appearance=\"outline\" subscriptSizing=\"dynamic\" class=\"deb-full\">\n <mat-label>Label ({{ lang() }})</mat-label>\n <input\n matInput\n data-testid=\"field-label\"\n [ngModel]=\"labelValue(f)\"\n (ngModelChange)=\"store.setFieldLabel(f.id, lang(), $event)\"\n />\n </mat-form-field>\n\n @if (meta()?.supportsPlaceholder) {\n <mat-form-field appearance=\"outline\" subscriptSizing=\"dynamic\" class=\"deb-full\">\n <mat-label>Placeholder ({{ lang() }})</mat-label>\n <input\n matInput\n [ngModel]=\"placeholderValue(f)\"\n (ngModelChange)=\"store.setFieldPlaceholder(f.id, lang(), $event)\"\n />\n </mat-form-field>\n }\n\n @if (meta()?.supportsDefaultValue) {\n <mat-form-field appearance=\"outline\" subscriptSizing=\"dynamic\" class=\"deb-full\">\n <mat-label>Default value</mat-label>\n <input\n matInput\n [ngModel]=\"f.defaultValue ?? ''\"\n (ngModelChange)=\"store.updateField(f.id, { defaultValue: $event })\"\n />\n </mat-form-field>\n }\n\n <!-- Entity reference, cascade, and autoPatch -->\n @if (meta()?.isEntityRef) {\n <mat-divider></mat-divider>\n <ngx-entity-reference-config />\n }\n\n <mat-divider></mat-divider>\n\n <!-- Validators -->\n @if (meta(); as m) {\n @if (m.flagValidators.length || m.paramValidators.length) {\n <span class=\"deb-section-title\">Validation</span>\n <div class=\"deb-toggles\">\n @for (v of m.flagValidators; track v) {\n <mat-checkbox\n [ngModel]=\"store.hasFlagValidator(f, v)\"\n (ngModelChange)=\"store.toggleFlagValidator(f.id, v, $event)\"\n >{{ v }}</mat-checkbox>\n }\n </div>\n @for (v of m.paramValidators; track v) {\n <mat-form-field appearance=\"outline\" subscriptSizing=\"dynamic\" class=\"deb-full\">\n <mat-label>{{ v }}</mat-label>\n <input\n matInput\n type=\"number\"\n [ngModel]=\"store.getParamValidator(f, v)\"\n (ngModelChange)=\"store.setParamValidator(f.id, v, toNum($event))\"\n />\n </mat-form-field>\n }\n }\n }\n\n <!-- Options (dropdown / radio / multiSelect) -->\n @if (meta()?.hasOptions) {\n <mat-divider></mat-divider>\n <span class=\"deb-section-title\">Data source</span>\n <mat-form-field appearance=\"outline\" subscriptSizing=\"dynamic\" class=\"deb-full\">\n <mat-label>Options come from</mat-label>\n <mat-select\n data-testid=\"data-source\"\n [ngModel]=\"store.fieldDataSource(f)\"\n (ngModelChange)=\"store.setFieldDataSource(f.id, $event)\"\n >\n <mat-option value=\"none\">Nothing yet</mat-option>\n <mat-option value=\"manual\">Options authored here</mat-option>\n <mat-option value=\"lookup\">A named list</mat-option>\n </mat-select>\n </mat-form-field>\n }\n\n <!-- Named lookup list -->\n @if (meta()?.hasOptions && store.fieldDataSource(f) === 'lookup') {\n <mat-form-field appearance=\"outline\" subscriptSizing=\"dynamic\" class=\"deb-full\">\n <mat-label>List name</mat-label>\n <input\n matInput\n data-testid=\"list-name\"\n [ngModel]=\"f.listName ?? ''\"\n (ngModelChange)=\"store.setListName(f.id, $event)\"\n />\n </mat-form-field>\n <p class=\"deb-hint\">\n The consuming app resolves this name through its lookup registry. Options are not\n authored here.\n </p>\n }\n\n <!-- Inline options -->\n @if (meta()?.hasOptions && store.fieldDataSource(f) !== 'lookup') {\n <div class=\"deb-row\" style=\"justify-content: space-between;\">\n <span class=\"deb-section-title\">Options</span>\n <button mat-stroked-button type=\"button\" (click)=\"store.addOption(f.id)\">\n <mat-icon>add</mat-icon> Option\n </button>\n </div>\n @for (opt of f.options ?? []; track $index) {\n <div class=\"deb-option-row\" data-testid=\"option-row\">\n <!-- One input: the displayed text is the stored value. -->\n <mat-form-field appearance=\"outline\" subscriptSizing=\"dynamic\" class=\"deb-full\">\n <mat-label>Option ({{ lang() }})</mat-label>\n <input\n matInput\n [attr.data-testid]=\"'option-' + $index\"\n [ngModel]=\"optionLabel(opt)\"\n (ngModelChange)=\"store.setOptionLabel(f.id, $index, lang(), $event)\"\n />\n </mat-form-field>\n <button mat-icon-button type=\"button\" color=\"warn\"\n (click)=\"store.removeOption(f.id, $index)\" matTooltip=\"Remove option\">\n <mat-icon>delete</mat-icon>\n </button>\n </div>\n }\n @if (!(f.options ?? []).length) {\n <p class=\"deb-hint\">No options yet \u2014 add at least one.</p>\n }\n }\n\n <mat-divider></mat-divider>\n\n <!-- Flags -->\n <span class=\"deb-section-title\">Display</span>\n <div class=\"deb-toggles\">\n <mat-slide-toggle\n [ngModel]=\"f.visibility ?? true\"\n (ngModelChange)=\"store.updateField(f.id, { visibility: $event })\"\n >Visible</mat-slide-toggle>\n <mat-slide-toggle\n [ngModel]=\"f.table?.visible ?? false\"\n (ngModelChange)=\"store.updateField(f.id, { table: { visible: $event } })\"\n >Table column</mat-slide-toggle>\n <mat-slide-toggle\n [ngModel]=\"f.readonly ?? false\"\n (ngModelChange)=\"store.updateField(f.id, { readonly: $event })\"\n >Read-only</mat-slide-toggle>\n <mat-slide-toggle\n [ngModel]=\"f.disabled ?? false\"\n (ngModelChange)=\"store.updateField(f.id, { disabled: $event })\"\n >Disabled</mat-slide-toggle>\n <mat-slide-toggle\n [ngModel]=\"f.maskData ?? false\"\n (ngModelChange)=\"store.updateField(f.id, { maskData: $event })\"\n >Mask data (RBAC)</mat-slide-toggle>\n <mat-slide-toggle\n data-testid=\"toggle-critical\"\n [ngModel]=\"f.criticalField ?? false\"\n (ngModelChange)=\"store.updateField(f.id, { criticalField: $event })\"\n matTooltip=\"Renders locked; editing it announces a change against the session baseline.\"\n >Critical field</mat-slide-toggle>\n <mat-slide-toggle\n [ngModel]=\"f.showOnMinimize ?? false\"\n (ngModelChange)=\"store.updateField(f.id, { showOnMinimize: $event })\"\n matTooltip=\"Shown in the record editor's summary panel.\"\n >Show in summary</mat-slide-toggle>\n </div>\n\n <mat-divider></mat-divider>\n\n <!-- Conditional visibility (static showWhen) -->\n <div class=\"deb-row deb-row--split\">\n <span class=\"deb-section-title\">Show when</span>\n <button mat-stroked-button type=\"button\" data-testid=\"add-show-when\" (click)=\"addShowWhen(f)\">\n <mat-icon>add</mat-icon> Condition\n </button>\n </div>\n @for (entry of showWhenEntries(f); track entry.key) {\n <div class=\"deb-option-row\" data-testid=\"option-row\">\n <mat-form-field appearance=\"outline\" subscriptSizing=\"dynamic\">\n <mat-label>Watched field</mat-label>\n <mat-select data-testid=\"show-when-field\"\n [ngModel]=\"entry.key\" (ngModelChange)=\"renameShowWhen(f, entry.key, $event)\">\n @for (option of optionsWith(entry.key); track option.value) {\n <mat-option [value]=\"option.value\">\n {{ option.label }} <span class=\"deb-path-hint\">{{ option.path }}</span>\n </mat-option>\n }\n </mat-select>\n </mat-form-field>\n <mat-form-field appearance=\"outline\" subscriptSizing=\"dynamic\">\n <mat-label>Equals</mat-label>\n <input matInput [ngModel]=\"entry.display\" (ngModelChange)=\"setShowWhenValue(f, entry.key, $event)\" />\n </mat-form-field>\n <button mat-icon-button type=\"button\" color=\"warn\" matTooltip=\"Remove condition\"\n (click)=\"removeShowWhen(f, entry.key)\">\n <mat-icon>delete</mat-icon>\n </button>\n </div>\n }\n @if (!showWhenEntries(f).length) {\n <p class=\"deb-hint\">Always visible. Add a condition to show this field only for certain values.</p>\n }\n\n <!-- patchOnTrue (boolean/checkbox only) -->\n @if (f.type === 'boolean' || f.type === 'checkbox') {\n <mat-divider></mat-divider>\n <div class=\"deb-row deb-row--split\">\n <span class=\"deb-section-title\">Copy on true</span>\n <button mat-stroked-button type=\"button\" data-testid=\"add-patch-on-true\"\n (click)=\"store.addPatchOnTrueMapping(f.id)\">\n <mat-icon>add</mat-icon> Mapping\n </button>\n </div>\n @for (mapping of f.patchOnTrue ?? []; track $index) {\n <div class=\"deb-option-row\" data-testid=\"option-row\">\n <mat-form-field appearance=\"outline\" subscriptSizing=\"dynamic\">\n <mat-label>From field</mat-label>\n <mat-select data-testid=\"patch-on-true-from\"\n [ngModel]=\"mapping.from\"\n (ngModelChange)=\"store.updatePatchOnTrueMapping(f.id, $index, { from: $event })\">\n @for (option of optionsWith(mapping.from); track option.value) {\n <mat-option [value]=\"option.value\">\n {{ option.label }} <span class=\"deb-path-hint\">{{ option.path }}</span>\n </mat-option>\n }\n </mat-select>\n </mat-form-field>\n <mat-form-field appearance=\"outline\" subscriptSizing=\"dynamic\">\n <mat-label>To field</mat-label>\n <mat-select data-testid=\"patch-on-true-to\"\n [ngModel]=\"mapping.to\"\n (ngModelChange)=\"store.updatePatchOnTrueMapping(f.id, $index, { to: $event })\">\n @for (option of optionsWith(mapping.to); track option.value) {\n <mat-option [value]=\"option.value\">\n {{ option.label }} <span class=\"deb-path-hint\">{{ option.path }}</span>\n </mat-option>\n }\n </mat-select>\n </mat-form-field>\n <button mat-icon-button type=\"button\" color=\"warn\" matTooltip=\"Remove mapping\"\n (click)=\"store.removePatchOnTrueMapping(f.id, $index)\">\n <mat-icon>delete</mat-icon>\n </button>\n </div>\n }\n @if (!(f.patchOnTrue ?? []).length) {\n <p class=\"deb-hint\">When this flips to true, copy one field's value into another.</p>\n }\n }\n\n <mat-divider></mat-divider>\n\n <!-- Rules attached to this field -->\n <ngx-field-rules-list />\n\n <mat-divider></mat-divider>\n\n <!-- Cross-entity field reference & drift management (Phase 8) -->\n <ngx-referenced-field-config />\n </div>\n} @else {\n <div class=\"deb-inspector__empty\">\n <mat-icon>touch_app</mat-icon>\n <p>Select a field to edit its properties.</p>\n </div>\n}\n", styles: [".deb-inspector{display:flex;flex-direction:column;gap:12px}.deb-inspector__empty{color:var(--deb-muted, #6b7280);text-align:center;padding:24px 8px}.deb-full{width:100%}.deb-row{display:flex;gap:8px;align-items:center}.deb-row>*{flex:1}.deb-row--split{justify-content:space-between}.deb-row--split>*{flex:0 0 auto}.deb-toggles{display:grid;grid-template-columns:1fr 1fr;gap:8px 12px}.deb-option-row{display:flex;gap:6px;align-items:center}.deb-chip{display:inline-block;padding:2px 8px;border-radius:12px;background:var(--deb-accent-soft, #eef2ff);color:var(--deb-accent, #6366f1);font-size:12px;font-weight:600}\n"], dependencies: [{ kind: "ngmodule", type: FormsModule }, { kind: "directive", type: i1.DefaultValueAccessor, selector: "input:not([type=checkbox])[formControlName],textarea[formControlName],input:not([type=checkbox])[formControl],textarea[formControl],input:not([type=checkbox])[ngModel],textarea[ngModel],[ngDefaultControl]" }, { kind: "directive", type: i1.NumberValueAccessor, selector: "input[type=number][formControlName],input[type=number][formControl],input[type=number][ngModel]" }, { kind: "directive", type: i1.NgControlStatus, selector: "[formControlName],[ngModel],[formControl]" }, { kind: "directive", type: i1.NgModel, selector: "[ngModel]:not([formControlName]):not([formControl])", inputs: ["name", "disabled", "ngModel", "ngModelOptions"], outputs: ["ngModelChange"], exportAs: ["ngModel"] }, { kind: "ngmodule", type: MatButtonModule }, { kind: "component", type: i2.MatButton, selector: " button[matButton], a[matButton], button[mat-button], button[mat-raised-button], button[mat-flat-button], button[mat-stroked-button], a[mat-button], a[mat-raised-button], a[mat-flat-button], a[mat-stroked-button] ", inputs: ["matButton"], exportAs: ["matButton", "matAnchor"] }, { kind: "component", type: i2.MatIconButton, selector: "button[mat-icon-button], a[mat-icon-button], button[matIconButton], a[matIconButton]", exportAs: ["matButton", "matAnchor"] }, { kind: "ngmodule", type: MatCheckboxModule }, { kind: "component", type: i3$1.MatCheckbox, selector: "mat-checkbox", inputs: ["aria-label", "aria-labelledby", "aria-describedby", "aria-expanded", "aria-controls", "aria-owns", "id", "required", "labelPosition", "name", "value", "disableRipple", "tabIndex", "color", "disabledInteractive", "checked", "disabled", "indeterminate"], outputs: ["change", "indeterminateChange"], exportAs: ["matCheckbox"] }, { kind: "ngmodule", type: MatDividerModule }, { kind: "component", type: i3.MatDivider, selector: "mat-divider", inputs: ["vertical", "inset"] }, { kind: "ngmodule", type: MatFormFieldModule }, { kind: "component", type: i4.MatFormField, selector: "mat-form-field", inputs: ["hideRequiredMarker", "color", "floatLabel", "appearance", "subscriptSizing", "hintLabel"], exportAs: ["matFormField"] }, { kind: "directive", type: i4.MatLabel, selector: "mat-label" }, { kind: "directive", type: i4.MatHint, selector: "mat-hint", inputs: ["align", "id"] }, { kind: "ngmodule", type: MatIconModule }, { kind: "component", type: i4$1.MatIcon, selector: "mat-icon", inputs: ["color", "inline", "svgIcon", "fontSet", "fontIcon"], exportAs: ["matIcon"] }, { kind: "ngmodule", type: MatInputModule }, { kind: "directive", type: i5.MatInput, selector: "input[matInput], textarea[matInput], select[matNativeControl], input[matNativeControl], textarea[matNativeControl]", inputs: ["disabled", "id", "placeholder", "name", "required", "type", "errorStateMatcher", "aria-describedby", "value", "readonly", "disabledInteractive"], exportAs: ["matInput"] }, { kind: "ngmodule", type: MatSelectModule }, { kind: "component", type: i8.MatSelect, selector: "mat-select", inputs: ["aria-describedby", "panelClass", "disabled", "disableRipple", "tabIndex", "hideSingleSelectionIndicator", "placeholder", "required", "multiple", "disableOptionCentering", "compareWith", "value", "aria-label", "aria-labelledby", "errorStateMatcher", "typeaheadDebounceInterval", "sortComparator", "id", "panelWidth", "canSelectNullableOptions"], outputs: ["openedChange", "opened", "closed", "selectionChange", "valueChange"], exportAs: ["matSelect"] }, { kind: "component", type: i8.MatOption, selector: "mat-option", inputs: ["value", "id", "disabled"], outputs: ["onSelectionChange"], exportAs: ["matOption"] }, { kind: "ngmodule", type: MatSlideToggleModule }, { kind: "component", type: i9.MatSlideToggle, selector: "mat-slide-toggle", inputs: ["name", "id", "labelPosition", "aria-label", "aria-labelledby", "aria-describedby", "required", "color", "disabled", "disableRipple", "tabIndex", "checked", "hideIcon", "disabledInteractive"], outputs: ["change", "toggleChange"], exportAs: ["matSlideToggle"] }, { kind: "ngmodule", type: MatTooltipModule }, { kind: "directive", type: i4$2.MatTooltip, selector: "[matTooltip]", inputs: ["matTooltipPosition", "matTooltipPositionAtOrigin", "matTooltipDisabled", "matTooltipShowDelay", "matTooltipHideDelay", "matTooltipTouchGestures", "matTooltip", "matTooltipClass"], exportAs: ["matTooltip"] }, { kind: "component", type: EntityReferenceConfigComponent, selector: "ngx-entity-reference-config" }, { kind: "component", type: FieldRulesListComponent, selector: "ngx-field-rules-list" }, { kind: "component", type: ReferencedFieldConfigComponent, selector: "ngx-referenced-field-config" }] });
|
|
2809
|
+
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.22", type: FieldInspectorComponent, isStandalone: true, selector: "ngx-field-inspector", ngImport: i0, template: "@if (field(); as f) {\n <div class=\"deb-inspector\">\n <!-- Identity -->\n <div class=\"deb-row\">\n <mat-form-field appearance=\"outline\" subscriptSizing=\"dynamic\">\n <mat-label>Field id</mat-label>\n <input matInput data-testid=\"field-id\" [value]=\"f.id\" disabled />\n @if (store.hasManualId(f.id)) {\n <mat-hint>The stored data key for this saved field \u2014 fixed.</mat-hint>\n } @else {\n <mat-hint>Derived from the label. Used as the data key.</mat-hint>\n }\n </mat-form-field>\n <span class=\"deb-chip\">{{ meta()?.label || f.type }}</span>\n </div>\n\n @if (tabOptions().length > 1) {\n <mat-form-field appearance=\"outline\" subscriptSizing=\"dynamic\" class=\"deb-full\">\n <mat-label>Tab</mat-label>\n <mat-select data-testid=\"field-tab\"\n [ngModel]=\"currentTabId(f)\" (ngModelChange)=\"moveToTab(f, $event)\">\n @for (tab of tabOptions(); track tab.id) {\n <mat-option [value]=\"tab.id\">{{ tab.label }}</mat-option>\n }\n </mat-select>\n <mat-hint>Moving the field rewrites its path and repoints the rules that named it.</mat-hint>\n </mat-form-field>\n }\n\n\n <!-- Localised text (active language) -->\n <mat-form-field appearance=\"outline\" subscriptSizing=\"dynamic\" class=\"deb-full\">\n <mat-label>Label ({{ lang() }})</mat-label>\n <input\n matInput\n data-testid=\"field-label\"\n [ngModel]=\"labelValue(f)\"\n (ngModelChange)=\"store.setFieldLabel(store.keyOf(f), lang(), $event)\"\n />\n </mat-form-field>\n\n @if (meta()?.supportsPlaceholder) {\n <mat-form-field appearance=\"outline\" subscriptSizing=\"dynamic\" class=\"deb-full\">\n <mat-label>Placeholder ({{ lang() }})</mat-label>\n <input\n matInput\n [ngModel]=\"placeholderValue(f)\"\n (ngModelChange)=\"store.setFieldPlaceholder(store.keyOf(f), lang(), $event)\"\n />\n </mat-form-field>\n }\n\n @if (meta()?.supportsDefaultValue) {\n <mat-form-field appearance=\"outline\" subscriptSizing=\"dynamic\" class=\"deb-full\">\n <mat-label>Default value</mat-label>\n <input\n matInput\n [ngModel]=\"f.defaultValue ?? ''\"\n (ngModelChange)=\"store.updateField(store.keyOf(f), { defaultValue: $event })\"\n />\n </mat-form-field>\n }\n\n <!-- Entity reference, cascade, and autoPatch -->\n @if (meta()?.isEntityRef) {\n <mat-divider></mat-divider>\n <ngx-entity-reference-config />\n }\n\n <mat-divider></mat-divider>\n\n <!-- Validators -->\n @if (meta(); as m) {\n @if (m.flagValidators.length || m.paramValidators.length) {\n <span class=\"deb-section-title\">Validation</span>\n <div class=\"deb-toggles\">\n @for (v of m.flagValidators; track v) {\n <mat-checkbox\n [ngModel]=\"store.hasFlagValidator(f, v)\"\n (ngModelChange)=\"store.toggleFlagValidator(store.keyOf(f), v, $event)\"\n >{{ v }}</mat-checkbox>\n }\n </div>\n @for (v of m.paramValidators; track v) {\n <mat-form-field appearance=\"outline\" subscriptSizing=\"dynamic\" class=\"deb-full\">\n <mat-label>{{ v }}</mat-label>\n <input\n matInput\n type=\"number\"\n [ngModel]=\"store.getParamValidator(f, v)\"\n (ngModelChange)=\"store.setParamValidator(store.keyOf(f), v, toNum($event))\"\n />\n </mat-form-field>\n }\n }\n }\n\n <!-- Options (dropdown / radio / multiSelect) -->\n @if (meta()?.hasOptions) {\n <mat-divider></mat-divider>\n <span class=\"deb-section-title\">Data source</span>\n <mat-form-field appearance=\"outline\" subscriptSizing=\"dynamic\" class=\"deb-full\">\n <mat-label>Options come from</mat-label>\n <mat-select\n data-testid=\"data-source\"\n [ngModel]=\"store.fieldDataSource(f)\"\n (ngModelChange)=\"store.setFieldDataSource(store.keyOf(f), $event)\"\n >\n <mat-option value=\"none\">Nothing yet</mat-option>\n <mat-option value=\"manual\">Options authored here</mat-option>\n <mat-option value=\"lookup\">A named list</mat-option>\n </mat-select>\n </mat-form-field>\n }\n\n <!-- Named lookup list -->\n @if (meta()?.hasOptions && store.fieldDataSource(f) === 'lookup') {\n <mat-form-field appearance=\"outline\" subscriptSizing=\"dynamic\" class=\"deb-full\">\n <mat-label>List name</mat-label>\n <input\n matInput\n data-testid=\"list-name\"\n [ngModel]=\"f.listName ?? ''\"\n (ngModelChange)=\"store.setListName(store.keyOf(f), $event)\"\n />\n </mat-form-field>\n <p class=\"deb-hint\">\n The consuming app resolves this name through its lookup registry. Options are not\n authored here.\n </p>\n }\n\n <!-- Inline options -->\n @if (meta()?.hasOptions && store.fieldDataSource(f) !== 'lookup') {\n <div class=\"deb-row\" style=\"justify-content: space-between;\">\n <span class=\"deb-section-title\">Options</span>\n <button mat-stroked-button type=\"button\" (click)=\"store.addOption(store.keyOf(f))\">\n <mat-icon>add</mat-icon> Option\n </button>\n </div>\n @for (opt of f.options ?? []; track $index) {\n <div class=\"deb-option-row\" data-testid=\"option-row\">\n <!-- One input: the displayed text is the stored value. -->\n <mat-form-field appearance=\"outline\" subscriptSizing=\"dynamic\" class=\"deb-full\">\n <mat-label>Option ({{ lang() }})</mat-label>\n <input\n matInput\n [attr.data-testid]=\"'option-' + $index\"\n [ngModel]=\"optionLabel(opt)\"\n (ngModelChange)=\"store.setOptionLabel(store.keyOf(f), $index, lang(), $event)\"\n />\n </mat-form-field>\n <button mat-icon-button type=\"button\" color=\"warn\"\n (click)=\"store.removeOption(store.keyOf(f), $index)\" matTooltip=\"Remove option\">\n <mat-icon>delete</mat-icon>\n </button>\n </div>\n }\n @if (!(f.options ?? []).length) {\n <p class=\"deb-hint\">No options yet \u2014 add at least one.</p>\n }\n }\n\n <mat-divider></mat-divider>\n\n <!-- Flags -->\n <span class=\"deb-section-title\">Display</span>\n <div class=\"deb-toggles\">\n <mat-slide-toggle\n [ngModel]=\"f.visibility ?? true\"\n (ngModelChange)=\"store.updateField(store.keyOf(f), { visibility: $event })\"\n >Visible</mat-slide-toggle>\n <mat-slide-toggle\n [ngModel]=\"f.table?.visible ?? false\"\n (ngModelChange)=\"store.updateField(store.keyOf(f), { table: { visible: $event } })\"\n >Table column</mat-slide-toggle>\n <mat-slide-toggle\n [ngModel]=\"f.readonly ?? false\"\n (ngModelChange)=\"store.updateField(store.keyOf(f), { readonly: $event })\"\n >Read-only</mat-slide-toggle>\n <mat-slide-toggle\n [ngModel]=\"f.disabled ?? false\"\n (ngModelChange)=\"store.updateField(store.keyOf(f), { disabled: $event })\"\n >Disabled</mat-slide-toggle>\n <mat-slide-toggle\n [ngModel]=\"f.maskData ?? false\"\n (ngModelChange)=\"store.updateField(store.keyOf(f), { maskData: $event })\"\n >Mask data (RBAC)</mat-slide-toggle>\n <mat-slide-toggle\n data-testid=\"toggle-critical\"\n [ngModel]=\"f.criticalField ?? false\"\n (ngModelChange)=\"store.updateField(store.keyOf(f), { criticalField: $event })\"\n matTooltip=\"Renders locked; editing it announces a change against the session baseline.\"\n >Critical field</mat-slide-toggle>\n <mat-slide-toggle\n [ngModel]=\"f.showOnMinimize ?? false\"\n (ngModelChange)=\"store.updateField(store.keyOf(f), { showOnMinimize: $event })\"\n matTooltip=\"Shown in the record editor's summary panel.\"\n >Show in summary</mat-slide-toggle>\n </div>\n\n <mat-divider></mat-divider>\n\n <!-- Conditional visibility (static showWhen) -->\n <div class=\"deb-row deb-row--split\">\n <span class=\"deb-section-title\">Show when</span>\n <button mat-stroked-button type=\"button\" data-testid=\"add-show-when\" (click)=\"addShowWhen(f)\">\n <mat-icon>add</mat-icon> Condition\n </button>\n </div>\n @for (entry of showWhenEntries(f); track entry.key) {\n <div class=\"deb-option-row\" data-testid=\"option-row\">\n <mat-form-field appearance=\"outline\" subscriptSizing=\"dynamic\">\n <mat-label>Watched field</mat-label>\n <mat-select data-testid=\"show-when-field\"\n [ngModel]=\"entry.key\" (ngModelChange)=\"renameShowWhen(f, entry.key, $event)\">\n @for (option of optionsWith(entry.key); track option.value) {\n <mat-option [value]=\"option.value\">\n {{ option.label }} <span class=\"deb-path-hint\">{{ option.path }}</span>\n </mat-option>\n }\n </mat-select>\n </mat-form-field>\n <mat-form-field appearance=\"outline\" subscriptSizing=\"dynamic\">\n <mat-label>Equals</mat-label>\n <input matInput [ngModel]=\"entry.display\" (ngModelChange)=\"setShowWhenValue(f, entry.key, $event)\" />\n </mat-form-field>\n <button mat-icon-button type=\"button\" color=\"warn\" matTooltip=\"Remove condition\"\n (click)=\"removeShowWhen(f, entry.key)\">\n <mat-icon>delete</mat-icon>\n </button>\n </div>\n }\n @if (!showWhenEntries(f).length) {\n <p class=\"deb-hint\">Always visible. Add a condition to show this field only for certain values.</p>\n }\n\n <!-- patchOnTrue (boolean/checkbox only) -->\n @if (f.type === 'boolean' || f.type === 'checkbox') {\n <mat-divider></mat-divider>\n <div class=\"deb-row deb-row--split\">\n <span class=\"deb-section-title\">Copy on true</span>\n <button mat-stroked-button type=\"button\" data-testid=\"add-patch-on-true\"\n (click)=\"store.addPatchOnTrueMapping(store.keyOf(f))\">\n <mat-icon>add</mat-icon> Mapping\n </button>\n </div>\n @for (mapping of f.patchOnTrue ?? []; track $index) {\n <div class=\"deb-option-row\" data-testid=\"option-row\">\n <mat-form-field appearance=\"outline\" subscriptSizing=\"dynamic\">\n <mat-label>From field</mat-label>\n <mat-select data-testid=\"patch-on-true-from\"\n [ngModel]=\"mapping.from\"\n (ngModelChange)=\"store.updatePatchOnTrueMapping(store.keyOf(f), $index, { from: $event })\">\n @for (option of optionsWith(mapping.from); track option.value) {\n <mat-option [value]=\"option.value\">\n {{ option.label }} <span class=\"deb-path-hint\">{{ option.path }}</span>\n </mat-option>\n }\n </mat-select>\n </mat-form-field>\n <mat-form-field appearance=\"outline\" subscriptSizing=\"dynamic\">\n <mat-label>To field</mat-label>\n <mat-select data-testid=\"patch-on-true-to\"\n [ngModel]=\"mapping.to\"\n (ngModelChange)=\"store.updatePatchOnTrueMapping(store.keyOf(f), $index, { to: $event })\">\n @for (option of optionsWith(mapping.to); track option.value) {\n <mat-option [value]=\"option.value\">\n {{ option.label }} <span class=\"deb-path-hint\">{{ option.path }}</span>\n </mat-option>\n }\n </mat-select>\n </mat-form-field>\n <button mat-icon-button type=\"button\" color=\"warn\" matTooltip=\"Remove mapping\"\n (click)=\"store.removePatchOnTrueMapping(store.keyOf(f), $index)\">\n <mat-icon>delete</mat-icon>\n </button>\n </div>\n }\n @if (!(f.patchOnTrue ?? []).length) {\n <p class=\"deb-hint\">When this flips to true, copy one field's value into another.</p>\n }\n }\n\n <mat-divider></mat-divider>\n\n <!-- Rules attached to this field -->\n <ngx-field-rules-list />\n\n <mat-divider></mat-divider>\n\n <!-- Cross-entity field reference & drift management (Phase 8) -->\n <ngx-referenced-field-config />\n </div>\n} @else {\n <div class=\"deb-inspector__empty\">\n <mat-icon>touch_app</mat-icon>\n <p>Select a field to edit its properties.</p>\n </div>\n}\n", styles: [".deb-inspector{display:flex;flex-direction:column;gap:12px}.deb-inspector__empty{color:var(--deb-muted, #6b7280);text-align:center;padding:24px 8px}.deb-full{width:100%}.deb-row{display:flex;gap:8px;align-items:center}.deb-row>*{flex:1}.deb-row--split{justify-content:space-between}.deb-row--split>*{flex:0 0 auto}.deb-toggles{display:grid;grid-template-columns:1fr 1fr;gap:8px 12px}.deb-option-row{display:flex;gap:6px;align-items:center}.deb-chip{display:inline-block;padding:2px 8px;border-radius:12px;background:var(--deb-accent-soft, #eef2ff);color:var(--deb-accent, #6366f1);font-size:12px;font-weight:600}\n"], dependencies: [{ kind: "ngmodule", type: FormsModule }, { kind: "directive", type: i1.DefaultValueAccessor, selector: "input:not([type=checkbox])[formControlName],textarea[formControlName],input:not([type=checkbox])[formControl],textarea[formControl],input:not([type=checkbox])[ngModel],textarea[ngModel],[ngDefaultControl]" }, { kind: "directive", type: i1.NumberValueAccessor, selector: "input[type=number][formControlName],input[type=number][formControl],input[type=number][ngModel]" }, { kind: "directive", type: i1.NgControlStatus, selector: "[formControlName],[ngModel],[formControl]" }, { kind: "directive", type: i1.NgModel, selector: "[ngModel]:not([formControlName]):not([formControl])", inputs: ["name", "disabled", "ngModel", "ngModelOptions"], outputs: ["ngModelChange"], exportAs: ["ngModel"] }, { kind: "ngmodule", type: MatButtonModule }, { kind: "component", type: i2.MatButton, selector: " button[matButton], a[matButton], button[mat-button], button[mat-raised-button], button[mat-flat-button], button[mat-stroked-button], a[mat-button], a[mat-raised-button], a[mat-flat-button], a[mat-stroked-button] ", inputs: ["matButton"], exportAs: ["matButton", "matAnchor"] }, { kind: "component", type: i2.MatIconButton, selector: "button[mat-icon-button], a[mat-icon-button], button[matIconButton], a[matIconButton]", exportAs: ["matButton", "matAnchor"] }, { kind: "ngmodule", type: MatCheckboxModule }, { kind: "component", type: i3$1.MatCheckbox, selector: "mat-checkbox", inputs: ["aria-label", "aria-labelledby", "aria-describedby", "aria-expanded", "aria-controls", "aria-owns", "id", "required", "labelPosition", "name", "value", "disableRipple", "tabIndex", "color", "disabledInteractive", "checked", "disabled", "indeterminate"], outputs: ["change", "indeterminateChange"], exportAs: ["matCheckbox"] }, { kind: "ngmodule", type: MatDividerModule }, { kind: "component", type: i3.MatDivider, selector: "mat-divider", inputs: ["vertical", "inset"] }, { kind: "ngmodule", type: MatFormFieldModule }, { kind: "component", type: i4.MatFormField, selector: "mat-form-field", inputs: ["hideRequiredMarker", "color", "floatLabel", "appearance", "subscriptSizing", "hintLabel"], exportAs: ["matFormField"] }, { kind: "directive", type: i4.MatLabel, selector: "mat-label" }, { kind: "directive", type: i4.MatHint, selector: "mat-hint", inputs: ["align", "id"] }, { kind: "ngmodule", type: MatIconModule }, { kind: "component", type: i4$1.MatIcon, selector: "mat-icon", inputs: ["color", "inline", "svgIcon", "fontSet", "fontIcon"], exportAs: ["matIcon"] }, { kind: "ngmodule", type: MatInputModule }, { kind: "directive", type: i5.MatInput, selector: "input[matInput], textarea[matInput], select[matNativeControl], input[matNativeControl], textarea[matNativeControl]", inputs: ["disabled", "id", "placeholder", "name", "required", "type", "errorStateMatcher", "aria-describedby", "value", "readonly", "disabledInteractive"], exportAs: ["matInput"] }, { kind: "ngmodule", type: MatSelectModule }, { kind: "component", type: i8.MatSelect, selector: "mat-select", inputs: ["aria-describedby", "panelClass", "disabled", "disableRipple", "tabIndex", "hideSingleSelectionIndicator", "placeholder", "required", "multiple", "disableOptionCentering", "compareWith", "value", "aria-label", "aria-labelledby", "errorStateMatcher", "typeaheadDebounceInterval", "sortComparator", "id", "panelWidth", "canSelectNullableOptions"], outputs: ["openedChange", "opened", "closed", "selectionChange", "valueChange"], exportAs: ["matSelect"] }, { kind: "component", type: i8.MatOption, selector: "mat-option", inputs: ["value", "id", "disabled"], outputs: ["onSelectionChange"], exportAs: ["matOption"] }, { kind: "ngmodule", type: MatSlideToggleModule }, { kind: "component", type: i9.MatSlideToggle, selector: "mat-slide-toggle", inputs: ["name", "id", "labelPosition", "aria-label", "aria-labelledby", "aria-describedby", "required", "color", "disabled", "disableRipple", "tabIndex", "checked", "hideIcon", "disabledInteractive"], outputs: ["change", "toggleChange"], exportAs: ["matSlideToggle"] }, { kind: "ngmodule", type: MatTooltipModule }, { kind: "directive", type: i4$2.MatTooltip, selector: "[matTooltip]", inputs: ["matTooltipPosition", "matTooltipPositionAtOrigin", "matTooltipDisabled", "matTooltipShowDelay", "matTooltipHideDelay", "matTooltipTouchGestures", "matTooltip", "matTooltipClass"], exportAs: ["matTooltip"] }, { kind: "component", type: EntityReferenceConfigComponent, selector: "ngx-entity-reference-config" }, { kind: "component", type: FieldRulesListComponent, selector: "ngx-field-rules-list" }, { kind: "component", type: ReferencedFieldConfigComponent, selector: "ngx-referenced-field-config" }] });
|
|
2567
2810
|
}
|
|
2568
2811
|
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.22", ngImport: i0, type: FieldInspectorComponent, decorators: [{
|
|
2569
2812
|
type: Component,
|
|
@@ -2581,7 +2824,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.22", ngImpo
|
|
|
2581
2824
|
EntityReferenceConfigComponent,
|
|
2582
2825
|
FieldRulesListComponent,
|
|
2583
2826
|
ReferencedFieldConfigComponent,
|
|
2584
|
-
], template: "@if (field(); as f) {\n <div class=\"deb-inspector\">\n <!-- Identity -->\n <div class=\"deb-row\">\n <mat-form-field appearance=\"outline\" subscriptSizing=\"dynamic\">\n <mat-label>Field id</mat-label>\n <input matInput data-testid=\"field-id\" [value]=\"f.id\" disabled />\n @if (store.hasManualId(f.id)) {\n <mat-hint>The stored data key for this saved field \u2014 fixed.</mat-hint>\n } @else {\n <mat-hint>Derived from the label. Used as the data key.</mat-hint>\n }\n </mat-form-field>\n <span class=\"deb-chip\">{{ meta()?.label || f.type }}</span>\n </div>\n\n @if (tabOptions().length > 1) {\n <mat-form-field appearance=\"outline\" subscriptSizing=\"dynamic\" class=\"deb-full\">\n <mat-label>Tab</mat-label>\n <mat-select data-testid=\"field-tab\"\n [ngModel]=\"currentTabId(f)\" (ngModelChange)=\"moveToTab(f, $event)\">\n @for (tab of tabOptions(); track tab.id) {\n <mat-option [value]=\"tab.id\">{{ tab.label }}</mat-option>\n }\n </mat-select>\n <mat-hint>Moving the field rewrites its path and repoints the rules that named it.</mat-hint>\n </mat-form-field>\n }\n\n\n <!-- Localised text (active language) -->\n <mat-form-field appearance=\"outline\" subscriptSizing=\"dynamic\" class=\"deb-full\">\n <mat-label>Label ({{ lang() }})</mat-label>\n <input\n matInput\n data-testid=\"field-label\"\n [ngModel]=\"labelValue(f)\"\n (ngModelChange)=\"store.setFieldLabel(f.id, lang(), $event)\"\n />\n </mat-form-field>\n\n @if (meta()?.supportsPlaceholder) {\n <mat-form-field appearance=\"outline\" subscriptSizing=\"dynamic\" class=\"deb-full\">\n <mat-label>Placeholder ({{ lang() }})</mat-label>\n <input\n matInput\n [ngModel]=\"placeholderValue(f)\"\n (ngModelChange)=\"store.setFieldPlaceholder(f.id, lang(), $event)\"\n />\n </mat-form-field>\n }\n\n @if (meta()?.supportsDefaultValue) {\n <mat-form-field appearance=\"outline\" subscriptSizing=\"dynamic\" class=\"deb-full\">\n <mat-label>Default value</mat-label>\n <input\n matInput\n [ngModel]=\"f.defaultValue ?? ''\"\n (ngModelChange)=\"store.updateField(f.id, { defaultValue: $event })\"\n />\n </mat-form-field>\n }\n\n <!-- Entity reference, cascade, and autoPatch -->\n @if (meta()?.isEntityRef) {\n <mat-divider></mat-divider>\n <ngx-entity-reference-config />\n }\n\n <mat-divider></mat-divider>\n\n <!-- Validators -->\n @if (meta(); as m) {\n @if (m.flagValidators.length || m.paramValidators.length) {\n <span class=\"deb-section-title\">Validation</span>\n <div class=\"deb-toggles\">\n @for (v of m.flagValidators; track v) {\n <mat-checkbox\n [ngModel]=\"store.hasFlagValidator(f, v)\"\n (ngModelChange)=\"store.toggleFlagValidator(f.id, v, $event)\"\n >{{ v }}</mat-checkbox>\n }\n </div>\n @for (v of m.paramValidators; track v) {\n <mat-form-field appearance=\"outline\" subscriptSizing=\"dynamic\" class=\"deb-full\">\n <mat-label>{{ v }}</mat-label>\n <input\n matInput\n type=\"number\"\n [ngModel]=\"store.getParamValidator(f, v)\"\n (ngModelChange)=\"store.setParamValidator(f.id, v, toNum($event))\"\n />\n </mat-form-field>\n }\n }\n }\n\n <!-- Options (dropdown / radio / multiSelect) -->\n @if (meta()?.hasOptions) {\n <mat-divider></mat-divider>\n <span class=\"deb-section-title\">Data source</span>\n <mat-form-field appearance=\"outline\" subscriptSizing=\"dynamic\" class=\"deb-full\">\n <mat-label>Options come from</mat-label>\n <mat-select\n data-testid=\"data-source\"\n [ngModel]=\"store.fieldDataSource(f)\"\n (ngModelChange)=\"store.setFieldDataSource(f.id, $event)\"\n >\n <mat-option value=\"none\">Nothing yet</mat-option>\n <mat-option value=\"manual\">Options authored here</mat-option>\n <mat-option value=\"lookup\">A named list</mat-option>\n </mat-select>\n </mat-form-field>\n }\n\n <!-- Named lookup list -->\n @if (meta()?.hasOptions && store.fieldDataSource(f) === 'lookup') {\n <mat-form-field appearance=\"outline\" subscriptSizing=\"dynamic\" class=\"deb-full\">\n <mat-label>List name</mat-label>\n <input\n matInput\n data-testid=\"list-name\"\n [ngModel]=\"f.listName ?? ''\"\n (ngModelChange)=\"store.setListName(f.id, $event)\"\n />\n </mat-form-field>\n <p class=\"deb-hint\">\n The consuming app resolves this name through its lookup registry. Options are not\n authored here.\n </p>\n }\n\n <!-- Inline options -->\n @if (meta()?.hasOptions && store.fieldDataSource(f) !== 'lookup') {\n <div class=\"deb-row\" style=\"justify-content: space-between;\">\n <span class=\"deb-section-title\">Options</span>\n <button mat-stroked-button type=\"button\" (click)=\"store.addOption(f.id)\">\n <mat-icon>add</mat-icon> Option\n </button>\n </div>\n @for (opt of f.options ?? []; track $index) {\n <div class=\"deb-option-row\" data-testid=\"option-row\">\n <!-- One input: the displayed text is the stored value. -->\n <mat-form-field appearance=\"outline\" subscriptSizing=\"dynamic\" class=\"deb-full\">\n <mat-label>Option ({{ lang() }})</mat-label>\n <input\n matInput\n [attr.data-testid]=\"'option-' + $index\"\n [ngModel]=\"optionLabel(opt)\"\n (ngModelChange)=\"store.setOptionLabel(f.id, $index, lang(), $event)\"\n />\n </mat-form-field>\n <button mat-icon-button type=\"button\" color=\"warn\"\n (click)=\"store.removeOption(f.id, $index)\" matTooltip=\"Remove option\">\n <mat-icon>delete</mat-icon>\n </button>\n </div>\n }\n @if (!(f.options ?? []).length) {\n <p class=\"deb-hint\">No options yet \u2014 add at least one.</p>\n }\n }\n\n <mat-divider></mat-divider>\n\n <!-- Flags -->\n <span class=\"deb-section-title\">Display</span>\n <div class=\"deb-toggles\">\n <mat-slide-toggle\n [ngModel]=\"f.visibility ?? true\"\n (ngModelChange)=\"store.updateField(f.id, { visibility: $event })\"\n >Visible</mat-slide-toggle>\n <mat-slide-toggle\n [ngModel]=\"f.table?.visible ?? false\"\n (ngModelChange)=\"store.updateField(f.id, { table: { visible: $event } })\"\n >Table column</mat-slide-toggle>\n <mat-slide-toggle\n [ngModel]=\"f.readonly ?? false\"\n (ngModelChange)=\"store.updateField(f.id, { readonly: $event })\"\n >Read-only</mat-slide-toggle>\n <mat-slide-toggle\n [ngModel]=\"f.disabled ?? false\"\n (ngModelChange)=\"store.updateField(f.id, { disabled: $event })\"\n >Disabled</mat-slide-toggle>\n <mat-slide-toggle\n [ngModel]=\"f.maskData ?? false\"\n (ngModelChange)=\"store.updateField(f.id, { maskData: $event })\"\n >Mask data (RBAC)</mat-slide-toggle>\n <mat-slide-toggle\n data-testid=\"toggle-critical\"\n [ngModel]=\"f.criticalField ?? false\"\n (ngModelChange)=\"store.updateField(f.id, { criticalField: $event })\"\n matTooltip=\"Renders locked; editing it announces a change against the session baseline.\"\n >Critical field</mat-slide-toggle>\n <mat-slide-toggle\n [ngModel]=\"f.showOnMinimize ?? false\"\n (ngModelChange)=\"store.updateField(f.id, { showOnMinimize: $event })\"\n matTooltip=\"Shown in the record editor's summary panel.\"\n >Show in summary</mat-slide-toggle>\n </div>\n\n <mat-divider></mat-divider>\n\n <!-- Conditional visibility (static showWhen) -->\n <div class=\"deb-row deb-row--split\">\n <span class=\"deb-section-title\">Show when</span>\n <button mat-stroked-button type=\"button\" data-testid=\"add-show-when\" (click)=\"addShowWhen(f)\">\n <mat-icon>add</mat-icon> Condition\n </button>\n </div>\n @for (entry of showWhenEntries(f); track entry.key) {\n <div class=\"deb-option-row\" data-testid=\"option-row\">\n <mat-form-field appearance=\"outline\" subscriptSizing=\"dynamic\">\n <mat-label>Watched field</mat-label>\n <mat-select data-testid=\"show-when-field\"\n [ngModel]=\"entry.key\" (ngModelChange)=\"renameShowWhen(f, entry.key, $event)\">\n @for (option of optionsWith(entry.key); track option.value) {\n <mat-option [value]=\"option.value\">\n {{ option.label }} <span class=\"deb-path-hint\">{{ option.path }}</span>\n </mat-option>\n }\n </mat-select>\n </mat-form-field>\n <mat-form-field appearance=\"outline\" subscriptSizing=\"dynamic\">\n <mat-label>Equals</mat-label>\n <input matInput [ngModel]=\"entry.display\" (ngModelChange)=\"setShowWhenValue(f, entry.key, $event)\" />\n </mat-form-field>\n <button mat-icon-button type=\"button\" color=\"warn\" matTooltip=\"Remove condition\"\n (click)=\"removeShowWhen(f, entry.key)\">\n <mat-icon>delete</mat-icon>\n </button>\n </div>\n }\n @if (!showWhenEntries(f).length) {\n <p class=\"deb-hint\">Always visible. Add a condition to show this field only for certain values.</p>\n }\n\n <!-- patchOnTrue (boolean/checkbox only) -->\n @if (f.type === 'boolean' || f.type === 'checkbox') {\n <mat-divider></mat-divider>\n <div class=\"deb-row deb-row--split\">\n <span class=\"deb-section-title\">Copy on true</span>\n <button mat-stroked-button type=\"button\" data-testid=\"add-patch-on-true\"\n (click)=\"store.addPatchOnTrueMapping(f.id)\">\n <mat-icon>add</mat-icon> Mapping\n </button>\n </div>\n @for (mapping of f.patchOnTrue ?? []; track $index) {\n <div class=\"deb-option-row\" data-testid=\"option-row\">\n <mat-form-field appearance=\"outline\" subscriptSizing=\"dynamic\">\n <mat-label>From field</mat-label>\n <mat-select data-testid=\"patch-on-true-from\"\n [ngModel]=\"mapping.from\"\n (ngModelChange)=\"store.updatePatchOnTrueMapping(f.id, $index, { from: $event })\">\n @for (option of optionsWith(mapping.from); track option.value) {\n <mat-option [value]=\"option.value\">\n {{ option.label }} <span class=\"deb-path-hint\">{{ option.path }}</span>\n </mat-option>\n }\n </mat-select>\n </mat-form-field>\n <mat-form-field appearance=\"outline\" subscriptSizing=\"dynamic\">\n <mat-label>To field</mat-label>\n <mat-select data-testid=\"patch-on-true-to\"\n [ngModel]=\"mapping.to\"\n (ngModelChange)=\"store.updatePatchOnTrueMapping(f.id, $index, { to: $event })\">\n @for (option of optionsWith(mapping.to); track option.value) {\n <mat-option [value]=\"option.value\">\n {{ option.label }} <span class=\"deb-path-hint\">{{ option.path }}</span>\n </mat-option>\n }\n </mat-select>\n </mat-form-field>\n <button mat-icon-button type=\"button\" color=\"warn\" matTooltip=\"Remove mapping\"\n (click)=\"store.removePatchOnTrueMapping(f.id, $index)\">\n <mat-icon>delete</mat-icon>\n </button>\n </div>\n }\n @if (!(f.patchOnTrue ?? []).length) {\n <p class=\"deb-hint\">When this flips to true, copy one field's value into another.</p>\n }\n }\n\n <mat-divider></mat-divider>\n\n <!-- Rules attached to this field -->\n <ngx-field-rules-list />\n\n <mat-divider></mat-divider>\n\n <!-- Cross-entity field reference & drift management (Phase 8) -->\n <ngx-referenced-field-config />\n </div>\n} @else {\n <div class=\"deb-inspector__empty\">\n <mat-icon>touch_app</mat-icon>\n <p>Select a field to edit its properties.</p>\n </div>\n}\n", styles: [".deb-inspector{display:flex;flex-direction:column;gap:12px}.deb-inspector__empty{color:var(--deb-muted, #6b7280);text-align:center;padding:24px 8px}.deb-full{width:100%}.deb-row{display:flex;gap:8px;align-items:center}.deb-row>*{flex:1}.deb-row--split{justify-content:space-between}.deb-row--split>*{flex:0 0 auto}.deb-toggles{display:grid;grid-template-columns:1fr 1fr;gap:8px 12px}.deb-option-row{display:flex;gap:6px;align-items:center}.deb-chip{display:inline-block;padding:2px 8px;border-radius:12px;background:var(--deb-accent-soft, #eef2ff);color:var(--deb-accent, #6366f1);font-size:12px;font-weight:600}\n"] }]
|
|
2827
|
+
], template: "@if (field(); as f) {\n <div class=\"deb-inspector\">\n <!-- Identity -->\n <div class=\"deb-row\">\n <mat-form-field appearance=\"outline\" subscriptSizing=\"dynamic\">\n <mat-label>Field id</mat-label>\n <input matInput data-testid=\"field-id\" [value]=\"f.id\" disabled />\n @if (store.hasManualId(f.id)) {\n <mat-hint>The stored data key for this saved field \u2014 fixed.</mat-hint>\n } @else {\n <mat-hint>Derived from the label. Used as the data key.</mat-hint>\n }\n </mat-form-field>\n <span class=\"deb-chip\">{{ meta()?.label || f.type }}</span>\n </div>\n\n @if (tabOptions().length > 1) {\n <mat-form-field appearance=\"outline\" subscriptSizing=\"dynamic\" class=\"deb-full\">\n <mat-label>Tab</mat-label>\n <mat-select data-testid=\"field-tab\"\n [ngModel]=\"currentTabId(f)\" (ngModelChange)=\"moveToTab(f, $event)\">\n @for (tab of tabOptions(); track tab.id) {\n <mat-option [value]=\"tab.id\">{{ tab.label }}</mat-option>\n }\n </mat-select>\n <mat-hint>Moving the field rewrites its path and repoints the rules that named it.</mat-hint>\n </mat-form-field>\n }\n\n\n <!-- Localised text (active language) -->\n <mat-form-field appearance=\"outline\" subscriptSizing=\"dynamic\" class=\"deb-full\">\n <mat-label>Label ({{ lang() }})</mat-label>\n <input\n matInput\n data-testid=\"field-label\"\n [ngModel]=\"labelValue(f)\"\n (ngModelChange)=\"store.setFieldLabel(store.keyOf(f), lang(), $event)\"\n />\n </mat-form-field>\n\n @if (meta()?.supportsPlaceholder) {\n <mat-form-field appearance=\"outline\" subscriptSizing=\"dynamic\" class=\"deb-full\">\n <mat-label>Placeholder ({{ lang() }})</mat-label>\n <input\n matInput\n [ngModel]=\"placeholderValue(f)\"\n (ngModelChange)=\"store.setFieldPlaceholder(store.keyOf(f), lang(), $event)\"\n />\n </mat-form-field>\n }\n\n @if (meta()?.supportsDefaultValue) {\n <mat-form-field appearance=\"outline\" subscriptSizing=\"dynamic\" class=\"deb-full\">\n <mat-label>Default value</mat-label>\n <input\n matInput\n [ngModel]=\"f.defaultValue ?? ''\"\n (ngModelChange)=\"store.updateField(store.keyOf(f), { defaultValue: $event })\"\n />\n </mat-form-field>\n }\n\n <!-- Entity reference, cascade, and autoPatch -->\n @if (meta()?.isEntityRef) {\n <mat-divider></mat-divider>\n <ngx-entity-reference-config />\n }\n\n <mat-divider></mat-divider>\n\n <!-- Validators -->\n @if (meta(); as m) {\n @if (m.flagValidators.length || m.paramValidators.length) {\n <span class=\"deb-section-title\">Validation</span>\n <div class=\"deb-toggles\">\n @for (v of m.flagValidators; track v) {\n <mat-checkbox\n [ngModel]=\"store.hasFlagValidator(f, v)\"\n (ngModelChange)=\"store.toggleFlagValidator(store.keyOf(f), v, $event)\"\n >{{ v }}</mat-checkbox>\n }\n </div>\n @for (v of m.paramValidators; track v) {\n <mat-form-field appearance=\"outline\" subscriptSizing=\"dynamic\" class=\"deb-full\">\n <mat-label>{{ v }}</mat-label>\n <input\n matInput\n type=\"number\"\n [ngModel]=\"store.getParamValidator(f, v)\"\n (ngModelChange)=\"store.setParamValidator(store.keyOf(f), v, toNum($event))\"\n />\n </mat-form-field>\n }\n }\n }\n\n <!-- Options (dropdown / radio / multiSelect) -->\n @if (meta()?.hasOptions) {\n <mat-divider></mat-divider>\n <span class=\"deb-section-title\">Data source</span>\n <mat-form-field appearance=\"outline\" subscriptSizing=\"dynamic\" class=\"deb-full\">\n <mat-label>Options come from</mat-label>\n <mat-select\n data-testid=\"data-source\"\n [ngModel]=\"store.fieldDataSource(f)\"\n (ngModelChange)=\"store.setFieldDataSource(store.keyOf(f), $event)\"\n >\n <mat-option value=\"none\">Nothing yet</mat-option>\n <mat-option value=\"manual\">Options authored here</mat-option>\n <mat-option value=\"lookup\">A named list</mat-option>\n </mat-select>\n </mat-form-field>\n }\n\n <!-- Named lookup list -->\n @if (meta()?.hasOptions && store.fieldDataSource(f) === 'lookup') {\n <mat-form-field appearance=\"outline\" subscriptSizing=\"dynamic\" class=\"deb-full\">\n <mat-label>List name</mat-label>\n <input\n matInput\n data-testid=\"list-name\"\n [ngModel]=\"f.listName ?? ''\"\n (ngModelChange)=\"store.setListName(store.keyOf(f), $event)\"\n />\n </mat-form-field>\n <p class=\"deb-hint\">\n The consuming app resolves this name through its lookup registry. Options are not\n authored here.\n </p>\n }\n\n <!-- Inline options -->\n @if (meta()?.hasOptions && store.fieldDataSource(f) !== 'lookup') {\n <div class=\"deb-row\" style=\"justify-content: space-between;\">\n <span class=\"deb-section-title\">Options</span>\n <button mat-stroked-button type=\"button\" (click)=\"store.addOption(store.keyOf(f))\">\n <mat-icon>add</mat-icon> Option\n </button>\n </div>\n @for (opt of f.options ?? []; track $index) {\n <div class=\"deb-option-row\" data-testid=\"option-row\">\n <!-- One input: the displayed text is the stored value. -->\n <mat-form-field appearance=\"outline\" subscriptSizing=\"dynamic\" class=\"deb-full\">\n <mat-label>Option ({{ lang() }})</mat-label>\n <input\n matInput\n [attr.data-testid]=\"'option-' + $index\"\n [ngModel]=\"optionLabel(opt)\"\n (ngModelChange)=\"store.setOptionLabel(store.keyOf(f), $index, lang(), $event)\"\n />\n </mat-form-field>\n <button mat-icon-button type=\"button\" color=\"warn\"\n (click)=\"store.removeOption(store.keyOf(f), $index)\" matTooltip=\"Remove option\">\n <mat-icon>delete</mat-icon>\n </button>\n </div>\n }\n @if (!(f.options ?? []).length) {\n <p class=\"deb-hint\">No options yet \u2014 add at least one.</p>\n }\n }\n\n <mat-divider></mat-divider>\n\n <!-- Flags -->\n <span class=\"deb-section-title\">Display</span>\n <div class=\"deb-toggles\">\n <mat-slide-toggle\n [ngModel]=\"f.visibility ?? true\"\n (ngModelChange)=\"store.updateField(store.keyOf(f), { visibility: $event })\"\n >Visible</mat-slide-toggle>\n <mat-slide-toggle\n [ngModel]=\"f.table?.visible ?? false\"\n (ngModelChange)=\"store.updateField(store.keyOf(f), { table: { visible: $event } })\"\n >Table column</mat-slide-toggle>\n <mat-slide-toggle\n [ngModel]=\"f.readonly ?? false\"\n (ngModelChange)=\"store.updateField(store.keyOf(f), { readonly: $event })\"\n >Read-only</mat-slide-toggle>\n <mat-slide-toggle\n [ngModel]=\"f.disabled ?? false\"\n (ngModelChange)=\"store.updateField(store.keyOf(f), { disabled: $event })\"\n >Disabled</mat-slide-toggle>\n <mat-slide-toggle\n [ngModel]=\"f.maskData ?? false\"\n (ngModelChange)=\"store.updateField(store.keyOf(f), { maskData: $event })\"\n >Mask data (RBAC)</mat-slide-toggle>\n <mat-slide-toggle\n data-testid=\"toggle-critical\"\n [ngModel]=\"f.criticalField ?? false\"\n (ngModelChange)=\"store.updateField(store.keyOf(f), { criticalField: $event })\"\n matTooltip=\"Renders locked; editing it announces a change against the session baseline.\"\n >Critical field</mat-slide-toggle>\n <mat-slide-toggle\n [ngModel]=\"f.showOnMinimize ?? false\"\n (ngModelChange)=\"store.updateField(store.keyOf(f), { showOnMinimize: $event })\"\n matTooltip=\"Shown in the record editor's summary panel.\"\n >Show in summary</mat-slide-toggle>\n </div>\n\n <mat-divider></mat-divider>\n\n <!-- Conditional visibility (static showWhen) -->\n <div class=\"deb-row deb-row--split\">\n <span class=\"deb-section-title\">Show when</span>\n <button mat-stroked-button type=\"button\" data-testid=\"add-show-when\" (click)=\"addShowWhen(f)\">\n <mat-icon>add</mat-icon> Condition\n </button>\n </div>\n @for (entry of showWhenEntries(f); track entry.key) {\n <div class=\"deb-option-row\" data-testid=\"option-row\">\n <mat-form-field appearance=\"outline\" subscriptSizing=\"dynamic\">\n <mat-label>Watched field</mat-label>\n <mat-select data-testid=\"show-when-field\"\n [ngModel]=\"entry.key\" (ngModelChange)=\"renameShowWhen(f, entry.key, $event)\">\n @for (option of optionsWith(entry.key); track option.value) {\n <mat-option [value]=\"option.value\">\n {{ option.label }} <span class=\"deb-path-hint\">{{ option.path }}</span>\n </mat-option>\n }\n </mat-select>\n </mat-form-field>\n <mat-form-field appearance=\"outline\" subscriptSizing=\"dynamic\">\n <mat-label>Equals</mat-label>\n <input matInput [ngModel]=\"entry.display\" (ngModelChange)=\"setShowWhenValue(f, entry.key, $event)\" />\n </mat-form-field>\n <button mat-icon-button type=\"button\" color=\"warn\" matTooltip=\"Remove condition\"\n (click)=\"removeShowWhen(f, entry.key)\">\n <mat-icon>delete</mat-icon>\n </button>\n </div>\n }\n @if (!showWhenEntries(f).length) {\n <p class=\"deb-hint\">Always visible. Add a condition to show this field only for certain values.</p>\n }\n\n <!-- patchOnTrue (boolean/checkbox only) -->\n @if (f.type === 'boolean' || f.type === 'checkbox') {\n <mat-divider></mat-divider>\n <div class=\"deb-row deb-row--split\">\n <span class=\"deb-section-title\">Copy on true</span>\n <button mat-stroked-button type=\"button\" data-testid=\"add-patch-on-true\"\n (click)=\"store.addPatchOnTrueMapping(store.keyOf(f))\">\n <mat-icon>add</mat-icon> Mapping\n </button>\n </div>\n @for (mapping of f.patchOnTrue ?? []; track $index) {\n <div class=\"deb-option-row\" data-testid=\"option-row\">\n <mat-form-field appearance=\"outline\" subscriptSizing=\"dynamic\">\n <mat-label>From field</mat-label>\n <mat-select data-testid=\"patch-on-true-from\"\n [ngModel]=\"mapping.from\"\n (ngModelChange)=\"store.updatePatchOnTrueMapping(store.keyOf(f), $index, { from: $event })\">\n @for (option of optionsWith(mapping.from); track option.value) {\n <mat-option [value]=\"option.value\">\n {{ option.label }} <span class=\"deb-path-hint\">{{ option.path }}</span>\n </mat-option>\n }\n </mat-select>\n </mat-form-field>\n <mat-form-field appearance=\"outline\" subscriptSizing=\"dynamic\">\n <mat-label>To field</mat-label>\n <mat-select data-testid=\"patch-on-true-to\"\n [ngModel]=\"mapping.to\"\n (ngModelChange)=\"store.updatePatchOnTrueMapping(store.keyOf(f), $index, { to: $event })\">\n @for (option of optionsWith(mapping.to); track option.value) {\n <mat-option [value]=\"option.value\">\n {{ option.label }} <span class=\"deb-path-hint\">{{ option.path }}</span>\n </mat-option>\n }\n </mat-select>\n </mat-form-field>\n <button mat-icon-button type=\"button\" color=\"warn\" matTooltip=\"Remove mapping\"\n (click)=\"store.removePatchOnTrueMapping(store.keyOf(f), $index)\">\n <mat-icon>delete</mat-icon>\n </button>\n </div>\n }\n @if (!(f.patchOnTrue ?? []).length) {\n <p class=\"deb-hint\">When this flips to true, copy one field's value into another.</p>\n }\n }\n\n <mat-divider></mat-divider>\n\n <!-- Rules attached to this field -->\n <ngx-field-rules-list />\n\n <mat-divider></mat-divider>\n\n <!-- Cross-entity field reference & drift management (Phase 8) -->\n <ngx-referenced-field-config />\n </div>\n} @else {\n <div class=\"deb-inspector__empty\">\n <mat-icon>touch_app</mat-icon>\n <p>Select a field to edit its properties.</p>\n </div>\n}\n", styles: [".deb-inspector{display:flex;flex-direction:column;gap:12px}.deb-inspector__empty{color:var(--deb-muted, #6b7280);text-align:center;padding:24px 8px}.deb-full{width:100%}.deb-row{display:flex;gap:8px;align-items:center}.deb-row>*{flex:1}.deb-row--split{justify-content:space-between}.deb-row--split>*{flex:0 0 auto}.deb-toggles{display:grid;grid-template-columns:1fr 1fr;gap:8px 12px}.deb-option-row{display:flex;gap:6px;align-items:center}.deb-chip{display:inline-block;padding:2px 8px;border-radius:12px;background:var(--deb-accent-soft, #eef2ff);color:var(--deb-accent, #6366f1);font-size:12px;font-weight:600}\n"] }]
|
|
2585
2828
|
}] });
|
|
2586
2829
|
|
|
2587
2830
|
/**
|
|
@@ -2977,13 +3220,13 @@ class EntityBuilderTreeNodeComponent {
|
|
|
2977
3220
|
cdkDrag
|
|
2978
3221
|
class="deb-field-row"
|
|
2979
3222
|
data-testid="builder-field-row"
|
|
2980
|
-
[class.deb-field-row--active]="store.
|
|
2981
|
-
(click)="store.selectField(field
|
|
2982
|
-
(keydown.enter)="store.selectField(field
|
|
2983
|
-
(keydown.space)="store.selectField(field
|
|
3223
|
+
[class.deb-field-row--active]="store.isSelected(field)"
|
|
3224
|
+
(click)="store.selectField(store.keyOf(field))"
|
|
3225
|
+
(keydown.enter)="store.selectField(store.keyOf(field))"
|
|
3226
|
+
(keydown.space)="store.selectField(store.keyOf(field)); $event.preventDefault()"
|
|
2984
3227
|
role="button"
|
|
2985
3228
|
tabindex="0"
|
|
2986
|
-
[attr.aria-pressed]="store.
|
|
3229
|
+
[attr.aria-pressed]="store.isSelected(field)"
|
|
2987
3230
|
[attr.aria-label]="'Select field ' + fieldLabel(field)"
|
|
2988
3231
|
>
|
|
2989
3232
|
<mat-icon class="deb-drag-handle" cdkDragHandle aria-hidden="true" title="Drag to reorder (or use the move buttons)">drag_indicator</mat-icon>
|
|
@@ -3006,24 +3249,24 @@ class EntityBuilderTreeNodeComponent {
|
|
|
3006
3249
|
<button mat-icon-button type="button" [disabled]="index === 0"
|
|
3007
3250
|
[attr.data-testid]="'row-up-' + field.id"
|
|
3008
3251
|
[attr.aria-label]="'Move ' + fieldLabel(field) + ' up'"
|
|
3009
|
-
(click)="store.moveField(field
|
|
3252
|
+
(click)="store.moveField(store.keyOf(field), -1); $event.stopPropagation()" matTooltip="Move up">
|
|
3010
3253
|
<mat-icon>arrow_upward</mat-icon>
|
|
3011
3254
|
</button>
|
|
3012
3255
|
<button mat-icon-button type="button" [disabled]="index === totalCount - 1"
|
|
3013
3256
|
[attr.data-testid]="'row-down-' + field.id"
|
|
3014
3257
|
[attr.aria-label]="'Move ' + fieldLabel(field) + ' down'"
|
|
3015
|
-
(click)="store.moveField(field
|
|
3258
|
+
(click)="store.moveField(store.keyOf(field), 1); $event.stopPropagation()" matTooltip="Move down">
|
|
3016
3259
|
<mat-icon>arrow_downward</mat-icon>
|
|
3017
3260
|
</button>
|
|
3018
3261
|
<button mat-icon-button type="button"
|
|
3019
3262
|
[attr.data-testid]="'row-duplicate-' + field.id"
|
|
3020
3263
|
[attr.aria-label]="'Duplicate ' + fieldLabel(field)"
|
|
3021
|
-
(click)="store.duplicateField(field
|
|
3264
|
+
(click)="store.duplicateField(store.keyOf(field)); $event.stopPropagation()" matTooltip="Duplicate">
|
|
3022
3265
|
<mat-icon>content_copy</mat-icon>
|
|
3023
3266
|
</button>
|
|
3024
3267
|
<button mat-icon-button type="button" color="warn"
|
|
3025
3268
|
[attr.data-testid]="'row-delete-' + field.id"
|
|
3026
|
-
(click)="store.removeField(field
|
|
3269
|
+
(click)="store.removeField(store.keyOf(field)); $event.stopPropagation()" matTooltip="Delete">
|
|
3027
3270
|
<mat-icon>delete</mat-icon>
|
|
3028
3271
|
</button>
|
|
3029
3272
|
</div>
|
|
@@ -3059,13 +3302,13 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.22", ngImpo
|
|
|
3059
3302
|
cdkDrag
|
|
3060
3303
|
class="deb-field-row"
|
|
3061
3304
|
data-testid="builder-field-row"
|
|
3062
|
-
[class.deb-field-row--active]="store.
|
|
3063
|
-
(click)="store.selectField(field
|
|
3064
|
-
(keydown.enter)="store.selectField(field
|
|
3065
|
-
(keydown.space)="store.selectField(field
|
|
3305
|
+
[class.deb-field-row--active]="store.isSelected(field)"
|
|
3306
|
+
(click)="store.selectField(store.keyOf(field))"
|
|
3307
|
+
(keydown.enter)="store.selectField(store.keyOf(field))"
|
|
3308
|
+
(keydown.space)="store.selectField(store.keyOf(field)); $event.preventDefault()"
|
|
3066
3309
|
role="button"
|
|
3067
3310
|
tabindex="0"
|
|
3068
|
-
[attr.aria-pressed]="store.
|
|
3311
|
+
[attr.aria-pressed]="store.isSelected(field)"
|
|
3069
3312
|
[attr.aria-label]="'Select field ' + fieldLabel(field)"
|
|
3070
3313
|
>
|
|
3071
3314
|
<mat-icon class="deb-drag-handle" cdkDragHandle aria-hidden="true" title="Drag to reorder (or use the move buttons)">drag_indicator</mat-icon>
|
|
@@ -3088,24 +3331,24 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.22", ngImpo
|
|
|
3088
3331
|
<button mat-icon-button type="button" [disabled]="index === 0"
|
|
3089
3332
|
[attr.data-testid]="'row-up-' + field.id"
|
|
3090
3333
|
[attr.aria-label]="'Move ' + fieldLabel(field) + ' up'"
|
|
3091
|
-
(click)="store.moveField(field
|
|
3334
|
+
(click)="store.moveField(store.keyOf(field), -1); $event.stopPropagation()" matTooltip="Move up">
|
|
3092
3335
|
<mat-icon>arrow_upward</mat-icon>
|
|
3093
3336
|
</button>
|
|
3094
3337
|
<button mat-icon-button type="button" [disabled]="index === totalCount - 1"
|
|
3095
3338
|
[attr.data-testid]="'row-down-' + field.id"
|
|
3096
3339
|
[attr.aria-label]="'Move ' + fieldLabel(field) + ' down'"
|
|
3097
|
-
(click)="store.moveField(field
|
|
3340
|
+
(click)="store.moveField(store.keyOf(field), 1); $event.stopPropagation()" matTooltip="Move down">
|
|
3098
3341
|
<mat-icon>arrow_downward</mat-icon>
|
|
3099
3342
|
</button>
|
|
3100
3343
|
<button mat-icon-button type="button"
|
|
3101
3344
|
[attr.data-testid]="'row-duplicate-' + field.id"
|
|
3102
3345
|
[attr.aria-label]="'Duplicate ' + fieldLabel(field)"
|
|
3103
|
-
(click)="store.duplicateField(field
|
|
3346
|
+
(click)="store.duplicateField(store.keyOf(field)); $event.stopPropagation()" matTooltip="Duplicate">
|
|
3104
3347
|
<mat-icon>content_copy</mat-icon>
|
|
3105
3348
|
</button>
|
|
3106
3349
|
<button mat-icon-button type="button" color="warn"
|
|
3107
3350
|
[attr.data-testid]="'row-delete-' + field.id"
|
|
3108
|
-
(click)="store.removeField(field
|
|
3351
|
+
(click)="store.removeField(store.keyOf(field)); $event.stopPropagation()" matTooltip="Delete">
|
|
3109
3352
|
<mat-icon>delete</mat-icon>
|
|
3110
3353
|
</button>
|
|
3111
3354
|
</div>
|
|
@@ -3287,6 +3530,25 @@ class EntityBuilderComponent {
|
|
|
3287
3530
|
constructor() {
|
|
3288
3531
|
effect(() => this.configChange.emit(this.store.config()), { allowSignalWrites: true });
|
|
3289
3532
|
}
|
|
3533
|
+
/**
|
|
3534
|
+
* Ctrl/Cmd+Z and Ctrl/Cmd+Shift+Z, but only when the user is not typing.
|
|
3535
|
+
*
|
|
3536
|
+
* A textarea or input has its own undo stack, and hijacking it would make Ctrl+Z revert a
|
|
3537
|
+
* whole structural edit when the author only wanted to take back a character.
|
|
3538
|
+
*/
|
|
3539
|
+
onKeydown(event) {
|
|
3540
|
+
if (!(event.ctrlKey || event.metaKey) || event.key.toLowerCase() !== 'z')
|
|
3541
|
+
return;
|
|
3542
|
+
const target = event.target;
|
|
3543
|
+
const tag = target?.tagName?.toLowerCase();
|
|
3544
|
+
if (tag === 'input' || tag === 'textarea' || target?.isContentEditable)
|
|
3545
|
+
return;
|
|
3546
|
+
event.preventDefault();
|
|
3547
|
+
if (event.shiftKey)
|
|
3548
|
+
this.store.redo();
|
|
3549
|
+
else
|
|
3550
|
+
this.store.undo();
|
|
3551
|
+
}
|
|
3290
3552
|
ngOnChanges(changes) {
|
|
3291
3553
|
if (changes['config'] && changes['config'].currentValue !== changes['config'].previousValue) {
|
|
3292
3554
|
if (this.config)
|
|
@@ -3339,7 +3601,7 @@ class EntityBuilderComponent {
|
|
|
3339
3601
|
this.store.setPermission(action, roles);
|
|
3340
3602
|
}
|
|
3341
3603
|
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.22", ngImport: i0, type: EntityBuilderComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
|
|
3342
|
-
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.22", type: EntityBuilderComponent, isStandalone: true, selector: "ngx-entity-builder", inputs: { config: "config", languages: "languages", availableRoles: "availableRoles", userRoles: "userRoles", commonModules: "commonModules" }, outputs: { configChange: "configChange", save: "save" }, providers: [BuilderStore], usesOnChanges: true, ngImport: i0, template: "<mat-toolbar class=\"deb-toolbar\" color=\"primary\">\n <mat-icon>dashboard_customize</mat-icon>\n <span class=\"deb-toolbar__title\">Entity Builder</span>\n <span class=\"deb-spacer\"></span>\n\n @if (store.problems().length) {\n <button\n mat-button\n type=\"button\"\n class=\"deb-problems\"\n [class.deb-problems--error]=\"!store.isValid()\"\n [matTooltip]=\"store.problems().length + ' issue(s) \u2014 hover items in the list'\"\n >\n <mat-icon>{{ store.isValid() ? 'warning' : 'error' }}</mat-icon>\n {{ store.errors().length }} / {{ store.problems().length }}\n </button>\n }\n\n <button mat-button type=\"button\" (click)=\"copyJson()\">\n <mat-icon>content_copy</mat-icon> Copy JSON\n </button>\n <!-- primary, not accent: white on Material's default pink accent is 3.33:1, below AA. -->\n <button mat-flat-button color=\"primary\" type=\"button\" [disabled]=\"!store.isValid()\" (click)=\"doSave()\">\n <mat-icon>save</mat-icon> Save\n </button>\n</mat-toolbar>\n\n<div class=\"deb-body\">\n <!-- \u2500\u2500\u2500 Left: settings, palette, tabs \u2500\u2500\u2500 -->\n <div class=\"deb-col deb-col--left\">\n <mat-card>\n <mat-card-content>\n <mat-form-field appearance=\"outline\" subscriptSizing=\"dynamic\" class=\"deb-full\">\n <mat-label>Entity name</mat-label>\n <input\n matInput\n [ngModel]=\"store.config().entity\"\n (ngModelChange)=\"store.setEntityName($event)\"\n placeholder=\"e.g. clients\"\n />\n </mat-form-field>\n\n <div class=\"deb-row\">\n <mat-form-field appearance=\"outline\" subscriptSizing=\"dynamic\" class=\"deb-grow\">\n <mat-label>Editing language</mat-label>\n <mat-select\n [ngModel]=\"store.activeLanguage()\"\n (ngModelChange)=\"store.setActiveLanguage($event)\"\n >\n @for (l of languages; track l) {\n <mat-option [value]=\"l\">{{ l }}</mat-option>\n }\n </mat-select>\n </mat-form-field>\n </div>\n\n <mat-slide-toggle\n [ngModel]=\"store.config().maskData ?? false\"\n (ngModelChange)=\"store.setMaskData($event)\"\n >Mask this entity (RBAC)</mat-slide-toggle>\n\n <mat-expansion-panel class=\"deb-rbac\">\n <mat-expansion-panel-header>\n <mat-panel-title>Permissions</mat-panel-title>\n </mat-expansion-panel-header>\n @for (action of rbacActions; track action) {\n @if (availableRoles.length) {\n <mat-form-field appearance=\"outline\" subscriptSizing=\"dynamic\" class=\"deb-full\">\n <mat-label>{{ action }} roles</mat-label>\n <mat-select multiple [ngModel]=\"rolesFor(action)\" (ngModelChange)=\"setRoles(action, $event)\">\n @for (role of availableRoles; track role) {\n <mat-option [value]=\"role\">{{ role }}</mat-option>\n }\n </mat-select>\n </mat-form-field>\n } @else {\n <mat-form-field appearance=\"outline\" subscriptSizing=\"dynamic\" class=\"deb-full\">\n <mat-label>{{ action }} roles (comma-separated)</mat-label>\n <input\n matInput\n [ngModel]=\"rolesFor(action).join(', ')\"\n (ngModelChange)=\"setRolesFromText(action, $event)\"\n />\n </mat-form-field>\n }\n }\n <p class=\"deb-hint\">Empty = no restriction for that action.</p>\n </mat-expansion-panel>\n </mat-card-content>\n </mat-card>\n\n <mat-card>\n <mat-card-header><mat-card-title>Add field</mat-card-title></mat-card-header>\n <mat-card-content>\n <ngx-field-palette (pick)=\"store.addField($event)\"></ngx-field-palette>\n </mat-card-content>\n </mat-card>\n\n <mat-card>\n <mat-card-content>\n <ngx-tab-manager></ngx-tab-manager>\n </mat-card-content>\n </mat-card>\n </div>\n\n <!-- \u2500\u2500\u2500 Center: field canvas \u2500\u2500\u2500 -->\n <div class=\"deb-col deb-col--center\">\n <ngx-entity-builder-canvas />\n </div>\n\n <!-- \u2500\u2500\u2500 Right: inspector \u2500\u2500\u2500 -->\n <div class=\"deb-col deb-col--right\">\n <mat-card>\n <mat-card-header><mat-card-title>Field properties</mat-card-title></mat-card-header>\n <mat-card-content>\n <ngx-field-inspector></ngx-field-inspector>\n </mat-card-content>\n </mat-card>\n </div>\n</div>\n\n<!-- \u2500\u2500\u2500 Footer: JSON + optional live preview \u2500\u2500\u2500 -->\n<div class=\"deb-footer\">\n <mat-expansion-panel>\n <mat-expansion-panel-header>\n <mat-panel-title><mat-icon>data_object</mat-icon> Config JSON</mat-panel-title>\n </mat-expansion-panel-header>\n <pre class=\"deb-json\">{{ json }}</pre>\n </mat-expansion-panel>\n\n <div class=\"deb-preview-slot\">\n <ng-content select=\"[ngxBuilderPreview]\"></ng-content>\n </div>\n</div>\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:#fff}.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;max-height:calc(100vh - 40px);overflow:auto}@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}.deb-path-hint{color:#0000008c;font-size:11px;margin-left:6px}\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[matButton], a[matButton], button[mat-button], button[mat-raised-button], button[mat-flat-button], button[mat-stroked-button], a[mat-button], a[mat-raised-button], a[mat-flat-button], a[mat-stroked-button] ", inputs: ["matButton"], exportAs: ["matButton", "matAnchor"] }, { kind: "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", "disabledInteractive"], exportAs: ["matInput"] }, { kind: "ngmodule", type: MatSelectModule }, { kind: "component", type: i8.MatSelect, selector: "mat-select", inputs: ["aria-describedby", "panelClass", "disabled", "disableRipple", "tabIndex", "hideSingleSelectionIndicator", "placeholder", "required", "multiple", "disableOptionCentering", "compareWith", "value", "aria-label", "aria-labelledby", "errorStateMatcher", "typeaheadDebounceInterval", "sortComparator", "id", "panelWidth", "canSelectNullableOptions"], outputs: ["openedChange", "opened", "closed", "selectionChange", "valueChange"], exportAs: ["matSelect"] }, { kind: "component", type: i8.MatOption, selector: "mat-option", inputs: ["value", "id", "disabled"], outputs: ["onSelectionChange"], exportAs: ["matOption"] }, { kind: "ngmodule", type: MatSlideToggleModule }, { kind: "component", type: i9.MatSlideToggle, selector: "mat-slide-toggle", inputs: ["name", "id", "labelPosition", "aria-label", "aria-labelledby", "aria-describedby", "required", "color", "disabled", "disableRipple", "tabIndex", "checked", "hideIcon", "disabledInteractive"], outputs: ["change", "toggleChange"], exportAs: ["matSlideToggle"] }, { kind: "ngmodule", type: MatToolbarModule }, { kind: "component", type: i10.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" }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
|
|
3604
|
+
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.22", type: EntityBuilderComponent, isStandalone: true, selector: "ngx-entity-builder", inputs: { config: "config", languages: "languages", availableRoles: "availableRoles", userRoles: "userRoles", commonModules: "commonModules" }, outputs: { configChange: "configChange", save: "save" }, host: { listeners: { "document:keydown": "onKeydown($event)" } }, providers: [BuilderStore], usesOnChanges: true, ngImport: i0, template: "<mat-toolbar class=\"deb-toolbar\" color=\"primary\">\n <mat-icon>dashboard_customize</mat-icon>\n <span class=\"deb-toolbar__title\">Entity Builder</span>\n <span class=\"deb-spacer\"></span>\n\n @if (store.problems().length) {\n <button\n mat-button\n type=\"button\"\n class=\"deb-problems\"\n [class.deb-problems--error]=\"!store.isValid()\"\n [matTooltip]=\"store.problems().length + ' issue(s) \u2014 hover items in the list'\"\n >\n <mat-icon>{{ store.isValid() ? 'warning' : 'error' }}</mat-icon>\n {{ store.errors().length }} / {{ store.problems().length }}\n </button>\n }\n\n <button\n mat-icon-button\n type=\"button\"\n data-testid=\"builder-undo\"\n [disabled]=\"!store.canUndo()\"\n (click)=\"store.undo()\"\n matTooltip=\"Undo (Ctrl+Z)\"\n aria-label=\"Undo\"\n >\n <mat-icon>undo</mat-icon>\n </button>\n <button\n mat-icon-button\n type=\"button\"\n data-testid=\"builder-redo\"\n [disabled]=\"!store.canRedo()\"\n (click)=\"store.redo()\"\n matTooltip=\"Redo (Ctrl+Shift+Z)\"\n aria-label=\"Redo\"\n >\n <mat-icon>redo</mat-icon>\n </button>\n\n <button mat-button type=\"button\" (click)=\"copyJson()\">\n <mat-icon>content_copy</mat-icon> Copy JSON\n </button>\n <!-- primary, not accent: white on Material's default pink accent is 3.33:1, below AA. -->\n <button mat-flat-button color=\"primary\" type=\"button\" [disabled]=\"!store.isValid()\" (click)=\"doSave()\">\n <mat-icon>save</mat-icon> Save\n </button>\n</mat-toolbar>\n\n<div class=\"deb-body\">\n <!-- \u2500\u2500\u2500 Left: settings, palette, tabs \u2500\u2500\u2500 -->\n <div class=\"deb-col deb-col--left\">\n <mat-card>\n <mat-card-content>\n <mat-form-field appearance=\"outline\" subscriptSizing=\"dynamic\" class=\"deb-full\">\n <mat-label>Entity name</mat-label>\n <input\n matInput\n [ngModel]=\"store.config().entity\"\n (ngModelChange)=\"store.setEntityName($event)\"\n placeholder=\"e.g. clients\"\n />\n </mat-form-field>\n\n <div class=\"deb-row\">\n <mat-form-field appearance=\"outline\" subscriptSizing=\"dynamic\" class=\"deb-grow\">\n <mat-label>Editing language</mat-label>\n <mat-select\n [ngModel]=\"store.activeLanguage()\"\n (ngModelChange)=\"store.setActiveLanguage($event)\"\n >\n @for (l of languages; track l) {\n <mat-option [value]=\"l\">{{ l }}</mat-option>\n }\n </mat-select>\n </mat-form-field>\n </div>\n\n <mat-slide-toggle\n [ngModel]=\"store.config().maskData ?? false\"\n (ngModelChange)=\"store.setMaskData($event)\"\n >Mask this entity (RBAC)</mat-slide-toggle>\n\n <mat-expansion-panel class=\"deb-rbac\">\n <mat-expansion-panel-header>\n <mat-panel-title>Permissions</mat-panel-title>\n </mat-expansion-panel-header>\n @for (action of rbacActions; track action) {\n @if (availableRoles.length) {\n <mat-form-field appearance=\"outline\" subscriptSizing=\"dynamic\" class=\"deb-full\">\n <mat-label>{{ action }} roles</mat-label>\n <mat-select multiple [ngModel]=\"rolesFor(action)\" (ngModelChange)=\"setRoles(action, $event)\">\n @for (role of availableRoles; track role) {\n <mat-option [value]=\"role\">{{ role }}</mat-option>\n }\n </mat-select>\n </mat-form-field>\n } @else {\n <mat-form-field appearance=\"outline\" subscriptSizing=\"dynamic\" class=\"deb-full\">\n <mat-label>{{ action }} roles (comma-separated)</mat-label>\n <input\n matInput\n [ngModel]=\"rolesFor(action).join(', ')\"\n (ngModelChange)=\"setRolesFromText(action, $event)\"\n />\n </mat-form-field>\n }\n }\n <p class=\"deb-hint\">Empty = no restriction for that action.</p>\n </mat-expansion-panel>\n </mat-card-content>\n </mat-card>\n\n <mat-card>\n <mat-card-header><mat-card-title>Add field</mat-card-title></mat-card-header>\n <mat-card-content>\n <ngx-field-palette (pick)=\"store.addField($event)\"></ngx-field-palette>\n </mat-card-content>\n </mat-card>\n\n <mat-card>\n <mat-card-content>\n <ngx-tab-manager></ngx-tab-manager>\n </mat-card-content>\n </mat-card>\n </div>\n\n <!-- \u2500\u2500\u2500 Center: field canvas \u2500\u2500\u2500 -->\n <div class=\"deb-col deb-col--center\">\n <ngx-entity-builder-canvas />\n </div>\n\n <!-- \u2500\u2500\u2500 Right: inspector \u2500\u2500\u2500 -->\n <div class=\"deb-col deb-col--right\">\n <mat-card>\n <mat-card-header><mat-card-title>Field properties</mat-card-title></mat-card-header>\n <mat-card-content>\n <ngx-field-inspector></ngx-field-inspector>\n </mat-card-content>\n </mat-card>\n </div>\n</div>\n\n<!-- \u2500\u2500\u2500 Footer: JSON + optional live preview \u2500\u2500\u2500 -->\n<div class=\"deb-footer\">\n <mat-expansion-panel>\n <mat-expansion-panel-header>\n <mat-panel-title><mat-icon>data_object</mat-icon> Config JSON</mat-panel-title>\n </mat-expansion-panel-header>\n <pre class=\"deb-json\">{{ json }}</pre>\n </mat-expansion-panel>\n\n <div class=\"deb-preview-slot\">\n <ng-content select=\"[ngxBuilderPreview]\"></ng-content>\n </div>\n</div>\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:#fff}.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;max-height:calc(100vh - 40px);overflow:auto}@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}.deb-path-hint{color:#0000008c;font-size:11px;margin-left:6px}\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[matButton], a[matButton], button[mat-button], button[mat-raised-button], button[mat-flat-button], button[mat-stroked-button], a[mat-button], a[mat-raised-button], a[mat-flat-button], a[mat-stroked-button] ", inputs: ["matButton"], exportAs: ["matButton", "matAnchor"] }, { kind: "component", type: i2.MatIconButton, selector: "button[mat-icon-button], a[mat-icon-button], button[matIconButton], a[matIconButton]", exportAs: ["matButton", "matAnchor"] }, { kind: "ngmodule", type: 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", "disabledInteractive"], exportAs: ["matInput"] }, { kind: "ngmodule", type: MatSelectModule }, { kind: "component", type: i8.MatSelect, selector: "mat-select", inputs: ["aria-describedby", "panelClass", "disabled", "disableRipple", "tabIndex", "hideSingleSelectionIndicator", "placeholder", "required", "multiple", "disableOptionCentering", "compareWith", "value", "aria-label", "aria-labelledby", "errorStateMatcher", "typeaheadDebounceInterval", "sortComparator", "id", "panelWidth", "canSelectNullableOptions"], outputs: ["openedChange", "opened", "closed", "selectionChange", "valueChange"], exportAs: ["matSelect"] }, { kind: "component", type: i8.MatOption, selector: "mat-option", inputs: ["value", "id", "disabled"], outputs: ["onSelectionChange"], exportAs: ["matOption"] }, { kind: "ngmodule", type: MatSlideToggleModule }, { kind: "component", type: i9.MatSlideToggle, selector: "mat-slide-toggle", inputs: ["name", "id", "labelPosition", "aria-label", "aria-labelledby", "aria-describedby", "required", "color", "disabled", "disableRipple", "tabIndex", "checked", "hideIcon", "disabledInteractive"], outputs: ["change", "toggleChange"], exportAs: ["matSlideToggle"] }, { kind: "ngmodule", type: MatToolbarModule }, { kind: "component", type: i10.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" }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
|
|
3343
3605
|
}
|
|
3344
3606
|
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.22", ngImport: i0, type: EntityBuilderComponent, decorators: [{
|
|
3345
3607
|
type: Component,
|
|
@@ -3360,7 +3622,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.22", ngImpo
|
|
|
3360
3622
|
TabManagerComponent,
|
|
3361
3623
|
FieldInspectorComponent,
|
|
3362
3624
|
EntityBuilderCanvasComponent,
|
|
3363
|
-
], template: "<mat-toolbar class=\"deb-toolbar\" color=\"primary\">\n <mat-icon>dashboard_customize</mat-icon>\n <span class=\"deb-toolbar__title\">Entity Builder</span>\n <span class=\"deb-spacer\"></span>\n\n @if (store.problems().length) {\n <button\n mat-button\n type=\"button\"\n class=\"deb-problems\"\n [class.deb-problems--error]=\"!store.isValid()\"\n [matTooltip]=\"store.problems().length + ' issue(s) \u2014 hover items in the list'\"\n >\n <mat-icon>{{ store.isValid() ? 'warning' : 'error' }}</mat-icon>\n {{ store.errors().length }} / {{ store.problems().length }}\n </button>\n }\n\n <button mat-button type=\"button\" (click)=\"copyJson()\">\n <mat-icon>content_copy</mat-icon> Copy JSON\n </button>\n <!-- primary, not accent: white on Material's default pink accent is 3.33:1, below AA. -->\n <button mat-flat-button color=\"primary\" type=\"button\" [disabled]=\"!store.isValid()\" (click)=\"doSave()\">\n <mat-icon>save</mat-icon> Save\n </button>\n</mat-toolbar>\n\n<div class=\"deb-body\">\n <!-- \u2500\u2500\u2500 Left: settings, palette, tabs \u2500\u2500\u2500 -->\n <div class=\"deb-col deb-col--left\">\n <mat-card>\n <mat-card-content>\n <mat-form-field appearance=\"outline\" subscriptSizing=\"dynamic\" class=\"deb-full\">\n <mat-label>Entity name</mat-label>\n <input\n matInput\n [ngModel]=\"store.config().entity\"\n (ngModelChange)=\"store.setEntityName($event)\"\n placeholder=\"e.g. clients\"\n />\n </mat-form-field>\n\n <div class=\"deb-row\">\n <mat-form-field appearance=\"outline\" subscriptSizing=\"dynamic\" class=\"deb-grow\">\n <mat-label>Editing language</mat-label>\n <mat-select\n [ngModel]=\"store.activeLanguage()\"\n (ngModelChange)=\"store.setActiveLanguage($event)\"\n >\n @for (l of languages; track l) {\n <mat-option [value]=\"l\">{{ l }}</mat-option>\n }\n </mat-select>\n </mat-form-field>\n </div>\n\n <mat-slide-toggle\n [ngModel]=\"store.config().maskData ?? false\"\n (ngModelChange)=\"store.setMaskData($event)\"\n >Mask this entity (RBAC)</mat-slide-toggle>\n\n <mat-expansion-panel class=\"deb-rbac\">\n <mat-expansion-panel-header>\n <mat-panel-title>Permissions</mat-panel-title>\n </mat-expansion-panel-header>\n @for (action of rbacActions; track action) {\n @if (availableRoles.length) {\n <mat-form-field appearance=\"outline\" subscriptSizing=\"dynamic\" class=\"deb-full\">\n <mat-label>{{ action }} roles</mat-label>\n <mat-select multiple [ngModel]=\"rolesFor(action)\" (ngModelChange)=\"setRoles(action, $event)\">\n @for (role of availableRoles; track role) {\n <mat-option [value]=\"role\">{{ role }}</mat-option>\n }\n </mat-select>\n </mat-form-field>\n } @else {\n <mat-form-field appearance=\"outline\" subscriptSizing=\"dynamic\" class=\"deb-full\">\n <mat-label>{{ action }} roles (comma-separated)</mat-label>\n <input\n matInput\n [ngModel]=\"rolesFor(action).join(', ')\"\n (ngModelChange)=\"setRolesFromText(action, $event)\"\n />\n </mat-form-field>\n }\n }\n <p class=\"deb-hint\">Empty = no restriction for that action.</p>\n </mat-expansion-panel>\n </mat-card-content>\n </mat-card>\n\n <mat-card>\n <mat-card-header><mat-card-title>Add field</mat-card-title></mat-card-header>\n <mat-card-content>\n <ngx-field-palette (pick)=\"store.addField($event)\"></ngx-field-palette>\n </mat-card-content>\n </mat-card>\n\n <mat-card>\n <mat-card-content>\n <ngx-tab-manager></ngx-tab-manager>\n </mat-card-content>\n </mat-card>\n </div>\n\n <!-- \u2500\u2500\u2500 Center: field canvas \u2500\u2500\u2500 -->\n <div class=\"deb-col deb-col--center\">\n <ngx-entity-builder-canvas />\n </div>\n\n <!-- \u2500\u2500\u2500 Right: inspector \u2500\u2500\u2500 -->\n <div class=\"deb-col deb-col--right\">\n <mat-card>\n <mat-card-header><mat-card-title>Field properties</mat-card-title></mat-card-header>\n <mat-card-content>\n <ngx-field-inspector></ngx-field-inspector>\n </mat-card-content>\n </mat-card>\n </div>\n</div>\n\n<!-- \u2500\u2500\u2500 Footer: JSON + optional live preview \u2500\u2500\u2500 -->\n<div class=\"deb-footer\">\n <mat-expansion-panel>\n <mat-expansion-panel-header>\n <mat-panel-title><mat-icon>data_object</mat-icon> Config JSON</mat-panel-title>\n </mat-expansion-panel-header>\n <pre class=\"deb-json\">{{ json }}</pre>\n </mat-expansion-panel>\n\n <div class=\"deb-preview-slot\">\n <ng-content select=\"[ngxBuilderPreview]\"></ng-content>\n </div>\n</div>\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:#fff}.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;max-height:calc(100vh - 40px);overflow:auto}@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}.deb-path-hint{color:#0000008c;font-size:11px;margin-left:6px}\n"] }]
|
|
3625
|
+
], template: "<mat-toolbar class=\"deb-toolbar\" color=\"primary\">\n <mat-icon>dashboard_customize</mat-icon>\n <span class=\"deb-toolbar__title\">Entity Builder</span>\n <span class=\"deb-spacer\"></span>\n\n @if (store.problems().length) {\n <button\n mat-button\n type=\"button\"\n class=\"deb-problems\"\n [class.deb-problems--error]=\"!store.isValid()\"\n [matTooltip]=\"store.problems().length + ' issue(s) \u2014 hover items in the list'\"\n >\n <mat-icon>{{ store.isValid() ? 'warning' : 'error' }}</mat-icon>\n {{ store.errors().length }} / {{ store.problems().length }}\n </button>\n }\n\n <button\n mat-icon-button\n type=\"button\"\n data-testid=\"builder-undo\"\n [disabled]=\"!store.canUndo()\"\n (click)=\"store.undo()\"\n matTooltip=\"Undo (Ctrl+Z)\"\n aria-label=\"Undo\"\n >\n <mat-icon>undo</mat-icon>\n </button>\n <button\n mat-icon-button\n type=\"button\"\n data-testid=\"builder-redo\"\n [disabled]=\"!store.canRedo()\"\n (click)=\"store.redo()\"\n matTooltip=\"Redo (Ctrl+Shift+Z)\"\n aria-label=\"Redo\"\n >\n <mat-icon>redo</mat-icon>\n </button>\n\n <button mat-button type=\"button\" (click)=\"copyJson()\">\n <mat-icon>content_copy</mat-icon> Copy JSON\n </button>\n <!-- primary, not accent: white on Material's default pink accent is 3.33:1, below AA. -->\n <button mat-flat-button color=\"primary\" type=\"button\" [disabled]=\"!store.isValid()\" (click)=\"doSave()\">\n <mat-icon>save</mat-icon> Save\n </button>\n</mat-toolbar>\n\n<div class=\"deb-body\">\n <!-- \u2500\u2500\u2500 Left: settings, palette, tabs \u2500\u2500\u2500 -->\n <div class=\"deb-col deb-col--left\">\n <mat-card>\n <mat-card-content>\n <mat-form-field appearance=\"outline\" subscriptSizing=\"dynamic\" class=\"deb-full\">\n <mat-label>Entity name</mat-label>\n <input\n matInput\n [ngModel]=\"store.config().entity\"\n (ngModelChange)=\"store.setEntityName($event)\"\n placeholder=\"e.g. clients\"\n />\n </mat-form-field>\n\n <div class=\"deb-row\">\n <mat-form-field appearance=\"outline\" subscriptSizing=\"dynamic\" class=\"deb-grow\">\n <mat-label>Editing language</mat-label>\n <mat-select\n [ngModel]=\"store.activeLanguage()\"\n (ngModelChange)=\"store.setActiveLanguage($event)\"\n >\n @for (l of languages; track l) {\n <mat-option [value]=\"l\">{{ l }}</mat-option>\n }\n </mat-select>\n </mat-form-field>\n </div>\n\n <mat-slide-toggle\n [ngModel]=\"store.config().maskData ?? false\"\n (ngModelChange)=\"store.setMaskData($event)\"\n >Mask this entity (RBAC)</mat-slide-toggle>\n\n <mat-expansion-panel class=\"deb-rbac\">\n <mat-expansion-panel-header>\n <mat-panel-title>Permissions</mat-panel-title>\n </mat-expansion-panel-header>\n @for (action of rbacActions; track action) {\n @if (availableRoles.length) {\n <mat-form-field appearance=\"outline\" subscriptSizing=\"dynamic\" class=\"deb-full\">\n <mat-label>{{ action }} roles</mat-label>\n <mat-select multiple [ngModel]=\"rolesFor(action)\" (ngModelChange)=\"setRoles(action, $event)\">\n @for (role of availableRoles; track role) {\n <mat-option [value]=\"role\">{{ role }}</mat-option>\n }\n </mat-select>\n </mat-form-field>\n } @else {\n <mat-form-field appearance=\"outline\" subscriptSizing=\"dynamic\" class=\"deb-full\">\n <mat-label>{{ action }} roles (comma-separated)</mat-label>\n <input\n matInput\n [ngModel]=\"rolesFor(action).join(', ')\"\n (ngModelChange)=\"setRolesFromText(action, $event)\"\n />\n </mat-form-field>\n }\n }\n <p class=\"deb-hint\">Empty = no restriction for that action.</p>\n </mat-expansion-panel>\n </mat-card-content>\n </mat-card>\n\n <mat-card>\n <mat-card-header><mat-card-title>Add field</mat-card-title></mat-card-header>\n <mat-card-content>\n <ngx-field-palette (pick)=\"store.addField($event)\"></ngx-field-palette>\n </mat-card-content>\n </mat-card>\n\n <mat-card>\n <mat-card-content>\n <ngx-tab-manager></ngx-tab-manager>\n </mat-card-content>\n </mat-card>\n </div>\n\n <!-- \u2500\u2500\u2500 Center: field canvas \u2500\u2500\u2500 -->\n <div class=\"deb-col deb-col--center\">\n <ngx-entity-builder-canvas />\n </div>\n\n <!-- \u2500\u2500\u2500 Right: inspector \u2500\u2500\u2500 -->\n <div class=\"deb-col deb-col--right\">\n <mat-card>\n <mat-card-header><mat-card-title>Field properties</mat-card-title></mat-card-header>\n <mat-card-content>\n <ngx-field-inspector></ngx-field-inspector>\n </mat-card-content>\n </mat-card>\n </div>\n</div>\n\n<!-- \u2500\u2500\u2500 Footer: JSON + optional live preview \u2500\u2500\u2500 -->\n<div class=\"deb-footer\">\n <mat-expansion-panel>\n <mat-expansion-panel-header>\n <mat-panel-title><mat-icon>data_object</mat-icon> Config JSON</mat-panel-title>\n </mat-expansion-panel-header>\n <pre class=\"deb-json\">{{ json }}</pre>\n </mat-expansion-panel>\n\n <div class=\"deb-preview-slot\">\n <ng-content select=\"[ngxBuilderPreview]\"></ng-content>\n </div>\n</div>\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:#fff}.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;max-height:calc(100vh - 40px);overflow:auto}@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}.deb-path-hint{color:#0000008c;font-size:11px;margin-left:6px}\n"] }]
|
|
3364
3626
|
}], ctorParameters: () => [], propDecorators: { config: [{
|
|
3365
3627
|
type: Input
|
|
3366
3628
|
}], languages: [{
|
|
@@ -3375,6 +3637,9 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.22", ngImpo
|
|
|
3375
3637
|
type: Output
|
|
3376
3638
|
}], save: [{
|
|
3377
3639
|
type: Output
|
|
3640
|
+
}], onKeydown: [{
|
|
3641
|
+
type: HostListener,
|
|
3642
|
+
args: ['document:keydown', ['$event']]
|
|
3378
3643
|
}] } });
|
|
3379
3644
|
|
|
3380
3645
|
// Main component
|