ng-form-foundry 0.2.1 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +9 -6
- package/fesm2022/ng-form-foundry.mjs +1090 -291
- package/fesm2022/ng-form-foundry.mjs.map +1 -1
- package/index.d.ts +501 -89
- package/package.json +1 -1
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import * as i1 from '@angular/forms';
|
|
2
2
|
import { Validators, FormControl, FormArray, FormGroup, ReactiveFormsModule } from '@angular/forms';
|
|
3
3
|
import * as i0 from '@angular/core';
|
|
4
|
-
import { Input, Component,
|
|
4
|
+
import { Input, Component, output, EventEmitter, inject, ChangeDetectorRef, forwardRef, ViewChildren, Output, HostBinding, input, model, computed, ElementRef, effect, untracked } from '@angular/core';
|
|
5
5
|
import * as i2 from '@angular/material/form-field';
|
|
6
6
|
import { MatFormFieldModule } from '@angular/material/form-field';
|
|
7
|
-
import * as
|
|
7
|
+
import * as i5 from '@angular/material/input';
|
|
8
8
|
import { MatInputModule, MatPrefix } from '@angular/material/input';
|
|
9
9
|
import * as i4 from '@angular/material/checkbox';
|
|
10
10
|
import { MatCheckboxModule } from '@angular/material/checkbox';
|
|
@@ -12,17 +12,21 @@ import * as i3 from '@angular/material/select';
|
|
|
12
12
|
import { MatSelectModule } from '@angular/material/select';
|
|
13
13
|
import * as i1$1 from '@angular/material/icon';
|
|
14
14
|
import { MatIconModule } from '@angular/material/icon';
|
|
15
|
-
import * as
|
|
15
|
+
import * as i2$1 from '@angular/material/button';
|
|
16
16
|
import { MatButtonModule } from '@angular/material/button';
|
|
17
|
-
import {
|
|
17
|
+
import { MatTooltip, MatTooltipModule } from '@angular/material/tooltip';
|
|
18
18
|
import { MatDialog } from '@angular/material/dialog';
|
|
19
|
-
import * as i2$
|
|
19
|
+
import * as i2$2 from '@angular/material/expansion';
|
|
20
20
|
import { MatExpansionModule } from '@angular/material/expansion';
|
|
21
|
-
import * as i5 from '@angular/material/card';
|
|
22
|
-
import { MatCardModule } from '@angular/material/card';
|
|
23
21
|
import { NgTemplateOutlet } from '@angular/common';
|
|
22
|
+
import * as i3$1 from '@angular/material/menu';
|
|
23
|
+
import { MatMenuModule } from '@angular/material/menu';
|
|
24
24
|
|
|
25
|
-
/**
|
|
25
|
+
/**
|
|
26
|
+
* The control name that records which case of a {@link NodeChoice} is active.
|
|
27
|
+
* The name is reserved: it cannot be used as a case field name (the builder
|
|
28
|
+
* throws) or as a map entry key (the entry helpers reject it).
|
|
29
|
+
*/
|
|
26
30
|
const CASE_KEY = '__case';
|
|
27
31
|
|
|
28
32
|
// --- type guards
|
|
@@ -41,10 +45,88 @@ function isNodeGroupList(node) {
|
|
|
41
45
|
function isChoice(node) {
|
|
42
46
|
return node.kind === 'choice';
|
|
43
47
|
}
|
|
48
|
+
function isMap(node) {
|
|
49
|
+
return node.kind === 'map';
|
|
50
|
+
}
|
|
51
|
+
/** Whether the node is an optional (presence) node: its key is data, absent until enabled. */
|
|
52
|
+
function hasPresence(node) {
|
|
53
|
+
return ((node.kind === 'leaf' || node.kind === 'nodeGroup' || node.kind === 'map' || node.kind === 'choice') &&
|
|
54
|
+
node.presence === true);
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Whether a presence child should start absent: there is no initial data
|
|
58
|
+
* object, or its key is missing from it. A key that is present with an explicit
|
|
59
|
+
* `null` value keeps its control — `null` is a value (a nullable leaf's), while
|
|
60
|
+
* presence is about the *absent key*.
|
|
61
|
+
*/
|
|
62
|
+
function presenceAbsent(initial, key) {
|
|
63
|
+
return initial == null || !(key in initial);
|
|
64
|
+
}
|
|
44
65
|
function enumValidator(choices) {
|
|
45
66
|
const set = new Set(choices);
|
|
46
67
|
return (ctrl) => ctrl.value == null || set.has(ctrl.value) ? null : { enum: true };
|
|
47
68
|
}
|
|
69
|
+
/**
|
|
70
|
+
* JSON Schema `pattern`: an *unanchored* `RegExp.test`, unlike Angular's built-in
|
|
71
|
+
* `Validators.pattern` which anchors the expression. An invalid regex disables
|
|
72
|
+
* the check rather than throwing. Empty/absent values pass (use `required`).
|
|
73
|
+
*/
|
|
74
|
+
function patternValidator(pattern) {
|
|
75
|
+
let re;
|
|
76
|
+
try {
|
|
77
|
+
re = new RegExp(pattern);
|
|
78
|
+
}
|
|
79
|
+
catch {
|
|
80
|
+
return () => null;
|
|
81
|
+
}
|
|
82
|
+
return (ctrl) => {
|
|
83
|
+
const v = ctrl.value;
|
|
84
|
+
if (v == null || v === '')
|
|
85
|
+
return null;
|
|
86
|
+
return re.test(String(v)) ? null : { pattern: { requiredPattern: pattern, actualValue: v } };
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
/** JSON Schema `type: 'integer'`: reject a value that is not a whole number. */
|
|
90
|
+
function integerValidator() {
|
|
91
|
+
return (ctrl) => {
|
|
92
|
+
const v = ctrl.value;
|
|
93
|
+
if (v == null || v === '')
|
|
94
|
+
return null;
|
|
95
|
+
const num = typeof v === 'number' ? v : Number(v);
|
|
96
|
+
return Number.isInteger(num) ? null : { integer: true };
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
/** JSON Schema `multipleOf`: reject a value that is not an integer multiple of `step`. */
|
|
100
|
+
function multipleOfValidator(step) {
|
|
101
|
+
return (ctrl) => {
|
|
102
|
+
const v = ctrl.value;
|
|
103
|
+
if (v == null || v === '')
|
|
104
|
+
return null;
|
|
105
|
+
const num = typeof v === 'number' ? v : Number(v);
|
|
106
|
+
if (Number.isNaN(num))
|
|
107
|
+
return null;
|
|
108
|
+
const ratio = num / step;
|
|
109
|
+
// A small tolerance absorbs binary float drift (e.g. 0.3 / 0.1).
|
|
110
|
+
return Math.abs(ratio - Math.round(ratio)) < 1e-9
|
|
111
|
+
? null
|
|
112
|
+
: { multipleOf: { multipleOf: step, actual: num } };
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
/** JSON Schema `format: uri`: reject a string that is not a parseable absolute URI. */
|
|
116
|
+
function uriValidator() {
|
|
117
|
+
return (ctrl) => {
|
|
118
|
+
const v = ctrl.value;
|
|
119
|
+
if (v == null || v === '')
|
|
120
|
+
return null;
|
|
121
|
+
try {
|
|
122
|
+
new URL(String(v));
|
|
123
|
+
return null;
|
|
124
|
+
}
|
|
125
|
+
catch {
|
|
126
|
+
return { uri: true };
|
|
127
|
+
}
|
|
128
|
+
};
|
|
129
|
+
}
|
|
48
130
|
function buildLeafControl(leaf, initial) {
|
|
49
131
|
const validators = [];
|
|
50
132
|
if ('required' in leaf && leaf.required)
|
|
@@ -53,9 +135,38 @@ function buildLeafControl(leaf, initial) {
|
|
|
53
135
|
const choices = leaf.enum;
|
|
54
136
|
validators.push(enumValidator(choices));
|
|
55
137
|
}
|
|
138
|
+
if (leaf.type === 'string') {
|
|
139
|
+
const s = leaf;
|
|
140
|
+
if (s.pattern != null)
|
|
141
|
+
validators.push(patternValidator(s.pattern));
|
|
142
|
+
if (s.minLength != null)
|
|
143
|
+
validators.push(Validators.minLength(s.minLength));
|
|
144
|
+
if (s.maxLength != null)
|
|
145
|
+
validators.push(Validators.maxLength(s.maxLength));
|
|
146
|
+
if (s.format === 'email')
|
|
147
|
+
validators.push(Validators.email);
|
|
148
|
+
else if (s.format === 'uri' || s.format === 'url')
|
|
149
|
+
validators.push(uriValidator());
|
|
150
|
+
}
|
|
151
|
+
else if (leaf.type === 'number') {
|
|
152
|
+
const n = leaf;
|
|
153
|
+
if (n.integer)
|
|
154
|
+
validators.push(integerValidator());
|
|
155
|
+
if (n.min != null)
|
|
156
|
+
validators.push(Validators.min(n.min));
|
|
157
|
+
if (n.max != null)
|
|
158
|
+
validators.push(Validators.max(n.max));
|
|
159
|
+
if (n.multipleOf != null)
|
|
160
|
+
validators.push(multipleOfValidator(n.multipleOf));
|
|
161
|
+
}
|
|
56
162
|
const defaultValue = initial ?? ('default' in leaf ? leaf.default : undefined) ?? null;
|
|
163
|
+
// A nullable leaf drops `nonNullable`, so `null` is a first-class value that
|
|
164
|
+
// `reset()` restores and that survives the round-trip (JSON Schema `null`).
|
|
165
|
+
// The typed model still treats a leaf value as non-null, so the runtime
|
|
166
|
+
// nullable control is cast back to the declared type.
|
|
167
|
+
const nullable = 'nullable' in leaf && leaf.nullable === true;
|
|
57
168
|
return new FormControl(defaultValue, {
|
|
58
|
-
nonNullable:
|
|
169
|
+
nonNullable: !nullable,
|
|
59
170
|
validators,
|
|
60
171
|
});
|
|
61
172
|
}
|
|
@@ -68,6 +179,12 @@ function buildNodeGroupControl(group, initial) {
|
|
|
68
179
|
const controls = {};
|
|
69
180
|
for (const key in group.children) {
|
|
70
181
|
const child = group.children[key];
|
|
182
|
+
// An absent presence child gets no control at all, so it is absent from the
|
|
183
|
+
// form value until enabled. Because every nested group is built through this
|
|
184
|
+
// function — plain children, list items, map values, choice case fields —
|
|
185
|
+
// presence is honored at any depth.
|
|
186
|
+
if (hasPresence(child) && presenceAbsent(initial, key))
|
|
187
|
+
continue;
|
|
71
188
|
// Forward only this child's slice of the initial data, keyed by the child's
|
|
72
189
|
// record key. Passing the whole `initial` object seeds every leaf with the
|
|
73
190
|
// parent record and prevents list builders from sizing to the real data.
|
|
@@ -82,33 +199,230 @@ function buildNodeGroupListControl(list, initial = null) {
|
|
|
82
199
|
return new FormArray(values.map((v) => buildNodeGroupControl(list.type, v)));
|
|
83
200
|
}
|
|
84
201
|
/**
|
|
85
|
-
*
|
|
202
|
+
* Normalize a {@link ChoiceCase} to a field record. A field record is returned
|
|
203
|
+
* as-is; a single node (a leaf-bodied case, e.g. a scalar `anyOf` branch) becomes
|
|
204
|
+
* a one-field record keyed by the node's `name`. The discriminant is a top-level
|
|
205
|
+
* `kind` string, which a field record never has (its keys are field names).
|
|
86
206
|
*
|
|
87
|
-
*
|
|
88
|
-
*
|
|
89
|
-
*
|
|
90
|
-
* this node — a scalar for a leaf, an array for a list, an object for a group —
|
|
91
|
-
* and seeds the control's value (falling back to the node's `default`).
|
|
92
|
-
*
|
|
93
|
-
* Most callers use {@link buildFormFromSchema}; this is exposed for building a
|
|
94
|
-
* control from a single non-root node.
|
|
207
|
+
* Throws when a case field is keyed `__case`: that name is reserved for the
|
|
208
|
+
* choice discriminator ({@link CASE_KEY}) and a field under it would silently
|
|
209
|
+
* clobber the active-case control.
|
|
95
210
|
*/
|
|
211
|
+
function caseFields(body) {
|
|
212
|
+
const fields = typeof body.kind === 'string'
|
|
213
|
+
? { [body.name]: body }
|
|
214
|
+
: body;
|
|
215
|
+
if (CASE_KEY in fields) {
|
|
216
|
+
throw new Error(`"${CASE_KEY}" is reserved for the choice discriminator and cannot name a case field`);
|
|
217
|
+
}
|
|
218
|
+
return fields;
|
|
219
|
+
}
|
|
220
|
+
/**
|
|
221
|
+
* The active case of a choice: an explicit `__case` in the initial value, else
|
|
222
|
+
* the case whose fields best match the initial data (inline wire data carries no
|
|
223
|
+
* `__case`), else the schema `default`. This lets a choice seed from real
|
|
224
|
+
* instance data whose branch is discriminated by which fields are present.
|
|
225
|
+
*/
|
|
226
|
+
function resolveChoiceCase(choice, initial) {
|
|
227
|
+
const explicit = initial?.[CASE_KEY];
|
|
228
|
+
if (typeof explicit === 'string')
|
|
229
|
+
return explicit;
|
|
230
|
+
return inferChoiceCase(choice, initial) ?? choice.default;
|
|
231
|
+
}
|
|
232
|
+
/** Pick the case whose fields most overlap the initial data (none if nothing matches). */
|
|
233
|
+
function inferChoiceCase(choice, initial) {
|
|
234
|
+
if (!initial)
|
|
235
|
+
return undefined;
|
|
236
|
+
let best;
|
|
237
|
+
let bestScore = 0;
|
|
238
|
+
for (const name of Object.keys(choice.cases)) {
|
|
239
|
+
const fields = Object.keys(caseFields(choice.cases[name]));
|
|
240
|
+
let score = 0;
|
|
241
|
+
for (const field of fields)
|
|
242
|
+
if (field in initial)
|
|
243
|
+
score++;
|
|
244
|
+
if (score > bestScore) {
|
|
245
|
+
bestScore = score;
|
|
246
|
+
best = name;
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
return best;
|
|
250
|
+
}
|
|
96
251
|
/**
|
|
97
252
|
* Build the FormGroup for a choice: a `__case` control holding the active case
|
|
98
253
|
* name plus that case's field controls. Only the active case's fields are built,
|
|
99
254
|
* matching the inline YANG encoding; switching the case swaps them.
|
|
100
255
|
*/
|
|
101
256
|
function buildChoiceControl(choice, initial) {
|
|
102
|
-
const active = initial
|
|
257
|
+
const active = resolveChoiceCase(choice, initial);
|
|
103
258
|
const controls = { [CASE_KEY]: new FormControl(active ?? null) };
|
|
104
259
|
if (active && choice.cases[active]) {
|
|
105
|
-
const caseChildren = choice.cases[active];
|
|
260
|
+
const caseChildren = caseFields(choice.cases[active]);
|
|
106
261
|
for (const key in caseChildren) {
|
|
262
|
+
// Case fields honor presence like any group's children: an absent
|
|
263
|
+
// presence field gets no control.
|
|
264
|
+
if (hasPresence(caseChildren[key]) && presenceAbsent(initial, key))
|
|
265
|
+
continue;
|
|
107
266
|
controls[key] = buildControl(caseChildren[key], initial?.[key]);
|
|
108
267
|
}
|
|
109
268
|
}
|
|
110
269
|
return new FormGroup(controls);
|
|
111
270
|
}
|
|
271
|
+
/**
|
|
272
|
+
* Switch a choice's FormGroup to `caseName`: sets `__case`, removes every other
|
|
273
|
+
* control, and builds `caseName`'s fields (normalized via {@link caseFields})
|
|
274
|
+
* with their defaults. Presence fields of the new case start absent — the
|
|
275
|
+
* switch carries no data that could make them present. An unknown case name
|
|
276
|
+
* leaves only `__case`. The swap is atomic: one value change fires, and every
|
|
277
|
+
* observable snapshot has fields matching its discriminator.
|
|
278
|
+
*/
|
|
279
|
+
function switchChoiceCase(group, choice, caseName) {
|
|
280
|
+
group.get(CASE_KEY)?.setValue(caseName, { emitEvent: false });
|
|
281
|
+
for (const name of Object.keys(group.controls)) {
|
|
282
|
+
if (name !== CASE_KEY)
|
|
283
|
+
group.removeControl(name, { emitEvent: false });
|
|
284
|
+
}
|
|
285
|
+
const caseChildren = choice.cases[caseName] ? caseFields(choice.cases[caseName]) : {};
|
|
286
|
+
for (const name in caseChildren) {
|
|
287
|
+
if (hasPresence(caseChildren[name]))
|
|
288
|
+
continue;
|
|
289
|
+
group.addControl(name, buildControl(caseChildren[name]), { emitEvent: false });
|
|
290
|
+
}
|
|
291
|
+
group.updateValueAndValidity();
|
|
292
|
+
}
|
|
293
|
+
/**
|
|
294
|
+
* Append a map entry built from `map.value` and return its committed key, or
|
|
295
|
+
* `null` when nothing was added. With no `key`, the first free `keyN`
|
|
296
|
+
* placeholder is generated (not checked against `keyPattern` — placeholders are
|
|
297
|
+
* meant to be renamed). An explicit `key` is rejected when it duplicates an
|
|
298
|
+
* existing entry or violates `keyPattern`. Rejects when `maxEntries` is reached.
|
|
299
|
+
*/
|
|
300
|
+
function addMapEntry(group, map, key) {
|
|
301
|
+
if (map.maxEntries != null && Object.keys(group.controls).length >= map.maxEntries)
|
|
302
|
+
return null;
|
|
303
|
+
let committed;
|
|
304
|
+
if (key != null) {
|
|
305
|
+
const trimmed = key.trim();
|
|
306
|
+
// `__case` is reserved for the choice discriminator; as an entry key it
|
|
307
|
+
// would make the map group indistinguishable from a choice group.
|
|
308
|
+
if (!trimmed || trimmed === CASE_KEY || group.contains(trimmed))
|
|
309
|
+
return null;
|
|
310
|
+
if (map.keyPattern && !new RegExp(map.keyPattern).test(trimmed))
|
|
311
|
+
return null;
|
|
312
|
+
committed = trimmed;
|
|
313
|
+
}
|
|
314
|
+
else {
|
|
315
|
+
let n = Object.keys(group.controls).length + 1;
|
|
316
|
+
committed = `key${n}`;
|
|
317
|
+
while (group.contains(committed))
|
|
318
|
+
committed = `key${++n}`;
|
|
319
|
+
}
|
|
320
|
+
group.addControl(committed, buildControl(map.value));
|
|
321
|
+
return committed;
|
|
322
|
+
}
|
|
323
|
+
/**
|
|
324
|
+
* Rename entry `oldKey` to `newKey.trim()`, preserving the control instance
|
|
325
|
+
* (so the value survives) and the entry's position in the group's key order —
|
|
326
|
+
* the order `getRawValue()` serializes and the tree editor renders. Returns
|
|
327
|
+
* whether the rename was committed: an empty, reserved (`__case`), unchanged,
|
|
328
|
+
* duplicate, or `keyPattern`-violating key is a no-op, leaving the entry under
|
|
329
|
+
* its current name. Entry keys are looked up verbatim — never via
|
|
330
|
+
* `AbstractControl.get`, which would split keys like `10.0.0.1` into
|
|
331
|
+
* dot-delimited paths. Emits a single value change.
|
|
332
|
+
*/
|
|
333
|
+
function renameMapEntry(group, map, oldKey, newKey) {
|
|
334
|
+
const committed = newKey.trim();
|
|
335
|
+
if (!committed || committed === CASE_KEY || committed === oldKey || group.contains(committed))
|
|
336
|
+
return false;
|
|
337
|
+
if (map.keyPattern && !new RegExp(map.keyPattern).test(committed))
|
|
338
|
+
return false;
|
|
339
|
+
const control = group.controls[oldKey];
|
|
340
|
+
if (!control)
|
|
341
|
+
return false;
|
|
342
|
+
// Re-key in place: swap the name, then re-append every key that followed so
|
|
343
|
+
// the renamed entry does not jump to the end of the key order.
|
|
344
|
+
const following = Object.keys(group.controls);
|
|
345
|
+
following.splice(0, following.indexOf(oldKey) + 1);
|
|
346
|
+
group.removeControl(oldKey, { emitEvent: false });
|
|
347
|
+
group.addControl(committed, control, { emitEvent: false });
|
|
348
|
+
for (const key of following) {
|
|
349
|
+
const sibling = group.controls[key];
|
|
350
|
+
group.removeControl(key, { emitEvent: false });
|
|
351
|
+
group.addControl(key, sibling, { emitEvent: false });
|
|
352
|
+
}
|
|
353
|
+
group.updateValueAndValidity();
|
|
354
|
+
return true;
|
|
355
|
+
}
|
|
356
|
+
/** Remove entry `key` unless the map is at `minEntries`. Returns whether it was removed. */
|
|
357
|
+
function removeMapEntry(group, map, key) {
|
|
358
|
+
if (!group.contains(key))
|
|
359
|
+
return false;
|
|
360
|
+
if (map.minEntries != null && Object.keys(group.controls).length <= map.minEntries)
|
|
361
|
+
return false;
|
|
362
|
+
group.removeControl(key);
|
|
363
|
+
return true;
|
|
364
|
+
}
|
|
365
|
+
/**
|
|
366
|
+
* The map's own constraints as a group validator: entry count against
|
|
367
|
+
* `minEntries`/`maxEntries` and every entry key against `keyPattern`. The UI
|
|
368
|
+
* gates prevent most violations; the validator reports the ones that slip
|
|
369
|
+
* through (seeded wire data, generated `keyN` placeholders awaiting a rename).
|
|
370
|
+
*/
|
|
371
|
+
function mapValidator(map) {
|
|
372
|
+
let re = null;
|
|
373
|
+
if (map.keyPattern) {
|
|
374
|
+
try {
|
|
375
|
+
re = new RegExp(map.keyPattern);
|
|
376
|
+
}
|
|
377
|
+
catch {
|
|
378
|
+
re = null;
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
return (ctrl) => {
|
|
382
|
+
const keys = Object.keys(ctrl.controls);
|
|
383
|
+
const errors = {};
|
|
384
|
+
if (map.minEntries != null && keys.length < map.minEntries) {
|
|
385
|
+
errors['minEntries'] = { required: map.minEntries, actual: keys.length };
|
|
386
|
+
}
|
|
387
|
+
if (map.maxEntries != null && keys.length > map.maxEntries) {
|
|
388
|
+
errors['maxEntries'] = { allowed: map.maxEntries, actual: keys.length };
|
|
389
|
+
}
|
|
390
|
+
if (re) {
|
|
391
|
+
const invalidKeys = keys.filter((k) => !re.test(k));
|
|
392
|
+
if (invalidKeys.length)
|
|
393
|
+
errors['keyPattern'] = { pattern: map.keyPattern, keys: invalidKeys };
|
|
394
|
+
}
|
|
395
|
+
return Object.keys(errors).length ? errors : null;
|
|
396
|
+
};
|
|
397
|
+
}
|
|
398
|
+
/**
|
|
399
|
+
* Build the FormGroup for a map: one control per entry, keyed by the entry key,
|
|
400
|
+
* each built from the map's shared `value` schema. Because the entry keys are the
|
|
401
|
+
* control names, `getRawValue()` is the map object directly. Empty when no
|
|
402
|
+
* initial object is supplied; the renderer adds/removes/renames entries. The
|
|
403
|
+
* group carries {@link mapValidator}, so `keyPattern`/`minEntries`/`maxEntries`
|
|
404
|
+
* violations surface as validation errors.
|
|
405
|
+
*/
|
|
406
|
+
function buildMapControl(map, initial) {
|
|
407
|
+
const controls = {};
|
|
408
|
+
const source = initial && typeof initial === 'object' && !Array.isArray(initial) ? initial : {};
|
|
409
|
+
for (const key of Object.keys(source)) {
|
|
410
|
+
controls[key] = buildControl(map.value, source[key]);
|
|
411
|
+
}
|
|
412
|
+
return new FormGroup(controls, { validators: mapValidator(map) });
|
|
413
|
+
}
|
|
414
|
+
/**
|
|
415
|
+
* Build the `AbstractControl` for a single schema node.
|
|
416
|
+
*
|
|
417
|
+
* Dispatches on `node.kind`: a `leaf` becomes a `FormControl`, a `leafList` a
|
|
418
|
+
* `FormArray` of controls, a `nodeGroup` a nested `FormGroup`, and a
|
|
419
|
+
* `nodeGroupList` a `FormArray` of groups. `initial` is the runtime value for
|
|
420
|
+
* this node — a scalar for a leaf, an array for a list, an object for a group —
|
|
421
|
+
* and seeds the control's value (falling back to the node's `default`).
|
|
422
|
+
*
|
|
423
|
+
* Most callers use {@link buildFormFromSchema}; this is exposed for building a
|
|
424
|
+
* control from a single non-root node.
|
|
425
|
+
*/
|
|
112
426
|
function buildControl(node, initial) {
|
|
113
427
|
if (isLeaf(node)) {
|
|
114
428
|
return buildLeafControl(node, initial);
|
|
@@ -116,6 +430,9 @@ function buildControl(node, initial) {
|
|
|
116
430
|
if (isChoice(node)) {
|
|
117
431
|
return buildChoiceControl(node, initial ? initial : null);
|
|
118
432
|
}
|
|
433
|
+
if (isMap(node)) {
|
|
434
|
+
return buildMapControl(node, initial ? initial : null);
|
|
435
|
+
}
|
|
119
436
|
if (isLeafList(node)) {
|
|
120
437
|
return buildLeafListControl(node, initial !== null
|
|
121
438
|
? initial
|
|
@@ -138,35 +455,19 @@ function buildControl(node, initial) {
|
|
|
138
455
|
* is an optional value object keyed by the schema's `children` keys; each child
|
|
139
456
|
* control is seeded from its matching slice (falling back to the node `default`).
|
|
140
457
|
*
|
|
458
|
+
* Presence nodes whose key is absent from `initial` get no control, at any depth
|
|
459
|
+
* — plain children, list items, map values, and choice case fields alike — so
|
|
460
|
+
* they are absent from the form value until enabled. A key present with an
|
|
461
|
+
* explicit `null` keeps its control: `null` is a value, absence is the missing
|
|
462
|
+
* key.
|
|
463
|
+
*
|
|
141
464
|
* Inference only holds when `schema`'s literal type is preserved. Author schemas
|
|
142
465
|
* with {@link defineSchema} or a `satisfies NodeGroup` annotation — never
|
|
143
466
|
* `const schema: NodeGroup = ...`, which widens `children` and erases the field
|
|
144
467
|
* names and value types.
|
|
145
468
|
*/
|
|
146
|
-
/**
|
|
147
|
-
* Remove presence groups that have no initial value so they are absent from the
|
|
148
|
-
* built form (and from `form.value`) until the user toggles them on. Runs on the
|
|
149
|
-
* fully-built, attached tree. `removeControl` is used rather than `disable`
|
|
150
|
-
* because a disabled group is still included in `FormGroup.value`.
|
|
151
|
-
*/
|
|
152
|
-
function applyPresence(group, schema, initial) {
|
|
153
|
-
for (const key in schema.children) {
|
|
154
|
-
const child = schema.children[key];
|
|
155
|
-
if (child.kind !== 'nodeGroup')
|
|
156
|
-
continue;
|
|
157
|
-
const childInitial = initial?.[key];
|
|
158
|
-
if (child.presence && childInitial == null) {
|
|
159
|
-
group.removeControl(key);
|
|
160
|
-
}
|
|
161
|
-
else if (group.get(key) instanceof FormGroup) {
|
|
162
|
-
applyPresence(group.get(key), child, childInitial);
|
|
163
|
-
}
|
|
164
|
-
}
|
|
165
|
-
}
|
|
166
469
|
function buildFormFromSchema(schema, initial = null) {
|
|
167
|
-
|
|
168
|
-
applyPresence(group, schema, initial);
|
|
169
|
-
return group;
|
|
470
|
+
return buildNodeGroupControl(schema, initial);
|
|
170
471
|
}
|
|
171
472
|
/**
|
|
172
473
|
* Capture a schema literal with its exact type while checking it against
|
|
@@ -193,11 +494,11 @@ class LeafEnumRendererComponent {
|
|
|
193
494
|
remove;
|
|
194
495
|
editable = true;
|
|
195
496
|
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.9", ngImport: i0, type: LeafEnumRendererComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
|
|
196
|
-
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "20.3.9", type: LeafEnumRendererComponent, isStandalone: true, selector: "nff-leaf-enum-renderer", inputs: { leafEnum: "leafEnum", initialValue: "initialValue", control: "control", removable: "removable", remove: "remove", editable: "editable" }, ngImport: i0, template: "<mat-form-field [appearance]=\"editable ? 'fill' : 'outline'\">\n <mat-label>{{ leafEnum.label ?? leafEnum.name }}</mat-label>\n <mat-select [formControl]=\"control\" value=\"{{
|
|
497
|
+
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "20.3.9", type: LeafEnumRendererComponent, isStandalone: true, selector: "nff-leaf-enum-renderer", inputs: { leafEnum: "leafEnum", initialValue: "initialValue", control: "control", removable: "removable", remove: "remove", editable: "editable" }, ngImport: i0, template: "<mat-form-field [appearance]=\"editable ? 'fill' : 'outline'\">\n <mat-label>{{ leafEnum.label ?? leafEnum.name }}</mat-label>\n @if (editable) {\n <mat-select [formControl]=\"control\">\n @for (option of leafEnum.enum; track $index) {\n <mat-option [value]=\"option\">{{ leafEnum.enumLabel?.[$index] ?? option }}</mat-option>\n }\n </mat-select>\n } @else {\n <!-- Display-only: no formControl binding, so the wire value cannot change. -->\n <mat-select [value]=\"control.value\" disabled>\n @for (option of leafEnum.enum; track $index) {\n <mat-option [value]=\"option\">{{ leafEnum.enumLabel?.[$index] ?? option }}</mat-option>\n }\n </mat-select>\n }\n</mat-form-field>\n", styles: [":host{flex:1;min-width:0;width:100%}mat-form-field{width:100%}\n"], dependencies: [{ kind: "ngmodule", type: ReactiveFormsModule }, { kind: "directive", type: i1.NgControlStatus, selector: "[formControlName],[ngModel],[formControl]" }, { kind: "directive", type: i1.FormControlDirective, selector: "[formControl]", inputs: ["formControl", "disabled", "ngModel"], outputs: ["ngModelChange"], exportAs: ["ngForm"] }, { kind: "ngmodule", type: MatFormFieldModule }, { kind: "component", type: i2.MatFormField, selector: "mat-form-field", inputs: ["hideRequiredMarker", "color", "floatLabel", "appearance", "subscriptSizing", "hintLabel"], exportAs: ["matFormField"] }, { kind: "directive", type: i2.MatLabel, selector: "mat-label" }, { kind: "ngmodule", type: MatSelectModule }, { kind: "component", type: i3.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: i3.MatOption, selector: "mat-option", inputs: ["value", "id", "disabled"], outputs: ["onSelectionChange"], exportAs: ["matOption"] }] });
|
|
197
498
|
}
|
|
198
499
|
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.9", ngImport: i0, type: LeafEnumRendererComponent, decorators: [{
|
|
199
500
|
type: Component,
|
|
200
|
-
args: [{ selector: 'nff-leaf-enum-renderer', standalone: true, imports: [ReactiveFormsModule, MatFormFieldModule, MatSelectModule], template: "<mat-form-field [appearance]=\"editable ? 'fill' : 'outline'\">\n <mat-label>{{ leafEnum.label ?? leafEnum.name }}</mat-label>\n <mat-select [formControl]=\"control\" value=\"{{
|
|
501
|
+
args: [{ selector: 'nff-leaf-enum-renderer', standalone: true, imports: [ReactiveFormsModule, MatFormFieldModule, MatSelectModule], template: "<mat-form-field [appearance]=\"editable ? 'fill' : 'outline'\">\n <mat-label>{{ leafEnum.label ?? leafEnum.name }}</mat-label>\n @if (editable) {\n <mat-select [formControl]=\"control\">\n @for (option of leafEnum.enum; track $index) {\n <mat-option [value]=\"option\">{{ leafEnum.enumLabel?.[$index] ?? option }}</mat-option>\n }\n </mat-select>\n } @else {\n <!-- Display-only: no formControl binding, so the wire value cannot change. -->\n <mat-select [value]=\"control.value\" disabled>\n @for (option of leafEnum.enum; track $index) {\n <mat-option [value]=\"option\">{{ leafEnum.enumLabel?.[$index] ?? option }}</mat-option>\n }\n </mat-select>\n }\n</mat-form-field>\n", styles: [":host{flex:1;min-width:0;width:100%}mat-form-field{width:100%}\n"] }]
|
|
201
502
|
}], propDecorators: { leafEnum: [{
|
|
202
503
|
type: Input
|
|
203
504
|
}], initialValue: [{
|
|
@@ -213,19 +514,63 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.9", ngImpor
|
|
|
213
514
|
}] } });
|
|
214
515
|
|
|
215
516
|
class LeafRendererComponent {
|
|
517
|
+
elementRef;
|
|
216
518
|
leaf_;
|
|
217
519
|
control = new FormControl();
|
|
218
|
-
initialValue;
|
|
219
520
|
removable = false;
|
|
220
521
|
editable = true;
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
522
|
+
/** Focus the field once rendered — for fields the user just added (e.g. an enabled presence leaf). */
|
|
523
|
+
autofocus = false;
|
|
524
|
+
/** Emitted when the user removes this field (e.g. an optional presence leaf). */
|
|
525
|
+
remove = output();
|
|
526
|
+
constructor(elementRef) {
|
|
527
|
+
this.elementRef = elementRef;
|
|
528
|
+
}
|
|
529
|
+
ngAfterViewInit() {
|
|
530
|
+
if (this.autofocus) {
|
|
531
|
+
// Deferred out of the change-detection pass: focusing flips the form
|
|
532
|
+
// field's label-float state, which would otherwise trip NG0100.
|
|
533
|
+
setTimeout(() => this.elementRef.nativeElement.querySelector('input, mat-select')?.focus());
|
|
225
534
|
}
|
|
226
535
|
}
|
|
227
|
-
|
|
228
|
-
|
|
536
|
+
/** Whether this field accepts input: the form is editable and the leaf is not `readOnly`. */
|
|
537
|
+
get fieldEditable() {
|
|
538
|
+
return this.editable && !('name' in this.leaf_ && this.leaf_.readOnly);
|
|
539
|
+
}
|
|
540
|
+
/**
|
|
541
|
+
* A human-readable message for the control's active validation error, or `''`
|
|
542
|
+
* when valid. `mat-form-field` only shows it once the field is in an error
|
|
543
|
+
* state (invalid and touched), so it can be bound unconditionally.
|
|
544
|
+
*/
|
|
545
|
+
get errorText() {
|
|
546
|
+
const e = this.control.errors;
|
|
547
|
+
if (!e)
|
|
548
|
+
return '';
|
|
549
|
+
const label = 'name' in this.leaf_ ? this.leaf_.label ?? this.leaf_.name : 'Value';
|
|
550
|
+
if (e['required'])
|
|
551
|
+
return `${label} is required`;
|
|
552
|
+
if (e['minlength'])
|
|
553
|
+
return `Must be at least ${e['minlength'].requiredLength} characters`;
|
|
554
|
+
if (e['maxlength'])
|
|
555
|
+
return `Must be at most ${e['maxlength'].requiredLength} characters`;
|
|
556
|
+
if (e['pattern'])
|
|
557
|
+
return `Must match ${e['pattern'].requiredPattern ?? 'the required pattern'}`;
|
|
558
|
+
if (e['email'])
|
|
559
|
+
return 'Must be a valid email address';
|
|
560
|
+
if (e['uri'])
|
|
561
|
+
return 'Must be a valid URI';
|
|
562
|
+
if (e['min'])
|
|
563
|
+
return `Must be ≥ ${e['min'].min}`;
|
|
564
|
+
if (e['max'])
|
|
565
|
+
return `Must be ≤ ${e['max'].max}`;
|
|
566
|
+
if (e['multipleOf'])
|
|
567
|
+
return `Must be a multiple of ${e['multipleOf'].multipleOf}`;
|
|
568
|
+
if (e['enum'])
|
|
569
|
+
return 'Not an allowed value';
|
|
570
|
+
return 'Invalid value';
|
|
571
|
+
}
|
|
572
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.9", ngImport: i0, type: LeafRendererComponent, deps: [{ token: i0.ElementRef }], target: i0.ɵɵFactoryTarget.Component });
|
|
573
|
+
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "20.3.9", type: LeafRendererComponent, isStandalone: true, selector: "nff-leaf-renderer", inputs: { leaf_: "leaf_", control: "control", removable: "removable", editable: "editable", autofocus: "autofocus" }, outputs: { remove: "remove" }, ngImport: i0, template: "@if ('name' in leaf_) {\n @if (leaf_.type === 'string') {\n <mat-form-field [appearance]=\"fieldEditable ? 'fill' : 'outline'\">\n <mat-label>{{ leaf_.label ?? leaf_.name }}</mat-label>\n <input [readonly]=\"!fieldEditable\" matInput type=\"text\" [formControl]=\"control\">\n <mat-error>{{ errorText }}</mat-error>\n </mat-form-field>\n } @else if (leaf_.type === 'number') {\n <mat-form-field [appearance]=\"fieldEditable ? 'fill' : 'outline'\">\n <mat-label>{{ leaf_.label ?? leaf_.name }}</mat-label>\n <input [readonly]=\"!fieldEditable\" matInput type=\"number\" [formControl]=\"control\">\n <mat-error>{{ errorText }}</mat-error>\n </mat-form-field>\n } @else if (leaf_.type === 'boolean') {\n @if (fieldEditable) {\n <mat-checkbox [formControl]=\"control\">{{ leaf_.label ?? leaf_.name }}</mat-checkbox>\n } @else {\n <!-- Display-only: no formControl binding, so the wire value cannot change.\n The control itself is never disable()d \u2014 a disabled control would\n still appear in the form value with different semantics. -->\n <mat-checkbox [checked]=\"control.value === true\" disabled>{{ leaf_.label ?? leaf_.name }}</mat-checkbox>\n }\n } @else if (leaf_.type === 'enum') {\n <nff-leaf-enum-renderer [editable]=\"fieldEditable\" [leafEnum]=\"leaf_\" [control]=\"control\"></nff-leaf-enum-renderer>\n }\n @if (removable && editable) {\n <button matIconButton class=\"remove-button small-icon-button\" matTooltip=\"Remove\" [attr.aria-label]=\"'Remove ' + ('label' in leaf_ && leaf_.label ? leaf_.label : 'name' in leaf_ ? leaf_.name : 'field')\" (click)=\"remove.emit()\">\n <mat-icon>delete</mat-icon>\n </button>\n }\n}\n", styles: [":host{display:flex;flex:1 1 0;min-width:10%;gap:8px}mat-form-field{flex:1;min-width:0}.remove-button{flex:0 0 auto;align-self:flex-start;margin-top:4px;background-color:var(--mat-sys-error-container);box-shadow:0 2px 1px -1px #0003,0 1px 1px #00000024,0 1px 3px #0000001f}\n"], dependencies: [{ kind: "ngmodule", type: MatFormFieldModule }, { kind: "component", type: i2.MatFormField, selector: "mat-form-field", inputs: ["hideRequiredMarker", "color", "floatLabel", "appearance", "subscriptSizing", "hintLabel"], exportAs: ["matFormField"] }, { kind: "directive", type: i2.MatLabel, selector: "mat-label" }, { kind: "directive", type: i2.MatError, selector: "mat-error, [matError]", inputs: ["id"] }, { kind: "ngmodule", type: ReactiveFormsModule }, { kind: "directive", type: i1.DefaultValueAccessor, selector: "input:not([type=checkbox])[formControlName],textarea[formControlName],input:not([type=checkbox])[formControl],textarea[formControl],input:not([type=checkbox])[ngModel],textarea[ngModel],[ngDefaultControl]" }, { kind: "directive", type: i1.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.FormControlDirective, selector: "[formControl]", inputs: ["formControl", "disabled", "ngModel"], outputs: ["ngModelChange"], exportAs: ["ngForm"] }, { 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: MatCheckboxModule }, { kind: "component", type: i4.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: MatSelectModule }, { kind: "ngmodule", type: MatIconModule }, { kind: "component", type: i1$1.MatIcon, selector: "mat-icon", inputs: ["color", "inline", "svgIcon", "fontSet", "fontIcon"], exportAs: ["matIcon"] }, { kind: "ngmodule", type: MatButtonModule }, { kind: "component", type: i2$1.MatIconButton, selector: "button[mat-icon-button], a[mat-icon-button], button[matIconButton], a[matIconButton]", exportAs: ["matButton", "matAnchor"] }, { kind: "directive", type: MatTooltip, selector: "[matTooltip]", inputs: ["matTooltipPosition", "matTooltipPositionAtOrigin", "matTooltipDisabled", "matTooltipShowDelay", "matTooltipHideDelay", "matTooltipTouchGestures", "matTooltip", "matTooltipClass"], exportAs: ["matTooltip"] }, { kind: "component", type: LeafEnumRendererComponent, selector: "nff-leaf-enum-renderer", inputs: ["leafEnum", "initialValue", "control", "removable", "remove", "editable"] }] });
|
|
229
574
|
}
|
|
230
575
|
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.9", ngImport: i0, type: LeafRendererComponent, decorators: [{
|
|
231
576
|
type: Component,
|
|
@@ -237,21 +582,20 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.9", ngImpor
|
|
|
237
582
|
MatSelectModule,
|
|
238
583
|
MatIconModule,
|
|
239
584
|
MatButtonModule,
|
|
585
|
+
MatTooltip,
|
|
240
586
|
LeafEnumRendererComponent,
|
|
241
|
-
], template: "@if ('name' in leaf_) {\n @if (leaf_.type === 'string') {\n <mat-form-field [appearance]=\"
|
|
242
|
-
}], propDecorators: { leaf_: [{
|
|
587
|
+
], template: "@if ('name' in leaf_) {\n @if (leaf_.type === 'string') {\n <mat-form-field [appearance]=\"fieldEditable ? 'fill' : 'outline'\">\n <mat-label>{{ leaf_.label ?? leaf_.name }}</mat-label>\n <input [readonly]=\"!fieldEditable\" matInput type=\"text\" [formControl]=\"control\">\n <mat-error>{{ errorText }}</mat-error>\n </mat-form-field>\n } @else if (leaf_.type === 'number') {\n <mat-form-field [appearance]=\"fieldEditable ? 'fill' : 'outline'\">\n <mat-label>{{ leaf_.label ?? leaf_.name }}</mat-label>\n <input [readonly]=\"!fieldEditable\" matInput type=\"number\" [formControl]=\"control\">\n <mat-error>{{ errorText }}</mat-error>\n </mat-form-field>\n } @else if (leaf_.type === 'boolean') {\n @if (fieldEditable) {\n <mat-checkbox [formControl]=\"control\">{{ leaf_.label ?? leaf_.name }}</mat-checkbox>\n } @else {\n <!-- Display-only: no formControl binding, so the wire value cannot change.\n The control itself is never disable()d \u2014 a disabled control would\n still appear in the form value with different semantics. -->\n <mat-checkbox [checked]=\"control.value === true\" disabled>{{ leaf_.label ?? leaf_.name }}</mat-checkbox>\n }\n } @else if (leaf_.type === 'enum') {\n <nff-leaf-enum-renderer [editable]=\"fieldEditable\" [leafEnum]=\"leaf_\" [control]=\"control\"></nff-leaf-enum-renderer>\n }\n @if (removable && editable) {\n <button matIconButton class=\"remove-button small-icon-button\" matTooltip=\"Remove\" [attr.aria-label]=\"'Remove ' + ('label' in leaf_ && leaf_.label ? leaf_.label : 'name' in leaf_ ? leaf_.name : 'field')\" (click)=\"remove.emit()\">\n <mat-icon>delete</mat-icon>\n </button>\n }\n}\n", styles: [":host{display:flex;flex:1 1 0;min-width:10%;gap:8px}mat-form-field{flex:1;min-width:0}.remove-button{flex:0 0 auto;align-self:flex-start;margin-top:4px;background-color:var(--mat-sys-error-container);box-shadow:0 2px 1px -1px #0003,0 1px 1px #00000024,0 1px 3px #0000001f}\n"] }]
|
|
588
|
+
}], ctorParameters: () => [{ type: i0.ElementRef }], propDecorators: { leaf_: [{
|
|
243
589
|
type: Input
|
|
244
590
|
}], control: [{
|
|
245
591
|
type: Input
|
|
246
|
-
}], initialValue: [{
|
|
247
|
-
type: Input
|
|
248
592
|
}], removable: [{
|
|
249
593
|
type: Input
|
|
250
594
|
}], editable: [{
|
|
251
595
|
type: Input
|
|
252
|
-
}],
|
|
253
|
-
type:
|
|
254
|
-
}] } });
|
|
596
|
+
}], autofocus: [{
|
|
597
|
+
type: Input
|
|
598
|
+
}], remove: [{ type: i0.Output, args: ["remove"] }] } });
|
|
255
599
|
|
|
256
600
|
class NodeGroupListRendererComponent {
|
|
257
601
|
nodeGroupList;
|
|
@@ -298,7 +642,7 @@ class NodeGroupListRendererComponent {
|
|
|
298
642
|
setLastEditable() {
|
|
299
643
|
const lastItem = this.items.last;
|
|
300
644
|
if (lastItem) {
|
|
301
|
-
lastItem.editable
|
|
645
|
+
lastItem.editable.set(true);
|
|
302
646
|
}
|
|
303
647
|
this.cdr.detectChanges();
|
|
304
648
|
}
|
|
@@ -315,7 +659,7 @@ class NodeGroupListRendererComponent {
|
|
|
315
659
|
}
|
|
316
660
|
}
|
|
317
661
|
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.9", ngImport: i0, type: NodeGroupListRendererComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
|
|
318
|
-
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "20.3.9", type: NodeGroupListRendererComponent, isStandalone: true, selector: "nff-node-group-list-renderer", inputs: { nodeGroupList: "nodeGroupList", initialValue: "initialValue", formArray: "formArray", editable: "editable", minItems: "minItems", maxItems: "maxItems" }, outputs: { message: "message" }, viewQueries: [{ propertyName: "items", predicate: i0.forwardRef(() => DynamicRecursiveFormComponent), descendants: true }], ngImport: i0, template: "@if (formArray && nodeGroupList) {\n <div class=\"fields-container\">\n @for (group of formArray.controls; track $index) {\n <div class=\"field-item\">\n <nff-dynamic-recursive-form\n [schema]=\"nodeGroupList.type\"\n [formGroup]=\"asFormGroup(group)\"\n [title]=\"getTitle($index)\"\n [index]=\"0\"\n [removable]=\"formArray.length > minItems\"\n (remove)=\"removeItem($index)\"\n [editable]=\"editable\"\n [addButtonCallback]=\"addItem\"\n />\n </div>\n }\n </div>\n}\n", styles: [":host{position:relative;width:100%;flex:1 1 0}.fields{display:flex;flex-direction:column;flex-wrap:wrap;width:100%}.fields-container{display:flex;flex-direction:column}.field-item{display:flex;flex-direction:row;gap:8px}.actions{display:flex;flex-direction:row;gap:8px;align-items:center;margin-top:0;padding:0;min-width:24px}.title{display:flex;flex-direction:column;gap:8px;align-items:center}nff-dynamic-recursive-form{flex:1}.add-button{background-color:var(--mat-sys-primary-container)}\n"], dependencies: [{ kind: "component", type: i0.forwardRef(() => DynamicRecursiveFormComponent), selector: "nff-dynamic-recursive-form", inputs: ["schema", "initialValue", "formGroup", "index", "removable", "title", "editable", "addButtonCallback"], outputs: ["remove"] }, { kind: "ngmodule", type: i0.forwardRef(() => MatButtonModule) }, { kind: "ngmodule", type: i0.forwardRef(() => MatIconModule) }, { kind: "ngmodule", type: i0.forwardRef(() => MatTooltipModule) }] });
|
|
662
|
+
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "20.3.9", type: NodeGroupListRendererComponent, isStandalone: true, selector: "nff-node-group-list-renderer", inputs: { nodeGroupList: "nodeGroupList", initialValue: "initialValue", formArray: "formArray", editable: "editable", minItems: "minItems", maxItems: "maxItems" }, outputs: { message: "message" }, viewQueries: [{ propertyName: "items", predicate: i0.forwardRef(() => DynamicRecursiveFormComponent), descendants: true }], ngImport: i0, template: "@if (formArray && nodeGroupList) {\n <div class=\"fields-container\">\n @for (group of formArray.controls; track $index) {\n <div class=\"field-item\">\n <nff-dynamic-recursive-form\n [schema]=\"nodeGroupList.type\"\n [formGroup]=\"asFormGroup(group)\"\n [title]=\"getTitle($index)\"\n [index]=\"0\"\n [removable]=\"formArray.length > minItems\"\n (remove)=\"removeItem($index)\"\n [editable]=\"editable\"\n [addButtonCallback]=\"addItem\"\n />\n </div>\n }\n </div>\n}\n", styles: [":host{position:relative;width:100%;flex:1 1 0}.fields{display:flex;flex-direction:column;flex-wrap:wrap;width:100%}.fields-container{display:flex;flex-direction:column}.field-item{display:flex;flex-direction:row;gap:8px}.actions{display:flex;flex-direction:row;gap:8px;align-items:center;margin-top:0;padding:0;min-width:24px}.title{display:flex;flex-direction:column;gap:8px;align-items:center}nff-dynamic-recursive-form{flex:1}.add-button{background-color:var(--mat-sys-primary-container)}\n"], dependencies: [{ kind: "component", type: i0.forwardRef(() => DynamicRecursiveFormComponent), selector: "nff-dynamic-recursive-form", inputs: ["schema", "initialValue", "formGroup", "index", "removable", "title", "editable", "addButtonCallback", "focusLeaf"], outputs: ["remove", "editableChange"] }, { kind: "ngmodule", type: i0.forwardRef(() => MatButtonModule) }, { kind: "ngmodule", type: i0.forwardRef(() => MatIconModule) }, { kind: "ngmodule", type: i0.forwardRef(() => MatTooltipModule) }] });
|
|
319
663
|
}
|
|
320
664
|
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.9", ngImport: i0, type: NodeGroupListRendererComponent, decorators: [{
|
|
321
665
|
type: Component,
|
|
@@ -357,7 +701,7 @@ class AnonLeafRendererComponent {
|
|
|
357
701
|
this.remove.emit();
|
|
358
702
|
}
|
|
359
703
|
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.9", ngImport: i0, type: AnonLeafRendererComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
|
|
360
|
-
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "20.3.9", type: AnonLeafRendererComponent, isStandalone: true, selector: "nff-anon-leaf-renderer", inputs: { AnonLeaf: "AnonLeaf", initialValue: "initialValue", control: "control", removable: "removable", editable: "editable", index: "index", label: "label" }, outputs: { remove: "remove" }, ngImport: i0, template: "<div class=\"anon-leaf-container\">\n@if (AnonLeaf.type === 'string') {\n <mat-form-field [appearance]=\"editable ? 'fill' : 'outline'\">\n <mat-label>{{ label }} #{{ index + 1}}</mat-label>\n <input [readonly]=\"!editable\"\n [formControl]=\"control\"\n matInput type=\"text\" >\n @if (removable) {\n }\n </mat-form-field>\n} @else if (AnonLeaf.type === 'number') {\n <mat-form-field [appearance]=\"editable ? 'fill' : 'outline'\">\n <mat-label>{{ label }} #{{ index + 1}}</mat-label>\n <input [readonly]=\"!editable\"\n [formControl]=\"control\"\n matInput type=\"number\">\n </mat-form-field>\n} @else if (AnonLeaf.type === 'boolean') {\n <mat-checkbox [
|
|
704
|
+
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "20.3.9", type: AnonLeafRendererComponent, isStandalone: true, selector: "nff-anon-leaf-renderer", inputs: { AnonLeaf: "AnonLeaf", initialValue: "initialValue", control: "control", removable: "removable", editable: "editable", index: "index", label: "label" }, outputs: { remove: "remove" }, ngImport: i0, template: "<div class=\"anon-leaf-container\">\n@if (AnonLeaf.type === 'string') {\n <mat-form-field [appearance]=\"editable ? 'fill' : 'outline'\">\n <mat-label>{{ label }} #{{ index + 1}}</mat-label>\n <input [readonly]=\"!editable\"\n [formControl]=\"control\"\n matInput type=\"text\" >\n @if (removable) {\n }\n </mat-form-field>\n} @else if (AnonLeaf.type === 'number') {\n <mat-form-field [appearance]=\"editable ? 'fill' : 'outline'\">\n <mat-label>{{ label }} #{{ index + 1}}</mat-label>\n <input [readonly]=\"!editable\"\n [formControl]=\"control\"\n matInput type=\"number\">\n </mat-form-field>\n} @else if (AnonLeaf.type === 'boolean') {\n @if (editable) {\n <mat-checkbox [formControl]=\"control\">value</mat-checkbox>\n } @else {\n <!-- Display-only: no formControl binding, so the wire value cannot change. -->\n <mat-checkbox [checked]=\"control.value === true\" disabled>value</mat-checkbox>\n }\n}\n @else if (AnonLeaf.type === 'enum' && 'enum' in AnonLeaf) {\n <mat-form-field [appearance]=\"editable ? 'fill' : 'outline'\">\n <mat-label>{{ label }} #{{ index + 1}}</mat-label>\n @if (editable) {\n <mat-select [formControl]=\"control\">\n @for (option of (AnonLeaf.type === 'enum' ? AnonLeaf.enum : []); track $index) {\n <mat-option [value]=\"option\">{{ option }}</mat-option>\n }\n </mat-select>\n } @else {\n <mat-select [value]=\"control.value\" disabled>\n @for (option of (AnonLeaf.type === 'enum' ? AnonLeaf.enum : []); track $index) {\n <mat-option [value]=\"option\">{{ option }}</mat-option>\n }\n </mat-select>\n }\n </mat-form-field>\n}\n</div>\n", styles: [".anon-leaf-container{display:flex;flex-direction:row;gap:8px;align-items:center;flex:1 1 0}mat-form-field{flex:1}\n"], dependencies: [{ kind: "ngmodule", type: ReactiveFormsModule }, { kind: "directive", type: i1.DefaultValueAccessor, selector: "input:not([type=checkbox])[formControlName],textarea[formControlName],input:not([type=checkbox])[formControl],textarea[formControl],input:not([type=checkbox])[ngModel],textarea[ngModel],[ngDefaultControl]" }, { kind: "directive", type: i1.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.FormControlDirective, selector: "[formControl]", inputs: ["formControl", "disabled", "ngModel"], outputs: ["ngModelChange"], exportAs: ["ngForm"] }, { kind: "ngmodule", type: MatFormFieldModule }, { kind: "component", type: i2.MatFormField, selector: "mat-form-field", inputs: ["hideRequiredMarker", "color", "floatLabel", "appearance", "subscriptSizing", "hintLabel"], exportAs: ["matFormField"] }, { kind: "directive", type: i2.MatLabel, selector: "mat-label" }, { kind: "ngmodule", type: MatSelectModule }, { kind: "component", type: i3.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: i3.MatOption, selector: "mat-option", inputs: ["value", "id", "disabled"], outputs: ["onSelectionChange"], exportAs: ["matOption"] }, { kind: "ngmodule", type: MatIconModule }, { kind: "ngmodule", type: MatCheckboxModule }, { kind: "component", type: i4.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: 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: MatButtonModule }] });
|
|
361
705
|
}
|
|
362
706
|
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.9", ngImport: i0, type: AnonLeafRendererComponent, decorators: [{
|
|
363
707
|
type: Component,
|
|
@@ -369,7 +713,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.9", ngImpor
|
|
|
369
713
|
MatCheckboxModule,
|
|
370
714
|
MatInputModule,
|
|
371
715
|
MatButtonModule,
|
|
372
|
-
], template: "<div class=\"anon-leaf-container\">\n@if (AnonLeaf.type === 'string') {\n <mat-form-field [appearance]=\"editable ? 'fill' : 'outline'\">\n <mat-label>{{ label }} #{{ index + 1}}</mat-label>\n <input [readonly]=\"!editable\"\n [formControl]=\"control\"\n matInput type=\"text\" >\n @if (removable) {\n }\n </mat-form-field>\n} @else if (AnonLeaf.type === 'number') {\n <mat-form-field [appearance]=\"editable ? 'fill' : 'outline'\">\n <mat-label>{{ label }} #{{ index + 1}}</mat-label>\n <input [readonly]=\"!editable\"\n [formControl]=\"control\"\n matInput type=\"number\">\n </mat-form-field>\n} @else if (AnonLeaf.type === 'boolean') {\n <mat-checkbox [
|
|
716
|
+
], template: "<div class=\"anon-leaf-container\">\n@if (AnonLeaf.type === 'string') {\n <mat-form-field [appearance]=\"editable ? 'fill' : 'outline'\">\n <mat-label>{{ label }} #{{ index + 1}}</mat-label>\n <input [readonly]=\"!editable\"\n [formControl]=\"control\"\n matInput type=\"text\" >\n @if (removable) {\n }\n </mat-form-field>\n} @else if (AnonLeaf.type === 'number') {\n <mat-form-field [appearance]=\"editable ? 'fill' : 'outline'\">\n <mat-label>{{ label }} #{{ index + 1}}</mat-label>\n <input [readonly]=\"!editable\"\n [formControl]=\"control\"\n matInput type=\"number\">\n </mat-form-field>\n} @else if (AnonLeaf.type === 'boolean') {\n @if (editable) {\n <mat-checkbox [formControl]=\"control\">value</mat-checkbox>\n } @else {\n <!-- Display-only: no formControl binding, so the wire value cannot change. -->\n <mat-checkbox [checked]=\"control.value === true\" disabled>value</mat-checkbox>\n }\n}\n @else if (AnonLeaf.type === 'enum' && 'enum' in AnonLeaf) {\n <mat-form-field [appearance]=\"editable ? 'fill' : 'outline'\">\n <mat-label>{{ label }} #{{ index + 1}}</mat-label>\n @if (editable) {\n <mat-select [formControl]=\"control\">\n @for (option of (AnonLeaf.type === 'enum' ? AnonLeaf.enum : []); track $index) {\n <mat-option [value]=\"option\">{{ option }}</mat-option>\n }\n </mat-select>\n } @else {\n <mat-select [value]=\"control.value\" disabled>\n @for (option of (AnonLeaf.type === 'enum' ? AnonLeaf.enum : []); track $index) {\n <mat-option [value]=\"option\">{{ option }}</mat-option>\n }\n </mat-select>\n }\n </mat-form-field>\n}\n</div>\n", styles: [".anon-leaf-container{display:flex;flex-direction:row;gap:8px;align-items:center;flex:1 1 0}mat-form-field{flex:1}\n"] }]
|
|
373
717
|
}], propDecorators: { AnonLeaf: [{
|
|
374
718
|
type: Input
|
|
375
719
|
}], initialValue: [{
|
|
@@ -424,7 +768,7 @@ class LeafListRendererComponent {
|
|
|
424
768
|
this.formArray.push(new FormControl());
|
|
425
769
|
}
|
|
426
770
|
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.9", ngImport: i0, type: LeafListRendererComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
|
|
427
|
-
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "20.3.9", type: LeafListRendererComponent, isStandalone: true, selector: "nff-leaf-list-renderer", inputs: { leaf_: "leaf_", initialValue: "initialValue", formArray: "formArray", editable: "editable", minItems: "minItems" }, outputs: { message: "message" }, host: { properties: { "class.stacked": "this.stacked" } }, ngImport: i0, template: "@if (formArray && leaf_) {\n @for (control of formArray.controls; track $index) {\n <div class=\"field-item\">\n <nff-anon-leaf-renderer\n [AnonLeaf]=\"leaf_\" [control]=\"control\"\n [removable]=\"minItems == null || formArray.length > minItems\"\n [index]=\"$index\"\n [editable]=\"editable\"\n [label]=\"leaf_.label ?? leaf_.name\"\n (remove)=\"removeItem($index)\"\n />\n @if (editable) {\n <div class=\"actions\">\n @if (formArray.length > minItems) {\n <button class=\"remove-button small-icon-button\" matIconButton (click)=\"removeItem($index)\">\n <mat-icon matPrefix>delete</mat-icon>\n </button>\n }\n @if ($last) {\n <button class=\"add-button small-icon-button\" matIconButton (click)=\"addItem()\">\n <mat-icon matPrefix>add</mat-icon>\n </button>\n }\n <div class=\"actions-spacer\"></div>\n </div>\n }\n </div>\n }\n}\n", styles: [":host{display:flex;flex-direction:row;flex-wrap:wrap;gap:16px}:host(.stacked){flex-basis:100%}.fields{position:relative;display:flex;flex-wrap:wrap;gap:8px;align-items:baseline}.field-item{display:flex;flex-direction:row;gap:4px;align-items:center;flex:1 1 0}.actions{z-index:100;display:flex;flex-direction:column;align-items:flex-end;gap:4px;height:100%}.actions-spacer{height:20px}nff-anon-leaf-renderer{width:100%}.add-button{background-color:var(--mat-sys-primary-container);box-shadow:0 2px 1px -1px #0003,0 1px 1px #00000024,0 1px 3px #0000001f}.remove-button{background-color:var(--mat-sys-error-container);box-shadow:0 2px 1px -1px #0003,0 1px 1px #00000024,0 1px 3px #0000001f}.title{display:flex;flex-direction:row;gap:8px;align-items:center}\n"], dependencies: [{ kind: "ngmodule", type: MatIconModule }, { kind: "component", type: i1$1.MatIcon, selector: "mat-icon", inputs: ["color", "inline", "svgIcon", "fontSet", "fontIcon"], exportAs: ["matIcon"] }, { kind: "component", type: AnonLeafRendererComponent, selector: "nff-anon-leaf-renderer", inputs: ["AnonLeaf", "initialValue", "control", "removable", "editable", "index", "label"], outputs: ["remove"] }, { kind: "ngmodule", type: MatButtonModule }, { kind: "component", type:
|
|
771
|
+
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "20.3.9", type: LeafListRendererComponent, isStandalone: true, selector: "nff-leaf-list-renderer", inputs: { leaf_: "leaf_", initialValue: "initialValue", formArray: "formArray", editable: "editable", minItems: "minItems" }, outputs: { message: "message" }, host: { properties: { "class.stacked": "this.stacked" } }, ngImport: i0, template: "@if (formArray && leaf_) {\n @for (control of formArray.controls; track $index) {\n <div class=\"field-item\">\n <nff-anon-leaf-renderer\n [AnonLeaf]=\"leaf_\" [control]=\"control\"\n [removable]=\"minItems == null || formArray.length > minItems\"\n [index]=\"$index\"\n [editable]=\"editable\"\n [label]=\"leaf_.label ?? leaf_.name\"\n (remove)=\"removeItem($index)\"\n />\n @if (editable) {\n <div class=\"actions\">\n @if (formArray.length > minItems) {\n <button class=\"remove-button small-icon-button\" matIconButton [attr.aria-label]=\"'Remove ' + (leaf_.label ?? leaf_.name) + ' item'\" (click)=\"removeItem($index)\">\n <mat-icon matPrefix>delete</mat-icon>\n </button>\n }\n @if ($last) {\n <button class=\"add-button small-icon-button\" matIconButton [attr.aria-label]=\"'Add ' + (leaf_.label ?? leaf_.name) + ' item'\" (click)=\"addItem()\">\n <mat-icon matPrefix>add</mat-icon>\n </button>\n }\n <div class=\"actions-spacer\"></div>\n </div>\n }\n </div>\n }\n}\n", styles: [":host{display:flex;flex-direction:row;flex-wrap:wrap;gap:16px}:host(.stacked){flex-basis:100%}.fields{position:relative;display:flex;flex-wrap:wrap;gap:8px;align-items:baseline}.field-item{display:flex;flex-direction:row;gap:4px;align-items:center;flex:1 1 0}.actions{z-index:100;display:flex;flex-direction:column;align-items:flex-end;gap:4px;height:100%}.actions-spacer{height:20px}nff-anon-leaf-renderer{width:100%}.add-button{background-color:var(--mat-sys-primary-container);box-shadow:0 2px 1px -1px #0003,0 1px 1px #00000024,0 1px 3px #0000001f}.remove-button{background-color:var(--mat-sys-error-container);box-shadow:0 2px 1px -1px #0003,0 1px 1px #00000024,0 1px 3px #0000001f}.title{display:flex;flex-direction:row;gap:8px;align-items:center}\n"], dependencies: [{ kind: "ngmodule", type: MatIconModule }, { kind: "component", type: i1$1.MatIcon, selector: "mat-icon", inputs: ["color", "inline", "svgIcon", "fontSet", "fontIcon"], exportAs: ["matIcon"] }, { kind: "component", type: AnonLeafRendererComponent, selector: "nff-anon-leaf-renderer", inputs: ["AnonLeaf", "initialValue", "control", "removable", "editable", "index", "label"], outputs: ["remove"] }, { kind: "ngmodule", type: MatButtonModule }, { kind: "component", type: i2$1.MatIconButton, selector: "button[mat-icon-button], a[mat-icon-button], button[matIconButton], a[matIconButton]", exportAs: ["matButton", "matAnchor"] }, { kind: "directive", type: MatPrefix, selector: "[matPrefix], [matIconPrefix], [matTextPrefix]", inputs: ["matTextPrefix"] }] });
|
|
428
772
|
}
|
|
429
773
|
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.9", ngImport: i0, type: LeafListRendererComponent, decorators: [{
|
|
430
774
|
type: Component,
|
|
@@ -433,7 +777,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.9", ngImpor
|
|
|
433
777
|
AnonLeafRendererComponent,
|
|
434
778
|
MatButtonModule,
|
|
435
779
|
MatPrefix,
|
|
436
|
-
], template: "@if (formArray && leaf_) {\n @for (control of formArray.controls; track $index) {\n <div class=\"field-item\">\n <nff-anon-leaf-renderer\n [AnonLeaf]=\"leaf_\" [control]=\"control\"\n [removable]=\"minItems == null || formArray.length > minItems\"\n [index]=\"$index\"\n [editable]=\"editable\"\n [label]=\"leaf_.label ?? leaf_.name\"\n (remove)=\"removeItem($index)\"\n />\n @if (editable) {\n <div class=\"actions\">\n @if (formArray.length > minItems) {\n <button class=\"remove-button small-icon-button\" matIconButton (click)=\"removeItem($index)\">\n <mat-icon matPrefix>delete</mat-icon>\n </button>\n }\n @if ($last) {\n <button class=\"add-button small-icon-button\" matIconButton (click)=\"addItem()\">\n <mat-icon matPrefix>add</mat-icon>\n </button>\n }\n <div class=\"actions-spacer\"></div>\n </div>\n }\n </div>\n }\n}\n", styles: [":host{display:flex;flex-direction:row;flex-wrap:wrap;gap:16px}:host(.stacked){flex-basis:100%}.fields{position:relative;display:flex;flex-wrap:wrap;gap:8px;align-items:baseline}.field-item{display:flex;flex-direction:row;gap:4px;align-items:center;flex:1 1 0}.actions{z-index:100;display:flex;flex-direction:column;align-items:flex-end;gap:4px;height:100%}.actions-spacer{height:20px}nff-anon-leaf-renderer{width:100%}.add-button{background-color:var(--mat-sys-primary-container);box-shadow:0 2px 1px -1px #0003,0 1px 1px #00000024,0 1px 3px #0000001f}.remove-button{background-color:var(--mat-sys-error-container);box-shadow:0 2px 1px -1px #0003,0 1px 1px #00000024,0 1px 3px #0000001f}.title{display:flex;flex-direction:row;gap:8px;align-items:center}\n"] }]
|
|
780
|
+
], template: "@if (formArray && leaf_) {\n @for (control of formArray.controls; track $index) {\n <div class=\"field-item\">\n <nff-anon-leaf-renderer\n [AnonLeaf]=\"leaf_\" [control]=\"control\"\n [removable]=\"minItems == null || formArray.length > minItems\"\n [index]=\"$index\"\n [editable]=\"editable\"\n [label]=\"leaf_.label ?? leaf_.name\"\n (remove)=\"removeItem($index)\"\n />\n @if (editable) {\n <div class=\"actions\">\n @if (formArray.length > minItems) {\n <button class=\"remove-button small-icon-button\" matIconButton [attr.aria-label]=\"'Remove ' + (leaf_.label ?? leaf_.name) + ' item'\" (click)=\"removeItem($index)\">\n <mat-icon matPrefix>delete</mat-icon>\n </button>\n }\n @if ($last) {\n <button class=\"add-button small-icon-button\" matIconButton [attr.aria-label]=\"'Add ' + (leaf_.label ?? leaf_.name) + ' item'\" (click)=\"addItem()\">\n <mat-icon matPrefix>add</mat-icon>\n </button>\n }\n <div class=\"actions-spacer\"></div>\n </div>\n }\n </div>\n }\n}\n", styles: [":host{display:flex;flex-direction:row;flex-wrap:wrap;gap:16px}:host(.stacked){flex-basis:100%}.fields{position:relative;display:flex;flex-wrap:wrap;gap:8px;align-items:baseline}.field-item{display:flex;flex-direction:row;gap:4px;align-items:center;flex:1 1 0}.actions{z-index:100;display:flex;flex-direction:column;align-items:flex-end;gap:4px;height:100%}.actions-spacer{height:20px}nff-anon-leaf-renderer{width:100%}.add-button{background-color:var(--mat-sys-primary-container);box-shadow:0 2px 1px -1px #0003,0 1px 1px #00000024,0 1px 3px #0000001f}.remove-button{background-color:var(--mat-sys-error-container);box-shadow:0 2px 1px -1px #0003,0 1px 1px #00000024,0 1px 3px #0000001f}.title{display:flex;flex-direction:row;gap:8px;align-items:center}\n"] }]
|
|
437
781
|
}], propDecorators: { leaf_: [{
|
|
438
782
|
type: Input
|
|
439
783
|
}], initialValue: [{
|
|
@@ -451,37 +795,6 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.9", ngImpor
|
|
|
451
795
|
args: ['class.stacked']
|
|
452
796
|
}] } });
|
|
453
797
|
|
|
454
|
-
function nodeGroupChildrenList(schema) {
|
|
455
|
-
const children = schema.children ?? {};
|
|
456
|
-
return Object.entries(children).map(([key, value]) => ({
|
|
457
|
-
key,
|
|
458
|
-
value: value,
|
|
459
|
-
}));
|
|
460
|
-
}
|
|
461
|
-
function nodeGroupChildrenLeafs(schema) {
|
|
462
|
-
const children = schema.children ?? {};
|
|
463
|
-
return Object.entries(children).reduce((acc, [key, value]) => {
|
|
464
|
-
if (value.kind === 'leaf') {
|
|
465
|
-
acc.push({ key, value: value });
|
|
466
|
-
}
|
|
467
|
-
return acc;
|
|
468
|
-
}, []);
|
|
469
|
-
}
|
|
470
|
-
function removeNullAndEmptyArrays(value) {
|
|
471
|
-
if (Array.isArray(value)) {
|
|
472
|
-
const cleanedArray = value
|
|
473
|
-
.map(removeNullAndEmptyArrays)
|
|
474
|
-
.filter(v => v != null && (typeof v !== 'object' || Object.keys(v).length > 0));
|
|
475
|
-
return cleanedArray.length > 0 ? cleanedArray : undefined;
|
|
476
|
-
}
|
|
477
|
-
if (value && typeof value === 'object') {
|
|
478
|
-
const cleanedObject = Object.fromEntries(Object.entries(value)
|
|
479
|
-
.map(([k, v]) => [k, removeNullAndEmptyArrays(v)])
|
|
480
|
-
.filter(([_, v]) => v != null && (typeof v !== 'object' || Object.keys(v).length > 0)));
|
|
481
|
-
return Object.keys(cleanedObject).length > 0 ? cleanedObject : undefined;
|
|
482
|
-
}
|
|
483
|
-
return value != null ? value : undefined;
|
|
484
|
-
}
|
|
485
798
|
function asFormControl(control) {
|
|
486
799
|
return control;
|
|
487
800
|
}
|
|
@@ -492,25 +805,131 @@ function asFormGroup(control) {
|
|
|
492
805
|
return control;
|
|
493
806
|
}
|
|
494
807
|
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
808
|
+
/**
|
|
809
|
+
* Renders a {@link NodeMap}: an open, arbitrary-keyed record. Each entry is a row
|
|
810
|
+
* with an editable key and the shared value schema's control; the user can add,
|
|
811
|
+
* remove, and rename entries. The map's control is a `FormGroup` whose control
|
|
812
|
+
* names are the entry keys, so the key is edited as a *rename* (remove + re-add
|
|
813
|
+
* the same control) committed on blur — see {@link renameEntry}.
|
|
814
|
+
*/
|
|
815
|
+
class NodeMapRendererComponent {
|
|
816
|
+
nodeMap;
|
|
498
817
|
formGroup = new FormGroup({});
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
818
|
+
editable = true;
|
|
819
|
+
/** Ordered view of entry keys, kept stable across renames (`addControl` appends). */
|
|
820
|
+
entryKeys = [];
|
|
821
|
+
ngOnChanges(changes) {
|
|
822
|
+
// Re-sync whenever the bound group changes: a host may rebind the renderer
|
|
823
|
+
// to another map (e.g. the tree editor swapping documents) while this
|
|
824
|
+
// component instance survives.
|
|
825
|
+
if (changes['formGroup'])
|
|
826
|
+
this.entryKeys = Object.keys(this.formGroup.controls);
|
|
827
|
+
}
|
|
828
|
+
/** The value schema as a leaf (used when `value.kind === 'leaf'`). */
|
|
829
|
+
get valueLeaf() {
|
|
830
|
+
return this.nodeMap.value;
|
|
831
|
+
}
|
|
832
|
+
/** The value schema as a group (used when `value.kind === 'nodeGroup'`). */
|
|
833
|
+
get valueGroup() {
|
|
834
|
+
return this.nodeMap.value;
|
|
835
|
+
}
|
|
836
|
+
get atMax() {
|
|
837
|
+
return this.nodeMap.maxEntries != null && this.entryKeys.length >= this.nodeMap.maxEntries;
|
|
838
|
+
}
|
|
839
|
+
get atMin() {
|
|
840
|
+
return this.nodeMap.minEntries != null && this.entryKeys.length <= this.nodeMap.minEntries;
|
|
841
|
+
}
|
|
842
|
+
/** Append a new entry under a unique placeholder key. Delegates to {@link addMapEntry}. */
|
|
843
|
+
addEntry() {
|
|
844
|
+
const key = addMapEntry(this.formGroup, this.nodeMap);
|
|
845
|
+
if (key != null)
|
|
846
|
+
this.entryKeys = [...this.entryKeys, key];
|
|
847
|
+
}
|
|
848
|
+
/** Drop an entry (its control leaves the group, so it drops from the value). Delegates to {@link removeMapEntry}. */
|
|
849
|
+
removeEntry(key) {
|
|
850
|
+
if (removeMapEntry(this.formGroup, this.nodeMap, key)) {
|
|
851
|
+
this.entryKeys = this.entryKeys.filter((k) => k !== key);
|
|
852
|
+
}
|
|
853
|
+
}
|
|
854
|
+
/**
|
|
855
|
+
* Commit an edited key by renaming its control once (the value is preserved).
|
|
856
|
+
* An empty, unchanged, duplicate, or `keyPattern`-violating key is a no-op,
|
|
857
|
+
* leaving the entry under its current name. Delegates to {@link renameMapEntry}.
|
|
858
|
+
*/
|
|
859
|
+
renameEntry(oldKey, rawKey) {
|
|
860
|
+
if (renameMapEntry(this.formGroup, this.nodeMap, oldKey, rawKey)) {
|
|
861
|
+
const newKey = rawKey.trim();
|
|
862
|
+
this.entryKeys = this.entryKeys.map((k) => (k === oldKey ? newKey : k));
|
|
863
|
+
}
|
|
864
|
+
}
|
|
865
|
+
asFormControl = asFormControl;
|
|
866
|
+
asFormGroup = asFormGroup;
|
|
867
|
+
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.9", ngImport: i0, type: NodeMapRendererComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
|
|
868
|
+
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "20.3.9", type: NodeMapRendererComponent, isStandalone: true, selector: "nff-node-map-renderer", inputs: { nodeMap: "nodeMap", formGroup: "formGroup", editable: "editable" }, usesOnChanges: true, ngImport: i0, template: "@if (formGroup && nodeMap) {\n <div class=\"map-node\">\n @for (key of entryKeys; track key) {\n <div class=\"map-entry\">\n <mat-form-field class=\"key-field\" [appearance]=\"editable ? 'fill' : 'outline'\">\n <mat-label>{{ nodeMap.keyLabel ?? 'Key' }}</mat-label>\n <!-- On a rejected rename (empty, duplicate, keyPattern violation) the\n input snaps back to the committed key, so the display never\n disagrees with the key getRawValue() will emit. -->\n <input\n matInput\n #keyInput\n [readonly]=\"!editable\"\n [value]=\"key\"\n (change)=\"renameEntry(key, keyInput.value); keyInput.value = key\"\n >\n </mat-form-field>\n\n <!-- Index access, not .get(): entry keys are arbitrary runtime data and\n .get() would split a key like 'web.example.com' into a dotted path. -->\n <div class=\"value-field\">\n @if (nodeMap.value.kind === 'leaf') {\n <nff-leaf-renderer\n [leaf_]=\"valueLeaf\"\n [control]=\"asFormControl(formGroup.controls[key])\"\n [editable]=\"editable\"\n />\n } @else if (nodeMap.value.kind === 'nodeGroup') {\n <nff-dynamic-recursive-form\n [schema]=\"valueGroup\"\n [formGroup]=\"asFormGroup(formGroup.controls[key])\"\n [editable]=\"editable\"\n />\n }\n </div>\n\n @if (editable && !atMin) {\n <button class=\"remove-button small-icon-button\" matIconButton [attr.aria-label]=\"'Remove ' + key + ' entry'\" (click)=\"removeEntry(key)\">\n <mat-icon>delete</mat-icon>\n </button>\n }\n </div>\n }\n @if (editable && !atMax) {\n <button class=\"add-button small-icon-button\" matIconButton aria-label=\"Add entry\" (click)=\"addEntry()\">\n <mat-icon>add</mat-icon>\n </button>\n }\n </div>\n}\n", styles: [":host{display:flex;flex:1 1 100%}.map-node{display:flex;flex-direction:column;gap:8px;width:100%}.map-entry{display:flex;flex-direction:row;gap:8px;align-items:flex-start}.key-field{flex:0 0 30%;min-width:120px}.value-field{display:flex;flex:1 1 0;min-width:0}.add-button{align-self:flex-start;background-color:var(--mat-sys-primary-container);box-shadow:0 2px 1px -1px #0003,0 1px 1px #00000024,0 1px 3px #0000001f}.remove-button{background-color:var(--mat-sys-error-container);box-shadow:0 2px 1px -1px #0003,0 1px 1px #00000024,0 1px 3px #0000001f}\n"], dependencies: [{ kind: "ngmodule", type: i0.forwardRef(() => MatButtonModule) }, { kind: "component", type: i0.forwardRef(() => i2$1.MatIconButton), selector: "button[mat-icon-button], a[mat-icon-button], button[matIconButton], a[matIconButton]", exportAs: ["matButton", "matAnchor"] }, { kind: "ngmodule", type: i0.forwardRef(() => MatIconModule) }, { kind: "component", type: i0.forwardRef(() => i1$1.MatIcon), selector: "mat-icon", inputs: ["color", "inline", "svgIcon", "fontSet", "fontIcon"], exportAs: ["matIcon"] }, { kind: "ngmodule", type: i0.forwardRef(() => MatFormFieldModule) }, { kind: "component", type: i0.forwardRef(() => i2.MatFormField), selector: "mat-form-field", inputs: ["hideRequiredMarker", "color", "floatLabel", "appearance", "subscriptSizing", "hintLabel"], exportAs: ["matFormField"] }, { kind: "directive", type: i0.forwardRef(() => i2.MatLabel), selector: "mat-label" }, { kind: "ngmodule", type: i0.forwardRef(() => MatInputModule) }, { kind: "directive", type: i0.forwardRef(() => 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: "component", type: i0.forwardRef(() => LeafRendererComponent), selector: "nff-leaf-renderer", inputs: ["leaf_", "control", "removable", "editable", "autofocus"], outputs: ["remove"] }, { kind: "component", type: i0.forwardRef(() => DynamicRecursiveFormComponent), selector: "nff-dynamic-recursive-form", inputs: ["schema", "initialValue", "formGroup", "index", "removable", "title", "editable", "addButtonCallback", "focusLeaf"], outputs: ["remove", "editableChange"] }] });
|
|
869
|
+
}
|
|
870
|
+
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.9", ngImport: i0, type: NodeMapRendererComponent, decorators: [{
|
|
871
|
+
type: Component,
|
|
872
|
+
args: [{ selector: 'nff-node-map-renderer', standalone: true, imports: [
|
|
873
|
+
MatButtonModule,
|
|
874
|
+
MatIconModule,
|
|
875
|
+
MatFormFieldModule,
|
|
876
|
+
MatInputModule,
|
|
877
|
+
LeafRendererComponent,
|
|
878
|
+
// node-map-renderer and dynamic-recursive-form import each other (a map value
|
|
879
|
+
// may be a group); forwardRef defers the reference to break the cycle.
|
|
880
|
+
forwardRef(() => DynamicRecursiveFormComponent),
|
|
881
|
+
], template: "@if (formGroup && nodeMap) {\n <div class=\"map-node\">\n @for (key of entryKeys; track key) {\n <div class=\"map-entry\">\n <mat-form-field class=\"key-field\" [appearance]=\"editable ? 'fill' : 'outline'\">\n <mat-label>{{ nodeMap.keyLabel ?? 'Key' }}</mat-label>\n <!-- On a rejected rename (empty, duplicate, keyPattern violation) the\n input snaps back to the committed key, so the display never\n disagrees with the key getRawValue() will emit. -->\n <input\n matInput\n #keyInput\n [readonly]=\"!editable\"\n [value]=\"key\"\n (change)=\"renameEntry(key, keyInput.value); keyInput.value = key\"\n >\n </mat-form-field>\n\n <!-- Index access, not .get(): entry keys are arbitrary runtime data and\n .get() would split a key like 'web.example.com' into a dotted path. -->\n <div class=\"value-field\">\n @if (nodeMap.value.kind === 'leaf') {\n <nff-leaf-renderer\n [leaf_]=\"valueLeaf\"\n [control]=\"asFormControl(formGroup.controls[key])\"\n [editable]=\"editable\"\n />\n } @else if (nodeMap.value.kind === 'nodeGroup') {\n <nff-dynamic-recursive-form\n [schema]=\"valueGroup\"\n [formGroup]=\"asFormGroup(formGroup.controls[key])\"\n [editable]=\"editable\"\n />\n }\n </div>\n\n @if (editable && !atMin) {\n <button class=\"remove-button small-icon-button\" matIconButton [attr.aria-label]=\"'Remove ' + key + ' entry'\" (click)=\"removeEntry(key)\">\n <mat-icon>delete</mat-icon>\n </button>\n }\n </div>\n }\n @if (editable && !atMax) {\n <button class=\"add-button small-icon-button\" matIconButton aria-label=\"Add entry\" (click)=\"addEntry()\">\n <mat-icon>add</mat-icon>\n </button>\n }\n </div>\n}\n", styles: [":host{display:flex;flex:1 1 100%}.map-node{display:flex;flex-direction:column;gap:8px;width:100%}.map-entry{display:flex;flex-direction:row;gap:8px;align-items:flex-start}.key-field{flex:0 0 30%;min-width:120px}.value-field{display:flex;flex:1 1 0;min-width:0}.add-button{align-self:flex-start;background-color:var(--mat-sys-primary-container);box-shadow:0 2px 1px -1px #0003,0 1px 1px #00000024,0 1px 3px #0000001f}.remove-button{background-color:var(--mat-sys-error-container);box-shadow:0 2px 1px -1px #0003,0 1px 1px #00000024,0 1px 3px #0000001f}\n"] }]
|
|
882
|
+
}], propDecorators: { nodeMap: [{
|
|
883
|
+
type: Input
|
|
884
|
+
}], formGroup: [{
|
|
885
|
+
type: Input
|
|
886
|
+
}], editable: [{
|
|
887
|
+
type: Input
|
|
888
|
+
}] } });
|
|
889
|
+
|
|
890
|
+
class DynamicRecursiveFormComponent {
|
|
891
|
+
/** The form-description schema to render (a root or nested `NodeGroup`). */
|
|
892
|
+
schema = input.required(...(ngDevMode ? [{ debugName: "schema" }] : []));
|
|
893
|
+
/** Optional value object to seed the form; keyed by the schema's `children` keys. */
|
|
894
|
+
initialValue = input(null, ...(ngDevMode ? [{ debugName: "initialValue" }] : []));
|
|
895
|
+
/** The reactive group this form binds to. Defaults to an empty group. */
|
|
896
|
+
formGroup = input(new FormGroup({}), ...(ngDevMode ? [{ debugName: "formGroup" }] : []));
|
|
897
|
+
/** Index of this form within a parent list, used by `addButtonCallback`. */
|
|
898
|
+
index = input(null, ...(ngDevMode ? [{ debugName: "index" }] : []));
|
|
899
|
+
/** Whether this form may be removed from a parent list (shows a remove control). */
|
|
900
|
+
removable = input(false, ...(ngDevMode ? [{ debugName: "removable" }] : []));
|
|
901
|
+
/** Emitted when the user removes this form from a parent list. */
|
|
902
|
+
remove = output();
|
|
903
|
+
/** Card/section title; falls back to the schema label or name. */
|
|
904
|
+
title = input(...(ngDevMode ? [undefined, { debugName: "title" }] : []));
|
|
905
|
+
/** Whether fields accept input. Two-way: also toggled by the built-in edit control. */
|
|
906
|
+
editable = model(false, ...(ngDevMode ? [{ debugName: "editable" }] : []));
|
|
907
|
+
/** Invoked with {@link index} to append a new sibling form to a parent list. */
|
|
908
|
+
addButtonCallback = input(null, ...(ngDevMode ? [{ debugName: "addButtonCallback" }] : []));
|
|
909
|
+
/**
|
|
910
|
+
* Key of a presence leaf whose field should grab focus when it renders — lets
|
|
911
|
+
* a host that added the control itself (e.g. the tree editor's optionals
|
|
912
|
+
* menu) hand focus to the new field, like the form's own add button does.
|
|
913
|
+
*/
|
|
914
|
+
focusLeaf = input(null, ...(ngDevMode ? [{ debugName: "focusLeaf" }] : []));
|
|
915
|
+
/** True when the schema is a root group (rendered flat, without a wrapping card). */
|
|
916
|
+
root = computed(() => this.schema().root ?? false, ...(ngDevMode ? [{ debugName: "root" }] : []));
|
|
506
917
|
ngOnInit() {
|
|
507
|
-
|
|
508
|
-
if (
|
|
509
|
-
this.formGroup
|
|
918
|
+
const initial = this.initialValue();
|
|
919
|
+
if (initial) {
|
|
920
|
+
const group = this.formGroup();
|
|
921
|
+
// Presence keys carried by the initial value need controls first:
|
|
922
|
+
// patchValue silently skips keys that have none, dropping the data.
|
|
923
|
+
for (const [key, child] of Object.entries(this.schema().children ?? {})) {
|
|
924
|
+
if ('presence' in child && child.presence && key in initial && !group.get(key)) {
|
|
925
|
+
group.addControl(key, buildControl(child, initial[key]));
|
|
926
|
+
}
|
|
927
|
+
}
|
|
928
|
+
group.patchValue(initial);
|
|
510
929
|
}
|
|
511
930
|
}
|
|
512
931
|
get nodeGroupChildrenList() {
|
|
513
|
-
const children = this.schema.children ?? {};
|
|
932
|
+
const children = this.schema().children ?? {};
|
|
514
933
|
return Object.entries(children).map(([key, value]) => ({
|
|
515
934
|
key,
|
|
516
935
|
value: value,
|
|
@@ -519,128 +938,192 @@ class DynamicRecursiveFormComponent {
|
|
|
519
938
|
emitRemoveEvent() {
|
|
520
939
|
this.remove.emit();
|
|
521
940
|
}
|
|
941
|
+
/** The key of the presence leaf the user just enabled; its field grabs focus when rendered. */
|
|
942
|
+
presenceFocusKey = null;
|
|
522
943
|
/**
|
|
523
|
-
* Add or remove a presence
|
|
524
|
-
* group
|
|
944
|
+
* Add or remove a presence node's control on this form — any presence kind:
|
|
945
|
+
* group, leaf, map, or choice. Removing it drops the key from `form.value`;
|
|
946
|
+
* adding it builds the control fresh from its schema (nested presence
|
|
947
|
+
* children start absent).
|
|
525
948
|
*/
|
|
526
|
-
|
|
949
|
+
toggleNodePresence(key, schema, present) {
|
|
950
|
+
const group = this.formGroup();
|
|
527
951
|
if (present) {
|
|
528
|
-
if (!
|
|
529
|
-
|
|
952
|
+
if (!group.get(key)) {
|
|
953
|
+
group.addControl(key, buildControl(schema));
|
|
530
954
|
}
|
|
531
955
|
}
|
|
532
|
-
else if (
|
|
533
|
-
|
|
956
|
+
else if (group.get(key)) {
|
|
957
|
+
group.removeControl(key);
|
|
958
|
+
}
|
|
959
|
+
}
|
|
960
|
+
/**
|
|
961
|
+
* {@link toggleNodePresence} for a presence leaf, additionally focusing the
|
|
962
|
+
* rendered field when the toggle just created it.
|
|
963
|
+
*/
|
|
964
|
+
toggleLeafPresence(key, schema, present) {
|
|
965
|
+
const had = !!this.formGroup().get(key);
|
|
966
|
+
this.toggleNodePresence(key, schema, present);
|
|
967
|
+
if (present && !had) {
|
|
968
|
+
this.presenceFocusKey = key;
|
|
969
|
+
// Retire the request once the field has rendered and taken focus, so a
|
|
970
|
+
// later re-creation of the same renderer cannot steal focus again.
|
|
971
|
+
setTimeout(() => {
|
|
972
|
+
if (this.presenceFocusKey === key)
|
|
973
|
+
this.presenceFocusKey = null;
|
|
974
|
+
});
|
|
534
975
|
}
|
|
976
|
+
if (!present && this.presenceFocusKey === key)
|
|
977
|
+
this.presenceFocusKey = null;
|
|
535
978
|
}
|
|
536
|
-
CASE_KEY = CASE_KEY;
|
|
537
979
|
objectKeys(obj) {
|
|
538
980
|
return Object.keys(obj);
|
|
539
981
|
}
|
|
540
982
|
/** The active case name of a choice control, or null if none is selected. */
|
|
541
983
|
activeCase(key) {
|
|
542
|
-
return this.formGroup.get(key)?.get(CASE_KEY)?.value ?? null;
|
|
984
|
+
return this.formGroup().get(key)?.get(CASE_KEY)?.value ?? null;
|
|
543
985
|
}
|
|
544
986
|
/** A synthetic flattened NodeGroup used to render the active case's fields against the choice's FormGroup. */
|
|
545
987
|
caseAsGroup(choice, caseName) {
|
|
546
988
|
return {
|
|
547
989
|
kind: 'nodeGroup',
|
|
548
990
|
name: choice.name,
|
|
549
|
-
children: choice.cases[caseName]
|
|
991
|
+
children: choice.cases[caseName] ? caseFields(choice.cases[caseName]) : {},
|
|
550
992
|
appearance: { flatten: true },
|
|
551
993
|
};
|
|
552
994
|
}
|
|
553
|
-
/**
|
|
995
|
+
/** The display label for a case: the schema's `caseLabels` entry, else the case name. */
|
|
996
|
+
caseLabel(choice, caseName) {
|
|
997
|
+
return choice.caseLabels?.[caseName] ?? caseName;
|
|
998
|
+
}
|
|
999
|
+
/**
|
|
1000
|
+
* A copy of a group flagged to render its fields inline (no inner panel). Used
|
|
1001
|
+
* for a presence group's body, whose container is the presence panel itself, so
|
|
1002
|
+
* the group's own section panel would be a redundant second box.
|
|
1003
|
+
*/
|
|
1004
|
+
flatGroup(group) {
|
|
1005
|
+
return { ...group, appearance: { ...group.appearance, flatten: true } };
|
|
1006
|
+
}
|
|
1007
|
+
/** Swap a choice's field controls when the selected case changes. Delegates to {@link switchChoiceCase}. */
|
|
554
1008
|
switchCase(key, choice, caseName) {
|
|
555
|
-
|
|
556
|
-
for (const name of Object.keys(group.controls)) {
|
|
557
|
-
if (name !== CASE_KEY)
|
|
558
|
-
group.removeControl(name);
|
|
559
|
-
}
|
|
560
|
-
const caseChildren = choice.cases[caseName] ?? {};
|
|
561
|
-
for (const name in caseChildren) {
|
|
562
|
-
group.addControl(name, buildControl(caseChildren[name]));
|
|
563
|
-
}
|
|
1009
|
+
switchChoiceCase(this.formGroup().get(key), choice, caseName);
|
|
564
1010
|
}
|
|
565
1011
|
asFormGroup = asFormGroup;
|
|
566
1012
|
asFormArray = asFormArray;
|
|
567
1013
|
asFormControl = asFormControl;
|
|
568
1014
|
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.9", ngImport: i0, type: DynamicRecursiveFormComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
|
|
569
|
-
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "20.3.9", type: DynamicRecursiveFormComponent, isStandalone: true, selector: "nff-dynamic-recursive-form", inputs: { schema: "schema", initialValue: "initialValue", formGroup: "formGroup", index: "index", removable: "removable", title: "title", editable: "editable", addButtonCallback: "addButtonCallback" }, outputs: { remove: "remove" }, ngImport: i0, template: "@if (!root) {\n <ng-template #formRender>\n <div class=\"form-content\" [formGroup]=\"formGroup\">\n <div class=\"leafs-container\">\n <div class=\"fields\">\n @for (childItem of nodeGroupChildrenList; track $index) {\n @let child = childItem.value;\n @if (child.kind == 'leaf') {\n <nff-leaf-renderer\n [leaf_]=child\n [control]=asFormControl(formGroup.get(child.name))\n [removable]=false\n [editable]=editable\n [initialValue]=\"formGroup.get(child.name)?.value\"\n />\n }\n }\n @for (childItem of nodeGroupChildrenList; track $index) {\n @let child = childItem.value;\n @if (child.kind == 'leafList') {\n <nff-leaf-list-renderer\n [leaf_]=\"child\"\n [editable]=editable\n [formArray]=\"asFormArray(formGroup.get(child.name))\"\n [initialValue]=\"formGroup.get(child.name)?.value\"\n />\n }\n }\n </div>\n </div>\n @for (childItem of nodeGroupChildrenList; track $index) {\n @let child = childItem.value;\n @if (child.kind == 'nodeGroup' && child.presence) {\n <div class=\"presence-group\">\n <mat-checkbox\n [checked]=\"!!formGroup.get(childItem.key)\"\n [disabled]=\"!editable\"\n (change)=\"togglePresence(childItem.key, child, $event.checked)\"\n >{{ child.label ?? child.name }}</mat-checkbox>\n @if (formGroup.get(childItem.key)) {\n <nff-dynamic-recursive-form\n [schema]=\"child\"\n [formGroup]=\"asFormGroup(formGroup.get(childItem.key))\"\n [editable]=\"editable\"\n />\n }\n </div>\n }\n @else if (child.kind == 'nodeGroup') {\n <nff-dynamic-recursive-form\n [schema]=\"child\"\n [formGroup]=\"asFormGroup(formGroup.get(child.name))\"\n [initialValue]=\"formGroup.get(child.name)?.value\"\n [editable]=\"editable\"\n />\n }\n @else if (child.kind == 'choice') {\n @let choiceGroup = asFormGroup(formGroup.get(childItem.key));\n @let selectedCase = activeCase(childItem.key);\n <div class=\"choice-group\" [formGroup]=\"choiceGroup\">\n <mat-form-field [appearance]=\"editable ? 'fill' : 'outline'\">\n <mat-label>{{ child.label ?? child.name }}</mat-label>\n <mat-select\n [formControlName]=\"CASE_KEY\"\n (selectionChange)=\"switchCase(childItem.key, child, $event.value)\"\n >\n @for (caseName of objectKeys(child.cases); track caseName) {\n <mat-option [value]=\"caseName\">{{ caseName }}</mat-option>\n }\n </mat-select>\n </mat-form-field>\n @if (selectedCase) {\n <nff-dynamic-recursive-form\n [schema]=\"caseAsGroup(child, selectedCase)\"\n [formGroup]=\"choiceGroup\"\n [editable]=\"editable\"\n />\n }\n </div>\n }\n }\n\n @for (childItem of nodeGroupChildrenList; track $index) {\n @let child = childItem.value;\n @if (child.kind == 'nodeGroupList') {\n <nff-node-group-list-renderer\n [nodeGroupList]=\"child\"\n [formArray]=\"asFormArray(formGroup.get(child.name))\"\n [initialValue]=\"formGroup.get(child.name)?.value\"\n [editable]=\"editable\"\n />\n }\n }\n </div>\n </ng-template>\n @if (schema.appearance?.flatten) {\n <div class=\"flattened-form\">\n <ng-container *ngTemplateOutlet=\"formRender\"></ng-container>\n <div class=\"flattened-actions\">\n @if (editable && index != null && addButtonCallback != null) {\n <button matIconButton matTooltip=\"+ Add new {{ schema.label ?? schema.name }}\" class=\"add-button small-icon-button\" (click)=\"addButtonCallback(index)\">\n <mat-icon>add</mat-icon>\n </button>\n }\n @if (editable && removable) {\n <button matIconButton class=\"remove-button small-icon-button\" (click)=\"emitRemoveEvent(); $event.stopPropagation()\">\n <mat-icon>delete</mat-icon>\n </button>\n }\n </div>\n </div>\n }\n @else {\n <mat-card appearance=\"outlined\" [class]=\"{ 'mat-card-no-broder': schema.appearance?.noBorder }\">\n <mat-card-header>\n <div class=\"card-actions\">\n <button matIconButton class=\"small-icon-button\" [class]=\"{ 'edit-icon': !editable}\" (click)=\"editable = !editable\">\n <mat-icon>edit</mat-icon>\n </button>\n @if (editable && index != null && addButtonCallback != null) {\n <button matIconButton matTooltip=\"+ Add new {{ schema.label ?? schema.name }}\" class=\"add-button small-icon-button\" (click)=\"addButtonCallback(index)\">\n <mat-icon>add</mat-icon>\n </button>\n }\n @if (editable && removable) {\n <button matIconButton class=\"remove-button small-icon-button\" (click)=\"emitRemoveEvent(); $event.stopPropagation()\">\n <mat-icon>delete</mat-icon>\n </button>\n }\n </div>\n <mat-card-title class=\"config-form-subsection-card\">\n {{ title ?? schema.label ?? schema.name }}\n </mat-card-title>\n </mat-card-header>\n <mat-card-content>\n <ng-container *ngTemplateOutlet=\"formRender\"></ng-container>\n </mat-card-content>\n </mat-card>\n }\n} @else {\n <div class=\"form-content\" [formGroup]=\"formGroup\">\n <div class=\"leafs-container\">\n <div class=\"fields\">\n <button matIconButton class=\"small-icon-button\" [class]=\"{ 'edit-icon': !editable }\" (click)=\"editable = !editable\">\n <mat-icon>edit</mat-icon>\n </button>\n @for (childItem of nodeGroupChildrenList; track $index) {\n @let child = childItem.value;\n @if (child.kind == 'leaf') {\n <nff-leaf-renderer\n [leaf_]=child\n [control]=asFormControl(formGroup.get(child.name))\n [removable]=false\n [editable]=editable\n [initialValue]=\"formGroup.get(child.name)?.value\"\n />\n }\n }\n @for (childItem of nodeGroupChildrenList; track $index) {\n @let child = childItem.value;\n @if (child.kind == 'leafList') {\n <nff-leaf-list-renderer\n [leaf_]=\"child\"\n [editable]=editable\n [formArray]=\"asFormArray(formGroup.get(child.name))\"\n [initialValue]=\"formGroup.get(child.name)?.value\"\n />\n }\n }\n @for (childItem of nodeGroupChildrenList; track $index) {\n @let child = childItem.value;\n @if (child.kind == 'choice') {\n @let choiceGroup = asFormGroup(formGroup.get(childItem.key));\n @let selectedCase = activeCase(childItem.key);\n <div class=\"choice-group\" [formGroup]=\"choiceGroup\">\n <mat-form-field [appearance]=\"editable ? 'fill' : 'outline'\">\n <mat-label>{{ child.label ?? child.name }}</mat-label>\n <mat-select\n [formControlName]=\"CASE_KEY\"\n (selectionChange)=\"switchCase(childItem.key, child, $event.value)\"\n >\n @for (caseName of objectKeys(child.cases); track caseName) {\n <mat-option [value]=\"caseName\">{{ caseName }}</mat-option>\n }\n </mat-select>\n </mat-form-field>\n @if (selectedCase) {\n <nff-dynamic-recursive-form\n [schema]=\"caseAsGroup(child, selectedCase)\"\n [formGroup]=\"choiceGroup\"\n [editable]=\"editable\"\n />\n }\n </div>\n }\n }\n </div>\n </div>\n <mat-accordion multi>\n @for (childItem of nodeGroupChildrenList; track $index) {\n @let child = childItem.value;\n @if (child.kind == 'nodeGroupList') {\n <mat-expansion-panel togglePosition=\"before\">\n <mat-expansion-panel-header>\n <mat-panel-title>\n {{ title ?? child.label ?? child.name }}\n </mat-panel-title>\n </mat-expansion-panel-header>\n <nff-node-group-list-renderer\n [nodeGroupList]=\"child\"\n [formArray]=\"asFormArray(formGroup.get(child.name))\"\n [initialValue]=\"formGroup.get(child.name)?.value\"\n [editable]=\"editable\"\n />\n </mat-expansion-panel>\n }\n }\n @for (childItem of nodeGroupChildrenList; track $index) {\n @let child = childItem.value;\n @if (child.kind == 'nodeGroup' && child.presence) {\n <mat-expansion-panel togglePosition=\"before\" [expanded]=\"!!formGroup.get(childItem.key)\">\n <mat-expansion-panel-header>\n <mat-panel-title>\n <mat-checkbox\n [checked]=\"!!formGroup.get(childItem.key)\"\n [disabled]=\"!editable\"\n (click)=\"$event.stopPropagation()\"\n (change)=\"togglePresence(childItem.key, child, $event.checked)\"\n >{{ child.label ?? child.name }}</mat-checkbox>\n </mat-panel-title>\n </mat-expansion-panel-header>\n @if (formGroup.get(childItem.key)) {\n <nff-dynamic-recursive-form\n [schema]=\"child\"\n [formGroup]=\"asFormGroup(formGroup.get(childItem.key))\"\n [editable]=\"editable\"\n />\n }\n </mat-expansion-panel>\n }\n @else if (child.kind == 'nodeGroup') {\n <mat-expansion-panel togglePosition=\"before\" expanded=\"true\">\n <mat-expansion-panel-header >\n <mat-panel-title>\n {{ title ?? child.label ?? child.name }}\n </mat-panel-title>\n </mat-expansion-panel-header>\n <nff-dynamic-recursive-form\n [schema]=\"child\"\n [formGroup]=\"asFormGroup(formGroup.get(child.name))\"\n [initialValue]=\"formGroup.get(child.name)?.value\"\n [title]=\"child.name ?? 'General Settings'\"\n [editable]=\"editable\"\n />\n </mat-expansion-panel>\n }\n }\n </mat-accordion>\n </div>\n}\n", styles: [":host{display:flex;flex-direction:column;flex-wrap:wrap;width:100%;margin:4px 0}.presence-group,.choice-group{display:flex;flex-direction:column;gap:8px}.fields{display:flex;flex-direction:row;gap:8px;flex-wrap:wrap;align-items:flex-start;flex:1 1 0}nff-leaf-list-renderer{flex:1}.mat-expansion-panel-content,.mat-expansion-panel-content:hover{display:flex;flex-direction:row;gap:16px}.form-content{display:flex;flex-direction:column;flex-wrap:wrap;width:100%}.leafs-container{display:flex;flex-direction:column;gap:8px;flex:1 1 0}mat-panel-description{display:flex;flex-direction:row}.node-groups-container{display:flex;flex-direction:column;flex-basis:100%}.section-actions{display:flex;flex-direction:row;gap:8px;justify-content:flex-end;width:100%}mat-card{padding:0;width:100%}.mat-card-no-broder{border:0}mat-card-content{box-sizing:border-box;padding:16px}mat-card-header{display:flex;flex-direction:row;align-items:flex-start;padding:16px;gap:16px}.edit-icon{opacity:.2;animation:icon-released .5s}.edit-icon:hover{opacity:1;animation:icon-transition .5s}.card-actions{display:flex;flex-direction:row;align-items:center;gap:4px}.flattened-actions{display:flex;flex-direction:column;align-items:flex-start;align-self:flex-start;gap:4px}.add-button{background-color:var(--mat-sys-primary-container);box-shadow:0 2px 1px -1px #0003,0 1px 1px #00000024,0 1px 3px #0000001f}.remove-button{background-color:var(--mat-sys-error-container);box-shadow:0 2px 1px -1px #0003,0 1px 1px #00000024,0 1px 3px #0000001f}.flattened-form{display:flex;flex-direction:row;width:100%;gap:4px}.flattened-style-actions{display:flex;flex-direction:row;gap:8px;justify-content:flex-end;align-items:flex-start}@keyframes icon-hovered{0%{opacity:.4}}@keyframes icon-released{0%{opacity:1}to{opacity:.4}}\n"], dependencies: [{ kind: "component", type: DynamicRecursiveFormComponent, selector: "nff-dynamic-recursive-form", inputs: ["schema", "initialValue", "formGroup", "index", "removable", "title", "editable", "addButtonCallback"], outputs: ["remove"] }, { kind: "component", type: LeafRendererComponent, selector: "nff-leaf-renderer", inputs: ["leaf_", "control", "initialValue", "removable", "editable"], outputs: ["remove"] }, { kind: "component", type: NodeGroupListRendererComponent, selector: "nff-node-group-list-renderer", inputs: ["nodeGroupList", "initialValue", "formArray", "editable", "minItems", "maxItems"], outputs: ["message"] }, { kind: "component", type: LeafListRendererComponent, selector: "nff-leaf-list-renderer", inputs: ["leaf_", "initialValue", "formArray", "editable", "minItems"], outputs: ["message"] }, { kind: "ngmodule", type: ReactiveFormsModule }, { kind: "directive", type: i1.NgControlStatus, selector: "[formControlName],[ngModel],[formControl]" }, { kind: "directive", type: i1.NgControlStatusGroup, selector: "[formGroupName],[formArrayName],[ngModelGroup],[formGroup],form:not([ngNoForm]),[ngForm]" }, { kind: "directive", type: i1.FormGroupDirective, selector: "[formGroup]", inputs: ["formGroup"], outputs: ["ngSubmit"], exportAs: ["ngForm"] }, { kind: "directive", type: i1.FormControlName, selector: "[formControlName]", inputs: ["formControlName", "disabled", "ngModel"], outputs: ["ngModelChange"] }, { kind: "ngmodule", type: MatExpansionModule }, { kind: "directive", type: i2$1.MatAccordion, selector: "mat-accordion", inputs: ["hideToggle", "displayMode", "togglePosition"], exportAs: ["matAccordion"] }, { kind: "component", type: i2$1.MatExpansionPanel, selector: "mat-expansion-panel", inputs: ["hideToggle", "togglePosition"], outputs: ["afterExpand", "afterCollapse"], exportAs: ["matExpansionPanel"] }, { kind: "component", type: i2$1.MatExpansionPanelHeader, selector: "mat-expansion-panel-header", inputs: ["expandedHeight", "collapsedHeight", "tabIndex"] }, { kind: "directive", type: i2$1.MatExpansionPanelTitle, selector: "mat-panel-title" }, { kind: "ngmodule", type: MatIconModule }, { kind: "component", type: i1$1.MatIcon, selector: "mat-icon", inputs: ["color", "inline", "svgIcon", "fontSet", "fontIcon"], exportAs: ["matIcon"] }, { kind: "ngmodule", type: MatButtonModule }, { kind: "component", type: i3$2.MatIconButton, selector: "button[mat-icon-button], a[mat-icon-button], button[matIconButton], a[matIconButton]", exportAs: ["matButton", "matAnchor"] }, { kind: "directive", type: NgTemplateOutlet, selector: "[ngTemplateOutlet]", inputs: ["ngTemplateOutletContext", "ngTemplateOutlet", "ngTemplateOutletInjector"] }, { kind: "ngmodule", type: MatCardModule }, { kind: "component", type: i5.MatCard, selector: "mat-card", inputs: ["appearance"], exportAs: ["matCard"] }, { kind: "directive", type: i5.MatCardContent, selector: "mat-card-content" }, { kind: "component", type: i5.MatCardHeader, selector: "mat-card-header" }, { kind: "directive", type: i5.MatCardTitle, selector: "mat-card-title, [mat-card-title], [matCardTitle]" }, { kind: "ngmodule", type: MatCheckboxModule }, { kind: "component", type: i4.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: MatFormFieldModule }, { kind: "component", type: i2.MatFormField, selector: "mat-form-field", inputs: ["hideRequiredMarker", "color", "floatLabel", "appearance", "subscriptSizing", "hintLabel"], exportAs: ["matFormField"] }, { kind: "directive", type: i2.MatLabel, selector: "mat-label" }, { kind: "ngmodule", type: MatSelectModule }, { kind: "component", type: i3.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: i3.MatOption, selector: "mat-option", inputs: ["value", "id", "disabled"], outputs: ["onSelectionChange"], exportAs: ["matOption"] }, { kind: "directive", type: MatTooltip, selector: "[matTooltip]", inputs: ["matTooltipPosition", "matTooltipPositionAtOrigin", "matTooltipDisabled", "matTooltipShowDelay", "matTooltipHideDelay", "matTooltipTouchGestures", "matTooltip", "matTooltipClass"], exportAs: ["matTooltip"] }] });
|
|
1015
|
+
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "20.3.9", type: DynamicRecursiveFormComponent, isStandalone: true, selector: "nff-dynamic-recursive-form", inputs: { schema: { classPropertyName: "schema", publicName: "schema", isSignal: true, isRequired: true, transformFunction: null }, initialValue: { classPropertyName: "initialValue", publicName: "initialValue", isSignal: true, isRequired: false, transformFunction: null }, formGroup: { classPropertyName: "formGroup", publicName: "formGroup", isSignal: true, isRequired: false, transformFunction: null }, index: { classPropertyName: "index", publicName: "index", isSignal: true, isRequired: false, transformFunction: null }, removable: { classPropertyName: "removable", publicName: "removable", isSignal: true, isRequired: false, transformFunction: null }, title: { classPropertyName: "title", publicName: "title", isSignal: true, isRequired: false, transformFunction: null }, editable: { classPropertyName: "editable", publicName: "editable", isSignal: true, isRequired: false, transformFunction: null }, addButtonCallback: { classPropertyName: "addButtonCallback", publicName: "addButtonCallback", isSignal: true, isRequired: false, transformFunction: null }, focusLeaf: { classPropertyName: "focusLeaf", publicName: "focusLeaf", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { remove: "remove", editable: "editableChange" }, ngImport: i0, template: "@if (!root()) {\n <ng-template #formRender>\n <div class=\"form-content\" [formGroup]=\"formGroup()\">\n <div class=\"leafs-container\">\n <div class=\"fields\">\n @for (childItem of nodeGroupChildrenList; track $index) {\n @let child = childItem.value;\n @if (child.kind == 'leaf' && !child.presence) {\n <nff-leaf-renderer\n [leaf_]=child\n [control]=asFormControl(formGroup().get(childItem.key))\n [removable]=false\n [editable]=\"editable()\"\n />\n }\n }\n @for (childItem of nodeGroupChildrenList; track $index) {\n @let child = childItem.value;\n @if (child.kind == 'leafList') {\n <nff-leaf-list-renderer\n [leaf_]=\"child\"\n [editable]=\"editable()\"\n [formArray]=\"asFormArray(formGroup().get(childItem.key))\"\n [initialValue]=\"formGroup().get(childItem.key)?.value\"\n />\n }\n }\n <!-- Presence leaves trail the regular fields so their add buttons don't\n interrupt the field flow; an enabled one keeps its trailing spot. -->\n @for (childItem of nodeGroupChildrenList; track $index) {\n @let child = childItem.value;\n @if (child.kind == 'leaf' && child.presence) {\n @if (formGroup().get(childItem.key)) {\n <nff-leaf-renderer\n [leaf_]=child\n [control]=asFormControl(formGroup().get(childItem.key))\n [removable]=true\n [editable]=\"editable()\"\n [autofocus]=\"presenceFocusKey === childItem.key || focusLeaf() === childItem.key\"\n (remove)=\"toggleLeafPresence(childItem.key, child, false)\"\n />\n } @else if (editable()) {\n <!-- Read-only mode renders nothing for an absent presence leaf:\n the key is simply absent, and add affordances are hidden\n like every other structural control. -->\n <button\n matButton=\"outlined\"\n class=\"presence-leaf-add\"\n (click)=\"toggleLeafPresence(childItem.key, child, true)\"\n >\n <mat-icon>add</mat-icon> Add {{ child.label ?? child.name }}\n </button>\n }\n }\n }\n </div>\n </div>\n @for (childItem of nodeGroupChildrenList; track $index) {\n @let child = childItem.value;\n @if (child.kind == 'nodeGroup' && child.presence) {\n <div class=\"presence-group\">\n <mat-checkbox\n [checked]=\"!!formGroup().get(childItem.key)\"\n [disabled]=\"!editable()\"\n (change)=\"toggleNodePresence(childItem.key, child, $event.checked)\"\n >{{ child.label ?? child.name }}</mat-checkbox>\n @if (formGroup().get(childItem.key)) {\n <nff-dynamic-recursive-form\n [schema]=\"flatGroup(child)\"\n [formGroup]=\"asFormGroup(formGroup().get(childItem.key))\"\n [editable]=\"editable()\"\n />\n }\n </div>\n }\n @else if (child.kind == 'nodeGroup') {\n <nff-dynamic-recursive-form\n [schema]=\"child\"\n [formGroup]=\"asFormGroup(formGroup().get(childItem.key))\"\n [initialValue]=\"formGroup().get(childItem.key)?.value\"\n [editable]=\"editable()\"\n />\n }\n @else if (child.kind == 'choice') {\n @let choiceGroup = asFormGroup(formGroup().get(childItem.key));\n @let selectedCase = activeCase(childItem.key);\n <mat-expansion-panel class=\"node-section\" [expanded]=\"child.presence ? !!choiceGroup : !child.appearance?.collapsed\" togglePosition=\"before\">\n <mat-expansion-panel-header>\n <mat-panel-title>\n @if (child.presence) {\n <mat-checkbox\n [checked]=\"!!choiceGroup\"\n [disabled]=\"!editable()\"\n (click)=\"$event.stopPropagation()\"\n (change)=\"toggleNodePresence(childItem.key, child, $event.checked)\"\n >{{ child.label ?? child.name }}</mat-checkbox>\n } @else {\n {{ child.label ?? child.name }}\n }\n </mat-panel-title>\n </mat-expansion-panel-header>\n @if (choiceGroup) {\n <div class=\"choice-group\">\n <mat-form-field [appearance]=\"editable() ? 'fill' : 'outline'\">\n <mat-label>Selected option</mat-label>\n <mat-select\n [value]=\"activeCase(childItem.key)\"\n (selectionChange)=\"switchCase(childItem.key, child, $event.value)\"\n >\n @for (caseName of objectKeys(child.cases); track caseName) {\n <mat-option [value]=\"caseName\">{{ caseLabel(child, caseName) }}</mat-option>\n }\n </mat-select>\n </mat-form-field>\n @if (selectedCase) {\n <nff-dynamic-recursive-form\n [schema]=\"caseAsGroup(child, selectedCase)\"\n [formGroup]=\"choiceGroup\"\n [editable]=\"editable()\"\n />\n }\n </div>\n }\n </mat-expansion-panel>\n }\n @else if (child.kind == 'nodeGroupList') {\n <nff-node-group-list-renderer\n [nodeGroupList]=\"child\"\n [formArray]=\"asFormArray(formGroup().get(childItem.key))\"\n [initialValue]=\"formGroup().get(childItem.key)?.value\"\n [editable]=\"editable()\"\n />\n }\n @else if (child.kind == 'map') {\n @let mapGroup = asFormGroup(formGroup().get(childItem.key));\n <mat-expansion-panel class=\"node-section\" [expanded]=\"child.presence ? !!mapGroup : !child.appearance?.collapsed\" togglePosition=\"before\">\n <mat-expansion-panel-header>\n <mat-panel-title>\n @if (child.presence) {\n <mat-checkbox\n [checked]=\"!!mapGroup\"\n [disabled]=\"!editable()\"\n (click)=\"$event.stopPropagation()\"\n (change)=\"toggleNodePresence(childItem.key, child, $event.checked)\"\n >{{ child.label ?? child.name }}</mat-checkbox>\n } @else {\n {{ child.label ?? child.name }}\n }\n </mat-panel-title>\n </mat-expansion-panel-header>\n @if (mapGroup) {\n <nff-node-map-renderer\n [nodeMap]=\"child\"\n [formGroup]=\"mapGroup\"\n [editable]=\"editable()\"\n />\n }\n </mat-expansion-panel>\n }\n }\n </div>\n </ng-template>\n @if (schema().appearance?.flatten) {\n <div class=\"flattened-form\">\n <ng-container *ngTemplateOutlet=\"formRender\"></ng-container>\n <div class=\"flattened-actions\">\n @if (editable() && index() != null && addButtonCallback() != null) {\n <button matIconButton matTooltip=\"+ Add new {{ schema().label ?? schema().name }}\" [attr.aria-label]=\"'Add new ' + (schema().label ?? schema().name)\" class=\"add-button small-icon-button\" (click)=\"addButtonCallback()!(index()!)\">\n <mat-icon>add</mat-icon>\n </button>\n }\n @if (editable() && removable()) {\n <button matIconButton class=\"remove-button small-icon-button\" [attr.aria-label]=\"'Remove ' + (schema().label ?? schema().name)\" (click)=\"emitRemoveEvent(); $event.stopPropagation()\">\n <mat-icon>delete</mat-icon>\n </button>\n }\n </div>\n </div>\n }\n @else {\n <mat-expansion-panel class=\"node-section\" [expanded]=\"!schema().appearance?.collapsed\" togglePosition=\"before\">\n <mat-expansion-panel-header>\n <mat-panel-title class=\"config-form-subsection-card\">\n {{ title() ?? schema().label ?? schema().name }}\n </mat-panel-title>\n <mat-panel-description class=\"section-actions\">\n <button matIconButton class=\"small-icon-button\" [class]=\"{ 'edit-icon': !editable() }\" [attr.aria-label]=\"editable() ? 'Stop editing' : 'Edit'\" (click)=\"editable.set(!editable()); $event.stopPropagation()\">\n <mat-icon>edit</mat-icon>\n </button>\n @if (editable() && index() != null && addButtonCallback() != null) {\n <button matIconButton matTooltip=\"+ Add new {{ schema().label ?? schema().name }}\" [attr.aria-label]=\"'Add new ' + (schema().label ?? schema().name)\" class=\"add-button small-icon-button\" (click)=\"addButtonCallback()!(index()!); $event.stopPropagation()\">\n <mat-icon>add</mat-icon>\n </button>\n }\n @if (editable() && removable()) {\n <button matIconButton class=\"remove-button small-icon-button\" [attr.aria-label]=\"'Remove ' + (schema().label ?? schema().name)\" (click)=\"emitRemoveEvent(); $event.stopPropagation()\">\n <mat-icon>delete</mat-icon>\n </button>\n }\n </mat-panel-description>\n </mat-expansion-panel-header>\n <ng-container *ngTemplateOutlet=\"formRender\"></ng-container>\n </mat-expansion-panel>\n }\n} @else {\n <div class=\"form-content\" [formGroup]=\"formGroup()\">\n <div class=\"leafs-container\">\n <div class=\"fields\">\n <button matIconButton class=\"small-icon-button\" [class]=\"{ 'edit-icon': !editable() }\" [attr.aria-label]=\"editable() ? 'Stop editing' : 'Edit'\" (click)=\"editable.set(!editable())\">\n <mat-icon>edit</mat-icon>\n </button>\n @for (childItem of nodeGroupChildrenList; track $index) {\n @let child = childItem.value;\n @if (child.kind == 'leaf' && !child.presence) {\n <nff-leaf-renderer\n [leaf_]=child\n [control]=asFormControl(formGroup().get(childItem.key))\n [removable]=false\n [editable]=\"editable()\"\n />\n }\n }\n @for (childItem of nodeGroupChildrenList; track $index) {\n @let child = childItem.value;\n @if (child.kind == 'leafList') {\n <nff-leaf-list-renderer\n [leaf_]=\"child\"\n [editable]=\"editable()\"\n [formArray]=\"asFormArray(formGroup().get(childItem.key))\"\n [initialValue]=\"formGroup().get(childItem.key)?.value\"\n />\n }\n }\n <!-- Presence leaves trail the regular fields so their add buttons don't\n interrupt the field flow; an enabled one keeps its trailing spot. -->\n @for (childItem of nodeGroupChildrenList; track $index) {\n @let child = childItem.value;\n @if (child.kind == 'leaf' && child.presence) {\n @if (formGroup().get(childItem.key)) {\n <nff-leaf-renderer\n [leaf_]=child\n [control]=asFormControl(formGroup().get(childItem.key))\n [removable]=true\n [editable]=\"editable()\"\n [autofocus]=\"presenceFocusKey === childItem.key || focusLeaf() === childItem.key\"\n (remove)=\"toggleLeafPresence(childItem.key, child, false)\"\n />\n } @else if (editable()) {\n <button\n matButton=\"outlined\"\n class=\"presence-leaf-add\"\n (click)=\"toggleLeafPresence(childItem.key, child, true)\"\n >\n <mat-icon>add</mat-icon> Add {{ child.label ?? child.name }}\n </button>\n }\n }\n }\n </div>\n </div>\n @for (childItem of nodeGroupChildrenList; track $index) {\n @let child = childItem.value;\n @if (child.kind == 'choice') {\n @let choiceGroup = asFormGroup(formGroup().get(childItem.key));\n @let selectedCase = activeCase(childItem.key);\n <mat-expansion-panel class=\"node-section\" [expanded]=\"child.presence ? !!choiceGroup : !child.appearance?.collapsed\" togglePosition=\"before\">\n <mat-expansion-panel-header>\n <mat-panel-title>\n @if (child.presence) {\n <mat-checkbox\n [checked]=\"!!choiceGroup\"\n [disabled]=\"!editable()\"\n (click)=\"$event.stopPropagation()\"\n (change)=\"toggleNodePresence(childItem.key, child, $event.checked)\"\n >{{ child.label ?? child.name }}</mat-checkbox>\n } @else {\n {{ child.label ?? child.name }}\n }\n </mat-panel-title>\n </mat-expansion-panel-header>\n @if (choiceGroup) {\n <div class=\"choice-group\">\n <mat-form-field [appearance]=\"editable() ? 'fill' : 'outline'\">\n <mat-label>Selected option</mat-label>\n <mat-select\n [value]=\"activeCase(childItem.key)\"\n (selectionChange)=\"switchCase(childItem.key, child, $event.value)\"\n >\n @for (caseName of objectKeys(child.cases); track caseName) {\n <mat-option [value]=\"caseName\">{{ caseLabel(child, caseName) }}</mat-option>\n }\n </mat-select>\n </mat-form-field>\n @if (selectedCase) {\n <nff-dynamic-recursive-form\n [schema]=\"caseAsGroup(child, selectedCase)\"\n [formGroup]=\"choiceGroup\"\n [editable]=\"editable()\"\n />\n }\n </div>\n }\n </mat-expansion-panel>\n }\n @else if (child.kind == 'map') {\n @let mapGroup = asFormGroup(formGroup().get(childItem.key));\n <mat-expansion-panel class=\"node-section\" [expanded]=\"child.presence ? !!mapGroup : !child.appearance?.collapsed\" togglePosition=\"before\">\n <mat-expansion-panel-header>\n <mat-panel-title>\n @if (child.presence) {\n <mat-checkbox\n [checked]=\"!!mapGroup\"\n [disabled]=\"!editable()\"\n (click)=\"$event.stopPropagation()\"\n (change)=\"toggleNodePresence(childItem.key, child, $event.checked)\"\n >{{ child.label ?? child.name }}</mat-checkbox>\n } @else {\n {{ child.label ?? child.name }}\n }\n </mat-panel-title>\n </mat-expansion-panel-header>\n @if (mapGroup) {\n <nff-node-map-renderer\n [nodeMap]=\"child\"\n [formGroup]=\"mapGroup\"\n [editable]=\"editable()\"\n />\n }\n </mat-expansion-panel>\n }\n @else if (child.kind == 'nodeGroupList') {\n <mat-expansion-panel class=\"node-section\" togglePosition=\"before\">\n <mat-expansion-panel-header>\n <mat-panel-title>\n {{ child.label ?? child.name }}\n </mat-panel-title>\n </mat-expansion-panel-header>\n <nff-node-group-list-renderer\n [nodeGroupList]=\"child\"\n [formArray]=\"asFormArray(formGroup().get(childItem.key))\"\n [initialValue]=\"formGroup().get(childItem.key)?.value\"\n [editable]=\"editable()\"\n />\n </mat-expansion-panel>\n }\n @else if (child.kind == 'nodeGroup' && child.presence) {\n <mat-expansion-panel class=\"node-section\" togglePosition=\"before\" [expanded]=\"!!formGroup().get(childItem.key)\">\n <mat-expansion-panel-header>\n <mat-panel-title>\n <mat-checkbox\n [checked]=\"!!formGroup().get(childItem.key)\"\n [disabled]=\"!editable()\"\n (click)=\"$event.stopPropagation()\"\n (change)=\"toggleNodePresence(childItem.key, child, $event.checked)\"\n >{{ child.label ?? child.name }}</mat-checkbox>\n </mat-panel-title>\n </mat-expansion-panel-header>\n @if (formGroup().get(childItem.key)) {\n <nff-dynamic-recursive-form\n [schema]=\"flatGroup(child)\"\n [formGroup]=\"asFormGroup(formGroup().get(childItem.key))\"\n [editable]=\"editable()\"\n />\n }\n </mat-expansion-panel>\n }\n @else if (child.kind == 'nodeGroup') {\n <nff-dynamic-recursive-form\n [schema]=\"child\"\n [formGroup]=\"asFormGroup(formGroup().get(childItem.key))\"\n [initialValue]=\"formGroup().get(childItem.key)?.value\"\n [editable]=\"editable()\"\n />\n }\n }\n </div>\n}\n", styles: [":host{display:flex;flex-direction:column;flex-wrap:wrap;width:100%;margin:4px 0}.presence-group,.choice-group{display:flex;flex-direction:column;gap:8px}.node-section{width:100%;margin:4px 0}.presence-leaf-add{flex:0 1 auto;align-self:flex-start;margin-top:8px}.fields{display:flex;flex-direction:row;gap:8px;flex-wrap:wrap;align-items:flex-start;flex:1 1 0}nff-leaf-list-renderer{flex:1}.mat-expansion-panel-content{display:flex;flex-direction:row;gap:16px}.form-content{display:flex;flex-direction:column;flex-wrap:wrap;width:100%}.leafs-container{display:flex;flex-direction:column;gap:8px;flex:1 1 0}mat-panel-description{display:flex;flex-direction:row}.section-actions{display:flex;flex-direction:row;align-items:center;gap:8px;justify-content:flex-end;width:100%}.edit-icon{opacity:.2;animation:icon-released .5s}.edit-icon:hover{opacity:1;animation:icon-hovered .5s}.flattened-actions{display:flex;flex-direction:column;align-items:flex-start;align-self:flex-start;gap:4px}.add-button{background-color:var(--mat-sys-primary-container);box-shadow:0 2px 1px -1px #0003,0 1px 1px #00000024,0 1px 3px #0000001f}.remove-button{background-color:var(--mat-sys-error-container);box-shadow:0 2px 1px -1px #0003,0 1px 1px #00000024,0 1px 3px #0000001f}.flattened-form{display:flex;flex-direction:row;width:100%;gap:4px}@keyframes icon-hovered{0%{opacity:.4}}@keyframes icon-released{0%{opacity:1}to{opacity:.4}}\n"], dependencies: [{ kind: "component", type: i0.forwardRef(() => DynamicRecursiveFormComponent), selector: "nff-dynamic-recursive-form", inputs: ["schema", "initialValue", "formGroup", "index", "removable", "title", "editable", "addButtonCallback", "focusLeaf"], outputs: ["remove", "editableChange"] }, { kind: "component", type: i0.forwardRef(() => LeafRendererComponent), selector: "nff-leaf-renderer", inputs: ["leaf_", "control", "removable", "editable", "autofocus"], outputs: ["remove"] }, { kind: "component", type: i0.forwardRef(() => LeafListRendererComponent), selector: "nff-leaf-list-renderer", inputs: ["leaf_", "initialValue", "formArray", "editable", "minItems"], outputs: ["message"] }, { kind: "component", type: i0.forwardRef(() => NodeGroupListRendererComponent), selector: "nff-node-group-list-renderer", inputs: ["nodeGroupList", "initialValue", "formArray", "editable", "minItems", "maxItems"], outputs: ["message"] }, { kind: "component", type: i0.forwardRef(() => NodeMapRendererComponent), selector: "nff-node-map-renderer", inputs: ["nodeMap", "formGroup", "editable"] }, { kind: "ngmodule", type: i0.forwardRef(() => ReactiveFormsModule) }, { kind: "directive", type: i0.forwardRef(() => i1.NgControlStatusGroup), selector: "[formGroupName],[formArrayName],[ngModelGroup],[formGroup],form:not([ngNoForm]),[ngForm]" }, { kind: "directive", type: i0.forwardRef(() => i1.FormGroupDirective), selector: "[formGroup]", inputs: ["formGroup"], outputs: ["ngSubmit"], exportAs: ["ngForm"] }, { kind: "ngmodule", type: i0.forwardRef(() => MatExpansionModule) }, { kind: "component", type: i0.forwardRef(() => i2$2.MatExpansionPanel), selector: "mat-expansion-panel", inputs: ["hideToggle", "togglePosition"], outputs: ["afterExpand", "afterCollapse"], exportAs: ["matExpansionPanel"] }, { kind: "component", type: i0.forwardRef(() => i2$2.MatExpansionPanelHeader), selector: "mat-expansion-panel-header", inputs: ["expandedHeight", "collapsedHeight", "tabIndex"] }, { kind: "directive", type: i0.forwardRef(() => i2$2.MatExpansionPanelTitle), selector: "mat-panel-title" }, { kind: "directive", type: i0.forwardRef(() => i2$2.MatExpansionPanelDescription), selector: "mat-panel-description" }, { kind: "ngmodule", type: i0.forwardRef(() => MatIconModule) }, { kind: "component", type: i0.forwardRef(() => i1$1.MatIcon), selector: "mat-icon", inputs: ["color", "inline", "svgIcon", "fontSet", "fontIcon"], exportAs: ["matIcon"] }, { kind: "ngmodule", type: i0.forwardRef(() => MatButtonModule) }, { kind: "component", type: i0.forwardRef(() => i2$1.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: i0.forwardRef(() => i2$1.MatIconButton), selector: "button[mat-icon-button], a[mat-icon-button], button[matIconButton], a[matIconButton]", exportAs: ["matButton", "matAnchor"] }, { kind: "directive", type: i0.forwardRef(() => NgTemplateOutlet), selector: "[ngTemplateOutlet]", inputs: ["ngTemplateOutletContext", "ngTemplateOutlet", "ngTemplateOutletInjector"] }, { kind: "ngmodule", type: i0.forwardRef(() => MatCheckboxModule) }, { kind: "component", type: i0.forwardRef(() => i4.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: i0.forwardRef(() => MatFormFieldModule) }, { kind: "component", type: i0.forwardRef(() => i2.MatFormField), selector: "mat-form-field", inputs: ["hideRequiredMarker", "color", "floatLabel", "appearance", "subscriptSizing", "hintLabel"], exportAs: ["matFormField"] }, { kind: "directive", type: i0.forwardRef(() => i2.MatLabel), selector: "mat-label" }, { kind: "ngmodule", type: i0.forwardRef(() => MatSelectModule) }, { kind: "component", type: i0.forwardRef(() => i3.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: i0.forwardRef(() => i3.MatOption), selector: "mat-option", inputs: ["value", "id", "disabled"], outputs: ["onSelectionChange"], exportAs: ["matOption"] }, { kind: "directive", type: i0.forwardRef(() => MatTooltip), selector: "[matTooltip]", inputs: ["matTooltipPosition", "matTooltipPositionAtOrigin", "matTooltipDisabled", "matTooltipShowDelay", "matTooltipHideDelay", "matTooltipTouchGestures", "matTooltip", "matTooltipClass"], exportAs: ["matTooltip"] }] });
|
|
570
1016
|
}
|
|
571
1017
|
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.9", ngImport: i0, type: DynamicRecursiveFormComponent, decorators: [{
|
|
572
1018
|
type: Component,
|
|
573
1019
|
args: [{ imports: [
|
|
574
1020
|
LeafRendererComponent,
|
|
575
|
-
NodeGroupListRendererComponent,
|
|
576
1021
|
LeafListRendererComponent,
|
|
1022
|
+
// node-group-list-renderer and node-map-renderer import this component back
|
|
1023
|
+
// (list items and map values may be groups); forwardRef tolerates either
|
|
1024
|
+
// module-evaluation order for the cycle.
|
|
1025
|
+
forwardRef(() => NodeGroupListRendererComponent),
|
|
1026
|
+
forwardRef(() => NodeMapRendererComponent),
|
|
577
1027
|
ReactiveFormsModule,
|
|
578
1028
|
MatExpansionModule,
|
|
579
1029
|
MatIconModule,
|
|
580
1030
|
MatButtonModule,
|
|
581
1031
|
NgTemplateOutlet,
|
|
582
|
-
MatCardModule,
|
|
583
1032
|
MatCheckboxModule,
|
|
584
1033
|
MatFormFieldModule,
|
|
585
1034
|
MatSelectModule,
|
|
586
1035
|
MatTooltip,
|
|
587
|
-
], selector: 'nff-dynamic-recursive-form', standalone: true, template: "@if (!root) {\n <ng-template #formRender>\n <div class=\"form-content\" [formGroup]=\"formGroup\">\n <div class=\"leafs-container\">\n <div class=\"fields\">\n @for (childItem of nodeGroupChildrenList; track $index) {\n @let child = childItem.value;\n @if (child.kind == 'leaf') {\n <nff-leaf-renderer\n [leaf_]=child\n [control]=asFormControl(formGroup.get(child.name))\n [removable]=false\n [editable]=editable\n [initialValue]=\"formGroup.get(child.name)?.value\"\n />\n }\n }\n @for (childItem of nodeGroupChildrenList; track $index) {\n @let child = childItem.value;\n @if (child.kind == 'leafList') {\n <nff-leaf-list-renderer\n [leaf_]=\"child\"\n [editable]=editable\n [formArray]=\"asFormArray(formGroup.get(child.name))\"\n [initialValue]=\"formGroup.get(child.name)?.value\"\n />\n }\n }\n </div>\n </div>\n @for (childItem of nodeGroupChildrenList; track $index) {\n @let child = childItem.value;\n @if (child.kind == 'nodeGroup' && child.presence) {\n <div class=\"presence-group\">\n <mat-checkbox\n [checked]=\"!!formGroup.get(childItem.key)\"\n [disabled]=\"!editable\"\n (change)=\"togglePresence(childItem.key, child, $event.checked)\"\n >{{ child.label ?? child.name }}</mat-checkbox>\n @if (formGroup.get(childItem.key)) {\n <nff-dynamic-recursive-form\n [schema]=\"child\"\n [formGroup]=\"asFormGroup(formGroup.get(childItem.key))\"\n [editable]=\"editable\"\n />\n }\n </div>\n }\n @else if (child.kind == 'nodeGroup') {\n <nff-dynamic-recursive-form\n [schema]=\"child\"\n [formGroup]=\"asFormGroup(formGroup.get(child.name))\"\n [initialValue]=\"formGroup.get(child.name)?.value\"\n [editable]=\"editable\"\n />\n }\n @else if (child.kind == 'choice') {\n @let choiceGroup = asFormGroup(formGroup.get(childItem.key));\n @let selectedCase = activeCase(childItem.key);\n <div class=\"choice-group\" [formGroup]=\"choiceGroup\">\n <mat-form-field [appearance]=\"editable ? 'fill' : 'outline'\">\n <mat-label>{{ child.label ?? child.name }}</mat-label>\n <mat-select\n [formControlName]=\"CASE_KEY\"\n (selectionChange)=\"switchCase(childItem.key, child, $event.value)\"\n >\n @for (caseName of objectKeys(child.cases); track caseName) {\n <mat-option [value]=\"caseName\">{{ caseName }}</mat-option>\n }\n </mat-select>\n </mat-form-field>\n @if (selectedCase) {\n <nff-dynamic-recursive-form\n [schema]=\"caseAsGroup(child, selectedCase)\"\n [formGroup]=\"choiceGroup\"\n [editable]=\"editable\"\n />\n }\n </div>\n }\n }\n\n @for (childItem of nodeGroupChildrenList; track $index) {\n @let child = childItem.value;\n @if (child.kind == 'nodeGroupList') {\n <nff-node-group-list-renderer\n [nodeGroupList]=\"child\"\n [formArray]=\"asFormArray(formGroup.get(child.name))\"\n [initialValue]=\"formGroup.get(child.name)?.value\"\n [editable]=\"editable\"\n />\n }\n }\n </div>\n </ng-template>\n @if (schema.appearance?.flatten) {\n <div class=\"flattened-form\">\n <ng-container *ngTemplateOutlet=\"formRender\"></ng-container>\n <div class=\"flattened-actions\">\n @if (editable && index != null && addButtonCallback != null) {\n <button matIconButton matTooltip=\"+ Add new {{ schema.label ?? schema.name }}\" class=\"add-button small-icon-button\" (click)=\"addButtonCallback(index)\">\n <mat-icon>add</mat-icon>\n </button>\n }\n @if (editable && removable) {\n <button matIconButton class=\"remove-button small-icon-button\" (click)=\"emitRemoveEvent(); $event.stopPropagation()\">\n <mat-icon>delete</mat-icon>\n </button>\n }\n </div>\n </div>\n }\n @else {\n <mat-card appearance=\"outlined\" [class]=\"{ 'mat-card-no-broder': schema.appearance?.noBorder }\">\n <mat-card-header>\n <div class=\"card-actions\">\n <button matIconButton class=\"small-icon-button\" [class]=\"{ 'edit-icon': !editable}\" (click)=\"editable = !editable\">\n <mat-icon>edit</mat-icon>\n </button>\n @if (editable && index != null && addButtonCallback != null) {\n <button matIconButton matTooltip=\"+ Add new {{ schema.label ?? schema.name }}\" class=\"add-button small-icon-button\" (click)=\"addButtonCallback(index)\">\n <mat-icon>add</mat-icon>\n </button>\n }\n @if (editable && removable) {\n <button matIconButton class=\"remove-button small-icon-button\" (click)=\"emitRemoveEvent(); $event.stopPropagation()\">\n <mat-icon>delete</mat-icon>\n </button>\n }\n </div>\n <mat-card-title class=\"config-form-subsection-card\">\n {{ title ?? schema.label ?? schema.name }}\n </mat-card-title>\n </mat-card-header>\n <mat-card-content>\n <ng-container *ngTemplateOutlet=\"formRender\"></ng-container>\n </mat-card-content>\n </mat-card>\n }\n} @else {\n <div class=\"form-content\" [formGroup]=\"formGroup\">\n <div class=\"leafs-container\">\n <div class=\"fields\">\n <button matIconButton class=\"small-icon-button\" [class]=\"{ 'edit-icon': !editable }\" (click)=\"editable = !editable\">\n <mat-icon>edit</mat-icon>\n </button>\n @for (childItem of nodeGroupChildrenList; track $index) {\n @let child = childItem.value;\n @if (child.kind == 'leaf') {\n <nff-leaf-renderer\n [leaf_]=child\n [control]=asFormControl(formGroup.get(child.name))\n [removable]=false\n [editable]=editable\n [initialValue]=\"formGroup.get(child.name)?.value\"\n />\n }\n }\n @for (childItem of nodeGroupChildrenList; track $index) {\n @let child = childItem.value;\n @if (child.kind == 'leafList') {\n <nff-leaf-list-renderer\n [leaf_]=\"child\"\n [editable]=editable\n [formArray]=\"asFormArray(formGroup.get(child.name))\"\n [initialValue]=\"formGroup.get(child.name)?.value\"\n />\n }\n }\n @for (childItem of nodeGroupChildrenList; track $index) {\n @let child = childItem.value;\n @if (child.kind == 'choice') {\n @let choiceGroup = asFormGroup(formGroup.get(childItem.key));\n @let selectedCase = activeCase(childItem.key);\n <div class=\"choice-group\" [formGroup]=\"choiceGroup\">\n <mat-form-field [appearance]=\"editable ? 'fill' : 'outline'\">\n <mat-label>{{ child.label ?? child.name }}</mat-label>\n <mat-select\n [formControlName]=\"CASE_KEY\"\n (selectionChange)=\"switchCase(childItem.key, child, $event.value)\"\n >\n @for (caseName of objectKeys(child.cases); track caseName) {\n <mat-option [value]=\"caseName\">{{ caseName }}</mat-option>\n }\n </mat-select>\n </mat-form-field>\n @if (selectedCase) {\n <nff-dynamic-recursive-form\n [schema]=\"caseAsGroup(child, selectedCase)\"\n [formGroup]=\"choiceGroup\"\n [editable]=\"editable\"\n />\n }\n </div>\n }\n }\n </div>\n </div>\n <mat-accordion multi>\n @for (childItem of nodeGroupChildrenList; track $index) {\n @let child = childItem.value;\n @if (child.kind == 'nodeGroupList') {\n <mat-expansion-panel togglePosition=\"before\">\n <mat-expansion-panel-header>\n <mat-panel-title>\n {{ title ?? child.label ?? child.name }}\n </mat-panel-title>\n </mat-expansion-panel-header>\n <nff-node-group-list-renderer\n [nodeGroupList]=\"child\"\n [formArray]=\"asFormArray(formGroup.get(child.name))\"\n [initialValue]=\"formGroup.get(child.name)?.value\"\n [editable]=\"editable\"\n />\n </mat-expansion-panel>\n }\n }\n @for (childItem of nodeGroupChildrenList; track $index) {\n @let child = childItem.value;\n @if (child.kind == 'nodeGroup' && child.presence) {\n <mat-expansion-panel togglePosition=\"before\" [expanded]=\"!!formGroup.get(childItem.key)\">\n <mat-expansion-panel-header>\n <mat-panel-title>\n <mat-checkbox\n [checked]=\"!!formGroup.get(childItem.key)\"\n [disabled]=\"!editable\"\n (click)=\"$event.stopPropagation()\"\n (change)=\"togglePresence(childItem.key, child, $event.checked)\"\n >{{ child.label ?? child.name }}</mat-checkbox>\n </mat-panel-title>\n </mat-expansion-panel-header>\n @if (formGroup.get(childItem.key)) {\n <nff-dynamic-recursive-form\n [schema]=\"child\"\n [formGroup]=\"asFormGroup(formGroup.get(childItem.key))\"\n [editable]=\"editable\"\n />\n }\n </mat-expansion-panel>\n }\n @else if (child.kind == 'nodeGroup') {\n <mat-expansion-panel togglePosition=\"before\" expanded=\"true\">\n <mat-expansion-panel-header >\n <mat-panel-title>\n {{ title ?? child.label ?? child.name }}\n </mat-panel-title>\n </mat-expansion-panel-header>\n <nff-dynamic-recursive-form\n [schema]=\"child\"\n [formGroup]=\"asFormGroup(formGroup.get(child.name))\"\n [initialValue]=\"formGroup.get(child.name)?.value\"\n [title]=\"child.name ?? 'General Settings'\"\n [editable]=\"editable\"\n />\n </mat-expansion-panel>\n }\n }\n </mat-accordion>\n </div>\n}\n", styles: [":host{display:flex;flex-direction:column;flex-wrap:wrap;width:100%;margin:4px 0}.presence-group,.choice-group{display:flex;flex-direction:column;gap:8px}.fields{display:flex;flex-direction:row;gap:8px;flex-wrap:wrap;align-items:flex-start;flex:1 1 0}nff-leaf-list-renderer{flex:1}.mat-expansion-panel-content,.mat-expansion-panel-content:hover{display:flex;flex-direction:row;gap:16px}.form-content{display:flex;flex-direction:column;flex-wrap:wrap;width:100%}.leafs-container{display:flex;flex-direction:column;gap:8px;flex:1 1 0}mat-panel-description{display:flex;flex-direction:row}.node-groups-container{display:flex;flex-direction:column;flex-basis:100%}.section-actions{display:flex;flex-direction:row;gap:8px;justify-content:flex-end;width:100%}mat-card{padding:0;width:100%}.mat-card-no-broder{border:0}mat-card-content{box-sizing:border-box;padding:16px}mat-card-header{display:flex;flex-direction:row;align-items:flex-start;padding:16px;gap:16px}.edit-icon{opacity:.2;animation:icon-released .5s}.edit-icon:hover{opacity:1;animation:icon-transition .5s}.card-actions{display:flex;flex-direction:row;align-items:center;gap:4px}.flattened-actions{display:flex;flex-direction:column;align-items:flex-start;align-self:flex-start;gap:4px}.add-button{background-color:var(--mat-sys-primary-container);box-shadow:0 2px 1px -1px #0003,0 1px 1px #00000024,0 1px 3px #0000001f}.remove-button{background-color:var(--mat-sys-error-container);box-shadow:0 2px 1px -1px #0003,0 1px 1px #00000024,0 1px 3px #0000001f}.flattened-form{display:flex;flex-direction:row;width:100%;gap:4px}.flattened-style-actions{display:flex;flex-direction:row;gap:8px;justify-content:flex-end;align-items:flex-start}@keyframes icon-hovered{0%{opacity:.4}}@keyframes icon-released{0%{opacity:1}to{opacity:.4}}\n"] }]
|
|
588
|
-
}], propDecorators: { schema: [{
|
|
589
|
-
type: Input,
|
|
590
|
-
args: [{ required: true }]
|
|
591
|
-
}], initialValue: [{
|
|
592
|
-
type: Input
|
|
593
|
-
}], formGroup: [{
|
|
594
|
-
type: Input
|
|
595
|
-
}], index: [{
|
|
596
|
-
type: Input
|
|
597
|
-
}], removable: [{
|
|
598
|
-
type: Input
|
|
599
|
-
}], remove: [{
|
|
600
|
-
type: Output
|
|
601
|
-
}], title: [{
|
|
602
|
-
type: Input
|
|
603
|
-
}], editable: [{
|
|
604
|
-
type: Input
|
|
605
|
-
}], addButtonCallback: [{
|
|
606
|
-
type: Input
|
|
607
|
-
}] } });
|
|
1036
|
+
], selector: 'nff-dynamic-recursive-form', standalone: true, template: "@if (!root()) {\n <ng-template #formRender>\n <div class=\"form-content\" [formGroup]=\"formGroup()\">\n <div class=\"leafs-container\">\n <div class=\"fields\">\n @for (childItem of nodeGroupChildrenList; track $index) {\n @let child = childItem.value;\n @if (child.kind == 'leaf' && !child.presence) {\n <nff-leaf-renderer\n [leaf_]=child\n [control]=asFormControl(formGroup().get(childItem.key))\n [removable]=false\n [editable]=\"editable()\"\n />\n }\n }\n @for (childItem of nodeGroupChildrenList; track $index) {\n @let child = childItem.value;\n @if (child.kind == 'leafList') {\n <nff-leaf-list-renderer\n [leaf_]=\"child\"\n [editable]=\"editable()\"\n [formArray]=\"asFormArray(formGroup().get(childItem.key))\"\n [initialValue]=\"formGroup().get(childItem.key)?.value\"\n />\n }\n }\n <!-- Presence leaves trail the regular fields so their add buttons don't\n interrupt the field flow; an enabled one keeps its trailing spot. -->\n @for (childItem of nodeGroupChildrenList; track $index) {\n @let child = childItem.value;\n @if (child.kind == 'leaf' && child.presence) {\n @if (formGroup().get(childItem.key)) {\n <nff-leaf-renderer\n [leaf_]=child\n [control]=asFormControl(formGroup().get(childItem.key))\n [removable]=true\n [editable]=\"editable()\"\n [autofocus]=\"presenceFocusKey === childItem.key || focusLeaf() === childItem.key\"\n (remove)=\"toggleLeafPresence(childItem.key, child, false)\"\n />\n } @else if (editable()) {\n <!-- Read-only mode renders nothing for an absent presence leaf:\n the key is simply absent, and add affordances are hidden\n like every other structural control. -->\n <button\n matButton=\"outlined\"\n class=\"presence-leaf-add\"\n (click)=\"toggleLeafPresence(childItem.key, child, true)\"\n >\n <mat-icon>add</mat-icon> Add {{ child.label ?? child.name }}\n </button>\n }\n }\n }\n </div>\n </div>\n @for (childItem of nodeGroupChildrenList; track $index) {\n @let child = childItem.value;\n @if (child.kind == 'nodeGroup' && child.presence) {\n <div class=\"presence-group\">\n <mat-checkbox\n [checked]=\"!!formGroup().get(childItem.key)\"\n [disabled]=\"!editable()\"\n (change)=\"toggleNodePresence(childItem.key, child, $event.checked)\"\n >{{ child.label ?? child.name }}</mat-checkbox>\n @if (formGroup().get(childItem.key)) {\n <nff-dynamic-recursive-form\n [schema]=\"flatGroup(child)\"\n [formGroup]=\"asFormGroup(formGroup().get(childItem.key))\"\n [editable]=\"editable()\"\n />\n }\n </div>\n }\n @else if (child.kind == 'nodeGroup') {\n <nff-dynamic-recursive-form\n [schema]=\"child\"\n [formGroup]=\"asFormGroup(formGroup().get(childItem.key))\"\n [initialValue]=\"formGroup().get(childItem.key)?.value\"\n [editable]=\"editable()\"\n />\n }\n @else if (child.kind == 'choice') {\n @let choiceGroup = asFormGroup(formGroup().get(childItem.key));\n @let selectedCase = activeCase(childItem.key);\n <mat-expansion-panel class=\"node-section\" [expanded]=\"child.presence ? !!choiceGroup : !child.appearance?.collapsed\" togglePosition=\"before\">\n <mat-expansion-panel-header>\n <mat-panel-title>\n @if (child.presence) {\n <mat-checkbox\n [checked]=\"!!choiceGroup\"\n [disabled]=\"!editable()\"\n (click)=\"$event.stopPropagation()\"\n (change)=\"toggleNodePresence(childItem.key, child, $event.checked)\"\n >{{ child.label ?? child.name }}</mat-checkbox>\n } @else {\n {{ child.label ?? child.name }}\n }\n </mat-panel-title>\n </mat-expansion-panel-header>\n @if (choiceGroup) {\n <div class=\"choice-group\">\n <mat-form-field [appearance]=\"editable() ? 'fill' : 'outline'\">\n <mat-label>Selected option</mat-label>\n <mat-select\n [value]=\"activeCase(childItem.key)\"\n (selectionChange)=\"switchCase(childItem.key, child, $event.value)\"\n >\n @for (caseName of objectKeys(child.cases); track caseName) {\n <mat-option [value]=\"caseName\">{{ caseLabel(child, caseName) }}</mat-option>\n }\n </mat-select>\n </mat-form-field>\n @if (selectedCase) {\n <nff-dynamic-recursive-form\n [schema]=\"caseAsGroup(child, selectedCase)\"\n [formGroup]=\"choiceGroup\"\n [editable]=\"editable()\"\n />\n }\n </div>\n }\n </mat-expansion-panel>\n }\n @else if (child.kind == 'nodeGroupList') {\n <nff-node-group-list-renderer\n [nodeGroupList]=\"child\"\n [formArray]=\"asFormArray(formGroup().get(childItem.key))\"\n [initialValue]=\"formGroup().get(childItem.key)?.value\"\n [editable]=\"editable()\"\n />\n }\n @else if (child.kind == 'map') {\n @let mapGroup = asFormGroup(formGroup().get(childItem.key));\n <mat-expansion-panel class=\"node-section\" [expanded]=\"child.presence ? !!mapGroup : !child.appearance?.collapsed\" togglePosition=\"before\">\n <mat-expansion-panel-header>\n <mat-panel-title>\n @if (child.presence) {\n <mat-checkbox\n [checked]=\"!!mapGroup\"\n [disabled]=\"!editable()\"\n (click)=\"$event.stopPropagation()\"\n (change)=\"toggleNodePresence(childItem.key, child, $event.checked)\"\n >{{ child.label ?? child.name }}</mat-checkbox>\n } @else {\n {{ child.label ?? child.name }}\n }\n </mat-panel-title>\n </mat-expansion-panel-header>\n @if (mapGroup) {\n <nff-node-map-renderer\n [nodeMap]=\"child\"\n [formGroup]=\"mapGroup\"\n [editable]=\"editable()\"\n />\n }\n </mat-expansion-panel>\n }\n }\n </div>\n </ng-template>\n @if (schema().appearance?.flatten) {\n <div class=\"flattened-form\">\n <ng-container *ngTemplateOutlet=\"formRender\"></ng-container>\n <div class=\"flattened-actions\">\n @if (editable() && index() != null && addButtonCallback() != null) {\n <button matIconButton matTooltip=\"+ Add new {{ schema().label ?? schema().name }}\" [attr.aria-label]=\"'Add new ' + (schema().label ?? schema().name)\" class=\"add-button small-icon-button\" (click)=\"addButtonCallback()!(index()!)\">\n <mat-icon>add</mat-icon>\n </button>\n }\n @if (editable() && removable()) {\n <button matIconButton class=\"remove-button small-icon-button\" [attr.aria-label]=\"'Remove ' + (schema().label ?? schema().name)\" (click)=\"emitRemoveEvent(); $event.stopPropagation()\">\n <mat-icon>delete</mat-icon>\n </button>\n }\n </div>\n </div>\n }\n @else {\n <mat-expansion-panel class=\"node-section\" [expanded]=\"!schema().appearance?.collapsed\" togglePosition=\"before\">\n <mat-expansion-panel-header>\n <mat-panel-title class=\"config-form-subsection-card\">\n {{ title() ?? schema().label ?? schema().name }}\n </mat-panel-title>\n <mat-panel-description class=\"section-actions\">\n <button matIconButton class=\"small-icon-button\" [class]=\"{ 'edit-icon': !editable() }\" [attr.aria-label]=\"editable() ? 'Stop editing' : 'Edit'\" (click)=\"editable.set(!editable()); $event.stopPropagation()\">\n <mat-icon>edit</mat-icon>\n </button>\n @if (editable() && index() != null && addButtonCallback() != null) {\n <button matIconButton matTooltip=\"+ Add new {{ schema().label ?? schema().name }}\" [attr.aria-label]=\"'Add new ' + (schema().label ?? schema().name)\" class=\"add-button small-icon-button\" (click)=\"addButtonCallback()!(index()!); $event.stopPropagation()\">\n <mat-icon>add</mat-icon>\n </button>\n }\n @if (editable() && removable()) {\n <button matIconButton class=\"remove-button small-icon-button\" [attr.aria-label]=\"'Remove ' + (schema().label ?? schema().name)\" (click)=\"emitRemoveEvent(); $event.stopPropagation()\">\n <mat-icon>delete</mat-icon>\n </button>\n }\n </mat-panel-description>\n </mat-expansion-panel-header>\n <ng-container *ngTemplateOutlet=\"formRender\"></ng-container>\n </mat-expansion-panel>\n }\n} @else {\n <div class=\"form-content\" [formGroup]=\"formGroup()\">\n <div class=\"leafs-container\">\n <div class=\"fields\">\n <button matIconButton class=\"small-icon-button\" [class]=\"{ 'edit-icon': !editable() }\" [attr.aria-label]=\"editable() ? 'Stop editing' : 'Edit'\" (click)=\"editable.set(!editable())\">\n <mat-icon>edit</mat-icon>\n </button>\n @for (childItem of nodeGroupChildrenList; track $index) {\n @let child = childItem.value;\n @if (child.kind == 'leaf' && !child.presence) {\n <nff-leaf-renderer\n [leaf_]=child\n [control]=asFormControl(formGroup().get(childItem.key))\n [removable]=false\n [editable]=\"editable()\"\n />\n }\n }\n @for (childItem of nodeGroupChildrenList; track $index) {\n @let child = childItem.value;\n @if (child.kind == 'leafList') {\n <nff-leaf-list-renderer\n [leaf_]=\"child\"\n [editable]=\"editable()\"\n [formArray]=\"asFormArray(formGroup().get(childItem.key))\"\n [initialValue]=\"formGroup().get(childItem.key)?.value\"\n />\n }\n }\n <!-- Presence leaves trail the regular fields so their add buttons don't\n interrupt the field flow; an enabled one keeps its trailing spot. -->\n @for (childItem of nodeGroupChildrenList; track $index) {\n @let child = childItem.value;\n @if (child.kind == 'leaf' && child.presence) {\n @if (formGroup().get(childItem.key)) {\n <nff-leaf-renderer\n [leaf_]=child\n [control]=asFormControl(formGroup().get(childItem.key))\n [removable]=true\n [editable]=\"editable()\"\n [autofocus]=\"presenceFocusKey === childItem.key || focusLeaf() === childItem.key\"\n (remove)=\"toggleLeafPresence(childItem.key, child, false)\"\n />\n } @else if (editable()) {\n <button\n matButton=\"outlined\"\n class=\"presence-leaf-add\"\n (click)=\"toggleLeafPresence(childItem.key, child, true)\"\n >\n <mat-icon>add</mat-icon> Add {{ child.label ?? child.name }}\n </button>\n }\n }\n }\n </div>\n </div>\n @for (childItem of nodeGroupChildrenList; track $index) {\n @let child = childItem.value;\n @if (child.kind == 'choice') {\n @let choiceGroup = asFormGroup(formGroup().get(childItem.key));\n @let selectedCase = activeCase(childItem.key);\n <mat-expansion-panel class=\"node-section\" [expanded]=\"child.presence ? !!choiceGroup : !child.appearance?.collapsed\" togglePosition=\"before\">\n <mat-expansion-panel-header>\n <mat-panel-title>\n @if (child.presence) {\n <mat-checkbox\n [checked]=\"!!choiceGroup\"\n [disabled]=\"!editable()\"\n (click)=\"$event.stopPropagation()\"\n (change)=\"toggleNodePresence(childItem.key, child, $event.checked)\"\n >{{ child.label ?? child.name }}</mat-checkbox>\n } @else {\n {{ child.label ?? child.name }}\n }\n </mat-panel-title>\n </mat-expansion-panel-header>\n @if (choiceGroup) {\n <div class=\"choice-group\">\n <mat-form-field [appearance]=\"editable() ? 'fill' : 'outline'\">\n <mat-label>Selected option</mat-label>\n <mat-select\n [value]=\"activeCase(childItem.key)\"\n (selectionChange)=\"switchCase(childItem.key, child, $event.value)\"\n >\n @for (caseName of objectKeys(child.cases); track caseName) {\n <mat-option [value]=\"caseName\">{{ caseLabel(child, caseName) }}</mat-option>\n }\n </mat-select>\n </mat-form-field>\n @if (selectedCase) {\n <nff-dynamic-recursive-form\n [schema]=\"caseAsGroup(child, selectedCase)\"\n [formGroup]=\"choiceGroup\"\n [editable]=\"editable()\"\n />\n }\n </div>\n }\n </mat-expansion-panel>\n }\n @else if (child.kind == 'map') {\n @let mapGroup = asFormGroup(formGroup().get(childItem.key));\n <mat-expansion-panel class=\"node-section\" [expanded]=\"child.presence ? !!mapGroup : !child.appearance?.collapsed\" togglePosition=\"before\">\n <mat-expansion-panel-header>\n <mat-panel-title>\n @if (child.presence) {\n <mat-checkbox\n [checked]=\"!!mapGroup\"\n [disabled]=\"!editable()\"\n (click)=\"$event.stopPropagation()\"\n (change)=\"toggleNodePresence(childItem.key, child, $event.checked)\"\n >{{ child.label ?? child.name }}</mat-checkbox>\n } @else {\n {{ child.label ?? child.name }}\n }\n </mat-panel-title>\n </mat-expansion-panel-header>\n @if (mapGroup) {\n <nff-node-map-renderer\n [nodeMap]=\"child\"\n [formGroup]=\"mapGroup\"\n [editable]=\"editable()\"\n />\n }\n </mat-expansion-panel>\n }\n @else if (child.kind == 'nodeGroupList') {\n <mat-expansion-panel class=\"node-section\" togglePosition=\"before\">\n <mat-expansion-panel-header>\n <mat-panel-title>\n {{ child.label ?? child.name }}\n </mat-panel-title>\n </mat-expansion-panel-header>\n <nff-node-group-list-renderer\n [nodeGroupList]=\"child\"\n [formArray]=\"asFormArray(formGroup().get(childItem.key))\"\n [initialValue]=\"formGroup().get(childItem.key)?.value\"\n [editable]=\"editable()\"\n />\n </mat-expansion-panel>\n }\n @else if (child.kind == 'nodeGroup' && child.presence) {\n <mat-expansion-panel class=\"node-section\" togglePosition=\"before\" [expanded]=\"!!formGroup().get(childItem.key)\">\n <mat-expansion-panel-header>\n <mat-panel-title>\n <mat-checkbox\n [checked]=\"!!formGroup().get(childItem.key)\"\n [disabled]=\"!editable()\"\n (click)=\"$event.stopPropagation()\"\n (change)=\"toggleNodePresence(childItem.key, child, $event.checked)\"\n >{{ child.label ?? child.name }}</mat-checkbox>\n </mat-panel-title>\n </mat-expansion-panel-header>\n @if (formGroup().get(childItem.key)) {\n <nff-dynamic-recursive-form\n [schema]=\"flatGroup(child)\"\n [formGroup]=\"asFormGroup(formGroup().get(childItem.key))\"\n [editable]=\"editable()\"\n />\n }\n </mat-expansion-panel>\n }\n @else if (child.kind == 'nodeGroup') {\n <nff-dynamic-recursive-form\n [schema]=\"child\"\n [formGroup]=\"asFormGroup(formGroup().get(childItem.key))\"\n [initialValue]=\"formGroup().get(childItem.key)?.value\"\n [editable]=\"editable()\"\n />\n }\n }\n </div>\n}\n", styles: [":host{display:flex;flex-direction:column;flex-wrap:wrap;width:100%;margin:4px 0}.presence-group,.choice-group{display:flex;flex-direction:column;gap:8px}.node-section{width:100%;margin:4px 0}.presence-leaf-add{flex:0 1 auto;align-self:flex-start;margin-top:8px}.fields{display:flex;flex-direction:row;gap:8px;flex-wrap:wrap;align-items:flex-start;flex:1 1 0}nff-leaf-list-renderer{flex:1}.mat-expansion-panel-content{display:flex;flex-direction:row;gap:16px}.form-content{display:flex;flex-direction:column;flex-wrap:wrap;width:100%}.leafs-container{display:flex;flex-direction:column;gap:8px;flex:1 1 0}mat-panel-description{display:flex;flex-direction:row}.section-actions{display:flex;flex-direction:row;align-items:center;gap:8px;justify-content:flex-end;width:100%}.edit-icon{opacity:.2;animation:icon-released .5s}.edit-icon:hover{opacity:1;animation:icon-hovered .5s}.flattened-actions{display:flex;flex-direction:column;align-items:flex-start;align-self:flex-start;gap:4px}.add-button{background-color:var(--mat-sys-primary-container);box-shadow:0 2px 1px -1px #0003,0 1px 1px #00000024,0 1px 3px #0000001f}.remove-button{background-color:var(--mat-sys-error-container);box-shadow:0 2px 1px -1px #0003,0 1px 1px #00000024,0 1px 3px #0000001f}.flattened-form{display:flex;flex-direction:row;width:100%;gap:4px}@keyframes icon-hovered{0%{opacity:.4}}@keyframes icon-released{0%{opacity:1}to{opacity:.4}}\n"] }]
|
|
1037
|
+
}], propDecorators: { schema: [{ type: i0.Input, args: [{ isSignal: true, alias: "schema", required: true }] }], initialValue: [{ type: i0.Input, args: [{ isSignal: true, alias: "initialValue", required: false }] }], formGroup: [{ type: i0.Input, args: [{ isSignal: true, alias: "formGroup", required: false }] }], index: [{ type: i0.Input, args: [{ isSignal: true, alias: "index", required: false }] }], removable: [{ type: i0.Input, args: [{ isSignal: true, alias: "removable", required: false }] }], remove: [{ type: i0.Output, args: ["remove"] }], title: [{ type: i0.Input, args: [{ isSignal: true, alias: "title", required: false }] }], editable: [{ type: i0.Input, args: [{ isSignal: true, alias: "editable", required: false }] }, { type: i0.Output, args: ["editableChange"] }], addButtonCallback: [{ type: i0.Input, args: [{ isSignal: true, alias: "addButtonCallback", required: false }] }], focusLeaf: [{ type: i0.Input, args: [{ isSignal: true, alias: "focusLeaf", required: false }] }] } });
|
|
608
1038
|
|
|
609
1039
|
/**
|
|
610
|
-
* A tree/detail editor for a schema-built form: the structure (
|
|
611
|
-
*
|
|
612
|
-
*
|
|
613
|
-
*
|
|
614
|
-
*
|
|
1040
|
+
* A tree/detail editor for a schema-built form: the structure (groups, lists,
|
|
1041
|
+
* maps, choices) is a tree on the left, and selecting a node renders that
|
|
1042
|
+
* node's **entire subtree** on the right as a **flat list of sections** — the
|
|
1043
|
+
* node's own fields first, then every descendant's fields, each separated by a
|
|
1044
|
+
* breadcrumb heading (`Service / Deploy scope / …`) instead of nested panels.
|
|
1045
|
+
* Leaf fields render through {@link DynamicRecursiveFormComponent} with a
|
|
1046
|
+
* leaf-only schema slice; choice selectors, map rows, and add controls render
|
|
1047
|
+
* inline in their section. The tree adds row conveniences of its own: `+` on
|
|
1048
|
+
* list and map rows, a delete control on removable rows, and a
|
|
1049
|
+
* "+ Optional field" menu for absent presence children.
|
|
1050
|
+
*
|
|
1051
|
+
* The tree is **derived state**: any structural change to the form — made
|
|
1052
|
+
* through the tree rows or through the detail sections — triggers a rebuild (a
|
|
1053
|
+
* cheap shape signature over `valueChanges` detects it). Node ids are stable
|
|
1054
|
+
* paths, so expansion and selection survive rebuilds. Swapping the `schema` or
|
|
1055
|
+
* `formGroup` input rebinds the editor to the new pair, resetting expansion
|
|
1056
|
+
* and selection.
|
|
1057
|
+
*
|
|
1058
|
+
* The component draws no outer container — only a divider between the tree and
|
|
1059
|
+
* detail panes — so the embedding client owns the surrounding chrome.
|
|
615
1060
|
*/
|
|
616
1061
|
class ConfigEditorComponent {
|
|
617
|
-
schema
|
|
618
|
-
|
|
619
|
-
|
|
1062
|
+
/** The form-description schema whose structure the tree renders. */
|
|
1063
|
+
schema = input.required(...(ngDevMode ? [{ debugName: "schema" }] : []));
|
|
1064
|
+
/** The schema-built reactive group the editor binds to and mutates. */
|
|
1065
|
+
formGroup = input.required(...(ngDevMode ? [{ debugName: "formGroup" }] : []));
|
|
1066
|
+
/** Whether fields accept input and structural controls (add/remove/menus) show. */
|
|
1067
|
+
editable = input(true, ...(ngDevMode ? [{ debugName: "editable" }] : []));
|
|
620
1068
|
root;
|
|
621
1069
|
selected = null;
|
|
1070
|
+
/** The selected subtree as a flat, breadcrumb-separated section list. */
|
|
1071
|
+
sections = [];
|
|
1072
|
+
/** Root-to-selection trail for the detail-pane breadcrumb, computed once per selection. */
|
|
1073
|
+
breadcrumb = [];
|
|
622
1074
|
expanded = new Set();
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
1075
|
+
shape = '';
|
|
1076
|
+
changes;
|
|
1077
|
+
host = inject(ElementRef);
|
|
1078
|
+
/** Stable per-instance ids for the shape signature, so replacing a control (setControl) reads as a structural change. */
|
|
1079
|
+
controlIds = new WeakMap();
|
|
1080
|
+
controlIdSeq = 0;
|
|
1081
|
+
constructor() {
|
|
1082
|
+
// Rebind whenever the schema or form inputs change (a host loading another
|
|
1083
|
+
// config document): derived state resets against the new pair. Wrapped in
|
|
1084
|
+
// untracked so only the two inputs re-trigger the effect.
|
|
1085
|
+
effect(() => {
|
|
1086
|
+
const schema = this.schema();
|
|
1087
|
+
const group = this.formGroup();
|
|
1088
|
+
untracked(() => this.attach(schema, group));
|
|
1089
|
+
});
|
|
1090
|
+
}
|
|
1091
|
+
ngOnDestroy() {
|
|
1092
|
+
this.changes?.unsubscribe();
|
|
1093
|
+
}
|
|
1094
|
+
/** Bind the editor to a schema/form pair: fresh tree, root selection, and shape-sync subscription. */
|
|
1095
|
+
attach(schema, group) {
|
|
1096
|
+
this.changes?.unsubscribe();
|
|
1097
|
+
this.expanded.clear();
|
|
1098
|
+
this.root = this.buildTree(schema, group, schema.label ?? schema.name, '');
|
|
1099
|
+
this.shape = this.shapeOf(group);
|
|
626
1100
|
this.expanded.add(this.root.id);
|
|
627
1101
|
this.select(this.root);
|
|
1102
|
+
// The detail sections mutate the FormGroup directly (presence toggles,
|
|
1103
|
+
// list items, map entries, case switches); a shape change there must
|
|
1104
|
+
// reflect in the tree.
|
|
1105
|
+
this.changes = group.valueChanges.subscribe(() => this.syncShape());
|
|
628
1106
|
}
|
|
629
|
-
select(node) {
|
|
1107
|
+
select(node, reveal = true) {
|
|
630
1108
|
this.selected = node;
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
1109
|
+
this.sections = this.buildSections(node, []);
|
|
1110
|
+
this.breadcrumb = this.pathTo(node);
|
|
1111
|
+
// Navigating retires any pending just-added-leaf focus request.
|
|
1112
|
+
this.focusSectionId = null;
|
|
1113
|
+
this.focusLeafKey = null;
|
|
1114
|
+
// Reveal the selection: expand every ancestor so the row is visible, and
|
|
1115
|
+
// the node itself as it opens on the right. Structural re-syncs pass
|
|
1116
|
+
// reveal=false — they must not re-expand what the user collapsed.
|
|
1117
|
+
if (reveal) {
|
|
1118
|
+
for (const crumb of this.breadcrumb) {
|
|
1119
|
+
if (crumb !== node || this.hasExpandableContent(crumb))
|
|
1120
|
+
this.expanded.add(crumb.id);
|
|
1121
|
+
}
|
|
1122
|
+
}
|
|
639
1123
|
}
|
|
640
1124
|
/**
|
|
641
1125
|
* Root-to-`target` path for the detail-pane breadcrumb (inclusive of both ends),
|
|
642
|
-
* or an empty array if `target` is not in the current tree.
|
|
643
|
-
* call so it stays correct after add/remove/presence mutations rebuild subtrees.
|
|
1126
|
+
* or an empty array if `target` is not in the current tree.
|
|
644
1127
|
*/
|
|
645
1128
|
pathTo(target) {
|
|
646
1129
|
const walk = (node, trail) => {
|
|
@@ -662,153 +1145,469 @@ class ConfigEditorComponent {
|
|
|
662
1145
|
else
|
|
663
1146
|
this.expanded.add(node.id);
|
|
664
1147
|
}
|
|
665
|
-
/**
|
|
1148
|
+
/** Keyboard access for tree rows: Enter/Space selects, ArrowRight expands, ArrowLeft collapses. */
|
|
1149
|
+
onRowKeydown(event, node) {
|
|
1150
|
+
// Keys pressed on the row's inner buttons keep their own meaning.
|
|
1151
|
+
if (event.target !== event.currentTarget)
|
|
1152
|
+
return;
|
|
1153
|
+
switch (event.key) {
|
|
1154
|
+
case 'Enter':
|
|
1155
|
+
case ' ':
|
|
1156
|
+
event.preventDefault();
|
|
1157
|
+
this.select(node);
|
|
1158
|
+
break;
|
|
1159
|
+
case 'ArrowRight':
|
|
1160
|
+
if (this.hasExpandableContent(node) && !this.expanded.has(node.id)) {
|
|
1161
|
+
event.preventDefault();
|
|
1162
|
+
this.expanded.add(node.id);
|
|
1163
|
+
}
|
|
1164
|
+
break;
|
|
1165
|
+
case 'ArrowLeft':
|
|
1166
|
+
if (this.expanded.has(node.id)) {
|
|
1167
|
+
event.preventDefault();
|
|
1168
|
+
this.expanded.delete(node.id);
|
|
1169
|
+
}
|
|
1170
|
+
break;
|
|
1171
|
+
}
|
|
1172
|
+
}
|
|
1173
|
+
/** Whether the row shows an expand twisty: it has child rows to reveal (children or an optionals menu row). */
|
|
1174
|
+
hasExpandableContent(node) {
|
|
1175
|
+
return node.children.length > 0 || (this.editable() && !!node.optionals?.length);
|
|
1176
|
+
}
|
|
1177
|
+
/**
|
|
1178
|
+
* Whether the node's form subtree holds a validation error. Group/choice, list,
|
|
1179
|
+
* and map nodes each check their backing control; `invalid` aggregates over
|
|
1180
|
+
* descendants, so an error anywhere below lights every ancestor row.
|
|
1181
|
+
*/
|
|
1182
|
+
hasError(node) {
|
|
1183
|
+
return !!(node.group?.invalid || node.list?.array.invalid || node.map?.group.invalid);
|
|
1184
|
+
}
|
|
1185
|
+
/** Append a new item to a list node's FormArray (up to `maxItems`), then select it. */
|
|
666
1186
|
addItem(listNode) {
|
|
667
1187
|
const list = listNode.list;
|
|
668
1188
|
if (!list)
|
|
669
1189
|
return;
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
1190
|
+
if (list.maxItems != null && list.array.length >= list.maxItems)
|
|
1191
|
+
return;
|
|
1192
|
+
list.array.push(buildFormFromSchema(list.itemSchema));
|
|
1193
|
+
this.selectByPath(this.join(listNode.id, String(list.array.length - 1)));
|
|
1194
|
+
}
|
|
1195
|
+
/**
|
|
1196
|
+
* Remove a list item from its FormArray (down to `minItems`). List-item
|
|
1197
|
+
* identity is positional, so expansion state under the list is cleared —
|
|
1198
|
+
* otherwise it would silently migrate to the items that shift into the
|
|
1199
|
+
* removed indexes.
|
|
1200
|
+
*/
|
|
680
1201
|
removeItem(listNode, item) {
|
|
681
1202
|
if (!listNode.list || !item.removable)
|
|
682
1203
|
return;
|
|
683
1204
|
if (listNode.list.array.length <= listNode.list.minItems)
|
|
684
1205
|
return;
|
|
1206
|
+
for (const id of [...this.expanded]) {
|
|
1207
|
+
if (id.startsWith(`${listNode.id}/`))
|
|
1208
|
+
this.expanded.delete(id);
|
|
1209
|
+
}
|
|
685
1210
|
listNode.list.array.removeAt(item.removable.index);
|
|
686
|
-
|
|
687
|
-
this.
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
if (!
|
|
1211
|
+
this.selectByPath(listNode.id);
|
|
1212
|
+
this.focusSelectedRow();
|
|
1213
|
+
}
|
|
1214
|
+
/** The just-added optional leaf (section path + key) whose field grabs focus when rendered. */
|
|
1215
|
+
focusSectionId = null;
|
|
1216
|
+
focusLeafKey = null;
|
|
1217
|
+
/** Add an absent optional child from the menu: build its control and select the node it lands on. */
|
|
1218
|
+
addOptional(node, entry) {
|
|
1219
|
+
if (!node.group || node.group.get(entry.key))
|
|
695
1220
|
return;
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
1221
|
+
node.group.addControl(entry.key, buildControl(entry.schema));
|
|
1222
|
+
// A leaf renders in the parent's detail pane; complex kinds become tree nodes.
|
|
1223
|
+
this.selectByPath(entry.schema.kind === 'leaf' ? node.id : this.join(node.id, entry.key));
|
|
1224
|
+
// Adding the last optional removes the menu row that held focus.
|
|
1225
|
+
if (entry.schema.kind !== 'leaf')
|
|
1226
|
+
this.focusSelectedRow();
|
|
1227
|
+
if (entry.schema.kind === 'leaf') {
|
|
1228
|
+
// Set after selection (select() retires any pending request): the new
|
|
1229
|
+
// field should grab focus, like the form's own add button. Retired after
|
|
1230
|
+
// a tick so later re-renders of the section cannot steal focus again.
|
|
1231
|
+
this.focusSectionId = node.id;
|
|
1232
|
+
this.focusLeafKey = entry.key;
|
|
1233
|
+
setTimeout(() => {
|
|
1234
|
+
if (this.focusLeafKey === entry.key) {
|
|
1235
|
+
this.focusSectionId = null;
|
|
1236
|
+
this.focusLeafKey = null;
|
|
1237
|
+
}
|
|
1238
|
+
});
|
|
1239
|
+
}
|
|
1240
|
+
}
|
|
1241
|
+
/** Remove a present optional child node, returning its entry to the parent's menu. */
|
|
1242
|
+
removeOptional(parent, node) {
|
|
1243
|
+
const key = node.presenceRemovable?.key;
|
|
1244
|
+
if (!key || !parent.group)
|
|
1245
|
+
return;
|
|
1246
|
+
parent.group.removeControl(key);
|
|
1247
|
+
this.selectByPath(parent.id);
|
|
1248
|
+
this.focusSelectedRow();
|
|
1249
|
+
}
|
|
1250
|
+
/** The active case name of a choice node, or null when none is selected. */
|
|
1251
|
+
activeCase(node) {
|
|
1252
|
+
return node.choice?.group.get(CASE_KEY)?.value ?? null;
|
|
1253
|
+
}
|
|
1254
|
+
/** The display label of a choice node's active case, or null when no case is selected. */
|
|
1255
|
+
activeCaseLabel(node) {
|
|
1256
|
+
const c = node.choice;
|
|
1257
|
+
const active = this.activeCase(node);
|
|
1258
|
+
return c && active ? (c.schema.caseLabels?.[active] ?? active) : null;
|
|
1259
|
+
}
|
|
1260
|
+
/** The display label for a case: the schema's `caseLabels` entry, else the case name. */
|
|
1261
|
+
caseLabel(choice, caseName) {
|
|
1262
|
+
return choice.caseLabels?.[caseName] ?? caseName;
|
|
1263
|
+
}
|
|
1264
|
+
/** Switch a choice node's active case; the structural sync rebuilds the tree and sections. */
|
|
1265
|
+
switchTreeCase(node, caseName) {
|
|
1266
|
+
const c = node.choice;
|
|
1267
|
+
if (!c)
|
|
1268
|
+
return;
|
|
1269
|
+
switchChoiceCase(c.group, c.schema, caseName);
|
|
1270
|
+
this.selectByPath(node.id);
|
|
1271
|
+
}
|
|
1272
|
+
objectKeys(obj) {
|
|
1273
|
+
return Object.keys(obj);
|
|
1274
|
+
}
|
|
1275
|
+
/** Append a new entry to a complex map node under a generated unique key, then select it. */
|
|
1276
|
+
addTreeMapEntry(mapNode) {
|
|
1277
|
+
const m = mapNode.map;
|
|
1278
|
+
if (!m)
|
|
1279
|
+
return;
|
|
1280
|
+
const key = addMapEntry(m.group, m.schema);
|
|
1281
|
+
if (key != null)
|
|
1282
|
+
this.selectByPath(this.join(mapNode.id, key));
|
|
1283
|
+
}
|
|
1284
|
+
/** Remove a complex map entry (down to `minEntries`). */
|
|
1285
|
+
removeTreeMapEntry(mapNode, entryNode) {
|
|
1286
|
+
const m = mapNode.map;
|
|
1287
|
+
const e = entryNode.mapEntry;
|
|
1288
|
+
if (!m || !e || !removeMapEntry(m.group, m.schema, e.key))
|
|
1289
|
+
return;
|
|
1290
|
+
this.selectByPath(mapNode.id);
|
|
1291
|
+
this.focusSelectedRow();
|
|
1292
|
+
}
|
|
1293
|
+
/**
|
|
1294
|
+
* Commit a rename-on-blur of a map entry's key; on success the entry is
|
|
1295
|
+
* selected under its new path and its fresh key field regains focus (the
|
|
1296
|
+
* rename re-renders the section under a new id, destroying the input that
|
|
1297
|
+
* held focus).
|
|
1298
|
+
*/
|
|
1299
|
+
renameTreeMapEntry(entryNode, rawKey) {
|
|
1300
|
+
const e = entryNode.mapEntry;
|
|
1301
|
+
if (!e)
|
|
1302
|
+
return;
|
|
1303
|
+
if (renameMapEntry(e.mapGroup, e.mapSchema, e.key, rawKey)) {
|
|
1304
|
+
const parentPath = entryNode.id.slice(0, entryNode.id.lastIndexOf('/'));
|
|
1305
|
+
const newId = this.join(parentPath, rawKey.trim());
|
|
1306
|
+
// The entry's descendants keep their expansion under the new identity.
|
|
1307
|
+
for (const id of [...this.expanded]) {
|
|
1308
|
+
if (id === entryNode.id || id.startsWith(`${entryNode.id}/`)) {
|
|
1309
|
+
this.expanded.delete(id);
|
|
1310
|
+
this.expanded.add(newId + id.slice(entryNode.id.length));
|
|
1311
|
+
}
|
|
705
1312
|
}
|
|
706
|
-
|
|
707
|
-
|
|
1313
|
+
this.selectByPath(newId);
|
|
1314
|
+
// Deferred so the rebuilt section's key field exists before focusing.
|
|
1315
|
+
setTimeout(() => this.host.nativeElement.querySelector('.detail .key-field input')?.focus());
|
|
708
1316
|
}
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
1317
|
+
}
|
|
1318
|
+
/** Move keyboard focus to the selected tree row once the action's re-render settles. */
|
|
1319
|
+
focusSelectedRow() {
|
|
1320
|
+
setTimeout(() => this.host.nativeElement.querySelector('.tree-row.selected')?.focus());
|
|
1321
|
+
}
|
|
1322
|
+
/** A muted hint for a section whose node currently renders no content of its own. */
|
|
1323
|
+
emptySectionHint(s) {
|
|
1324
|
+
const n = s.node;
|
|
1325
|
+
if (n.list && !n.children.length)
|
|
1326
|
+
return `No ${n.list.itemLabel} items.`;
|
|
1327
|
+
if (n.map?.complex && !n.children.length)
|
|
1328
|
+
return 'No entries.';
|
|
1329
|
+
if (n.map && !n.map.complex && !Object.keys(n.map.group.controls).length && !this.editable()) {
|
|
1330
|
+
return 'No entries.';
|
|
716
1331
|
}
|
|
1332
|
+
return null;
|
|
717
1333
|
}
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
return
|
|
722
|
-
}
|
|
723
|
-
/**
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
1334
|
+
/** Whether a list node is at `maxItems` (the add control is hidden). */
|
|
1335
|
+
listAtMax(node) {
|
|
1336
|
+
const l = node?.list;
|
|
1337
|
+
return !!l && l.maxItems != null && l.array.length >= l.maxItems;
|
|
1338
|
+
}
|
|
1339
|
+
/** Whether a list node is at `minItems` (item remove controls are hidden). */
|
|
1340
|
+
listAtMin(node) {
|
|
1341
|
+
const l = node?.list;
|
|
1342
|
+
return !!l && l.array.length <= l.minItems;
|
|
1343
|
+
}
|
|
1344
|
+
/** Whether a map node is at `maxEntries` (the add control is hidden). */
|
|
1345
|
+
mapAtMax(node) {
|
|
1346
|
+
const m = node?.map;
|
|
1347
|
+
return !!m && m.schema.maxEntries != null && Object.keys(m.group.controls).length >= m.schema.maxEntries;
|
|
730
1348
|
}
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
const
|
|
1349
|
+
/** Whether a map node is at `minEntries` (entry remove controls are hidden). */
|
|
1350
|
+
mapAtMin(node) {
|
|
1351
|
+
const m = node?.map;
|
|
1352
|
+
return !!m && m.schema.minEntries != null && Object.keys(m.group.controls).length <= m.schema.minEntries;
|
|
1353
|
+
}
|
|
1354
|
+
// --- derived-tree maintenance ----------------------------------------------
|
|
1355
|
+
/** Rebuild the tree from the current form structure if its shape changed. */
|
|
1356
|
+
syncShape() {
|
|
1357
|
+
const shape = this.shapeOf(this.formGroup());
|
|
1358
|
+
if (shape === this.shape)
|
|
1359
|
+
return;
|
|
1360
|
+
this.rebuild();
|
|
1361
|
+
this.reconcileSelection();
|
|
1362
|
+
}
|
|
1363
|
+
/**
|
|
1364
|
+
* A cheap structural signature of a control tree: group keys (plus the active
|
|
1365
|
+
* `__case`), array lengths, leaf placeholders. Value edits don't change it;
|
|
1366
|
+
* added/removed/renamed controls and case switches do. Reading `__case` from
|
|
1367
|
+
* any group is safe because the name is reserved (the builder rejects it as a
|
|
1368
|
+
* case field name or map entry key), so only choice groups carry it.
|
|
1369
|
+
*/
|
|
1370
|
+
shapeOf(control) {
|
|
1371
|
+
if (control instanceof FormGroup) {
|
|
1372
|
+
const inner = Object.keys(control.controls)
|
|
1373
|
+
.sort()
|
|
1374
|
+
.map((k) => `${k}:${this.shapeOf(control.controls[k])}`)
|
|
1375
|
+
.join(',');
|
|
1376
|
+
const active = control.get(CASE_KEY)?.value;
|
|
1377
|
+
return `{#${this.uidOf(control)}${typeof active === 'string' ? `=${active};` : ''}${inner}}`;
|
|
1378
|
+
}
|
|
1379
|
+
if (control instanceof FormArray) {
|
|
1380
|
+
return `[#${this.uidOf(control)}${control.controls.map((c) => this.shapeOf(c)).join(',')}]`;
|
|
1381
|
+
}
|
|
1382
|
+
return '.';
|
|
1383
|
+
}
|
|
1384
|
+
/** The instance id a container contributes to the shape signature. */
|
|
1385
|
+
uidOf(control) {
|
|
1386
|
+
let id = this.controlIds.get(control);
|
|
1387
|
+
if (id == null) {
|
|
1388
|
+
id = ++this.controlIdSeq;
|
|
1389
|
+
this.controlIds.set(control, id);
|
|
1390
|
+
}
|
|
1391
|
+
return id;
|
|
1392
|
+
}
|
|
1393
|
+
/** Rebuild the whole tree from schema + form and refresh the shape signature. */
|
|
1394
|
+
rebuild() {
|
|
1395
|
+
const schema = this.schema();
|
|
1396
|
+
this.root = this.buildTree(schema, this.formGroup(), schema.label ?? schema.name, '');
|
|
1397
|
+
this.shape = this.shapeOf(this.formGroup());
|
|
1398
|
+
}
|
|
1399
|
+
/** Re-point `selected` at the rebuilt tree: same path, else the closest surviving ancestor. */
|
|
1400
|
+
reconcileSelection() {
|
|
1401
|
+
this.selectByPath(this.selected?.id ?? '', false);
|
|
1402
|
+
}
|
|
1403
|
+
/** The node at `path` in the current tree, or null. Walks by id-prefix segments. */
|
|
1404
|
+
byPath(path) {
|
|
1405
|
+
if (path === this.root.id)
|
|
1406
|
+
return this.root;
|
|
1407
|
+
const walk = (node) => {
|
|
1408
|
+
for (const child of node.children) {
|
|
1409
|
+
if (child.id === path)
|
|
1410
|
+
return child;
|
|
1411
|
+
if (path.startsWith(child.id + '/'))
|
|
1412
|
+
return walk(child);
|
|
1413
|
+
}
|
|
1414
|
+
return null;
|
|
1415
|
+
};
|
|
1416
|
+
return walk(this.root);
|
|
1417
|
+
}
|
|
1418
|
+
/** Select the node at `path`, falling back through its ancestors to the root. */
|
|
1419
|
+
selectByPath(path, reveal = true) {
|
|
1420
|
+
let target = this.byPath(path);
|
|
1421
|
+
let trimmed = path;
|
|
1422
|
+
while (!target && trimmed.includes('/')) {
|
|
1423
|
+
trimmed = trimmed.slice(0, trimmed.lastIndexOf('/'));
|
|
1424
|
+
target = this.byPath(trimmed);
|
|
1425
|
+
}
|
|
1426
|
+
this.select(target ?? this.root, reveal);
|
|
1427
|
+
}
|
|
1428
|
+
/**
|
|
1429
|
+
* Flatten a subtree into detail sections, pre-order: the node's own fields
|
|
1430
|
+
* first, then each child as its own breadcrumb-headed section. No nesting
|
|
1431
|
+
* chrome — the headings are the boundaries between children.
|
|
1432
|
+
*/
|
|
1433
|
+
buildSections(node, trail) {
|
|
1434
|
+
const here = [...trail, node];
|
|
1435
|
+
const list = [{ node, trail: here, ...this.sectionContent(node) }];
|
|
1436
|
+
for (const child of node.children)
|
|
1437
|
+
list.push(...this.buildSections(child, here));
|
|
1438
|
+
return list;
|
|
1439
|
+
}
|
|
1440
|
+
/** A section's own renderable fields: a leaf-only schema slice and the group it binds to. */
|
|
1441
|
+
sectionContent(node) {
|
|
1442
|
+
if (node.choice) {
|
|
1443
|
+
const active = this.activeCase(node);
|
|
1444
|
+
const body = active && node.choice.schema.cases[active] ? this.caseAsGroup(node.choice.schema, active) : null;
|
|
1445
|
+
return { schema: body ? this.leafOnly(body) : null, group: node.choice.group };
|
|
1446
|
+
}
|
|
1447
|
+
if (node.schema && node.group) {
|
|
1448
|
+
return { schema: this.leafOnly(node.schema), group: node.group };
|
|
1449
|
+
}
|
|
1450
|
+
return { schema: null, group: null };
|
|
1451
|
+
}
|
|
1452
|
+
/**
|
|
1453
|
+
* The node's own fields as a flattened schema: leaf and leafList children
|
|
1454
|
+
* only. Complex children are rendered as sections of their own, so the
|
|
1455
|
+
* embedded form never draws nested section chrome. Null when there are none.
|
|
1456
|
+
*/
|
|
1457
|
+
leafOnly(schema) {
|
|
1458
|
+
const children = {};
|
|
1459
|
+
for (const key of Object.keys(schema.children)) {
|
|
1460
|
+
const child = schema.children[key];
|
|
1461
|
+
if (child.kind === 'leaf' || child.kind === 'leafList')
|
|
1462
|
+
children[key] = child;
|
|
1463
|
+
}
|
|
1464
|
+
if (!Object.keys(children).length)
|
|
1465
|
+
return null;
|
|
1466
|
+
return { ...schema, root: false, children, appearance: { flatten: true } };
|
|
1467
|
+
}
|
|
1468
|
+
// --- tree construction -----------------------------------------------------
|
|
1469
|
+
buildTree(schema, group, label, path) {
|
|
734
1470
|
const children = [];
|
|
1471
|
+
const optionals = [];
|
|
735
1472
|
for (const key of Object.keys(schema.children)) {
|
|
736
1473
|
const child = schema.children[key];
|
|
737
|
-
if (child.kind === 'leaf') {
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
leafLists.push({ key, node: child });
|
|
742
|
-
}
|
|
743
|
-
else if (child.kind === 'nodeGroup') {
|
|
744
|
-
const childGroup = group.get(key);
|
|
745
|
-
if (child.presence) {
|
|
746
|
-
// Optional group: always a tree node — a placeholder when absent.
|
|
747
|
-
const node = childGroup instanceof FormGroup
|
|
748
|
-
? this.buildTree(child, childGroup, child.label ?? key)
|
|
749
|
-
: this.placeholder(child.label ?? key);
|
|
750
|
-
node.presence = { parentGroup: group, key, schema: child };
|
|
751
|
-
node.present = childGroup instanceof FormGroup;
|
|
752
|
-
children.push(node);
|
|
753
|
-
}
|
|
754
|
-
else if (childGroup instanceof FormGroup) {
|
|
755
|
-
children.push(this.buildTree(child, childGroup, child.label ?? key));
|
|
1474
|
+
if (child.kind === 'leaf' || child.kind === 'leafList') {
|
|
1475
|
+
// Leaves render in the detail pane; an absent presence leaf is offered by the menu.
|
|
1476
|
+
if (child.kind === 'leaf' && child.presence && !group.get(key)) {
|
|
1477
|
+
optionals.push({ key, schema: child, label: this.labelOf(child, key) });
|
|
756
1478
|
}
|
|
1479
|
+
continue;
|
|
757
1480
|
}
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
? array.controls
|
|
763
|
-
.filter((c) => c instanceof FormGroup)
|
|
764
|
-
.map((item, i) => {
|
|
765
|
-
// Just "#n": the item sits under its list node, so repeating the
|
|
766
|
-
// item name (e.g. "Interface #1") only echoes the parent.
|
|
767
|
-
const node = this.buildTree(child.type, item, `#${i + 1}`);
|
|
768
|
-
node.removable = { array, index: i };
|
|
769
|
-
return node;
|
|
770
|
-
})
|
|
771
|
-
: [];
|
|
772
|
-
children.push({
|
|
773
|
-
id: String(this.nextId++),
|
|
774
|
-
label: child.label ?? key,
|
|
775
|
-
children: items,
|
|
776
|
-
leaves: [],
|
|
777
|
-
leafLists: [],
|
|
778
|
-
group: null,
|
|
779
|
-
list: array instanceof FormArray
|
|
780
|
-
? { array, itemSchema: child.type, itemLabel, minItems: child.minItems ?? 0 }
|
|
781
|
-
: undefined,
|
|
782
|
-
});
|
|
1481
|
+
const presence = child.kind !== 'nodeGroupList' && child.presence;
|
|
1482
|
+
if (presence && !group.get(key)) {
|
|
1483
|
+
optionals.push({ key, schema: child, label: this.labelOf(child, key) });
|
|
1484
|
+
continue;
|
|
783
1485
|
}
|
|
784
|
-
|
|
1486
|
+
const node = this.buildChildNode(child, group.get(key), this.labelOf(child, key), this.join(path, key));
|
|
1487
|
+
if (!node)
|
|
1488
|
+
continue;
|
|
1489
|
+
if (presence)
|
|
1490
|
+
node.presenceRemovable = { key };
|
|
1491
|
+
children.push(node);
|
|
1492
|
+
}
|
|
1493
|
+
const node = { id: path, label, children, group, schema };
|
|
1494
|
+
if (optionals.length)
|
|
1495
|
+
node.optionals = optionals;
|
|
1496
|
+
return node;
|
|
1497
|
+
}
|
|
1498
|
+
/** Build the tree node for a single non-leaf child, dispatching on its kind. Null when its control is missing. */
|
|
1499
|
+
buildChildNode(schema, control, label, path) {
|
|
1500
|
+
if (schema.kind === 'nodeGroup') {
|
|
1501
|
+
return control instanceof FormGroup ? this.buildTree(schema, control, label, path) : null;
|
|
1502
|
+
}
|
|
1503
|
+
if (schema.kind === 'nodeGroupList') {
|
|
1504
|
+
const array = control;
|
|
1505
|
+
const itemLabel = schema.type.label ?? schema.type.name;
|
|
1506
|
+
const items = array instanceof FormArray
|
|
1507
|
+
? array.controls
|
|
1508
|
+
.filter((c) => c instanceof FormGroup)
|
|
1509
|
+
.map((item, i) => {
|
|
1510
|
+
// Just "#n": the item sits under its list node, so repeating the
|
|
1511
|
+
// item name (e.g. "Interface #1") only echoes the parent.
|
|
1512
|
+
const node = this.buildTree(schema.type, item, `#${i + 1}`, this.join(path, String(i)));
|
|
1513
|
+
node.removable = { index: i };
|
|
1514
|
+
return node;
|
|
1515
|
+
})
|
|
1516
|
+
: [];
|
|
1517
|
+
return {
|
|
1518
|
+
id: path,
|
|
1519
|
+
label,
|
|
1520
|
+
children: items,
|
|
1521
|
+
group: null,
|
|
1522
|
+
list: array instanceof FormArray
|
|
1523
|
+
? { array, itemSchema: schema.type, itemLabel, minItems: schema.minItems ?? 0, maxItems: schema.maxItems }
|
|
1524
|
+
: undefined,
|
|
1525
|
+
};
|
|
1526
|
+
}
|
|
1527
|
+
if (schema.kind === 'choice') {
|
|
1528
|
+
if (!(control instanceof FormGroup))
|
|
1529
|
+
return null;
|
|
1530
|
+
const active = control.get(CASE_KEY)?.value;
|
|
1531
|
+
const node = active && schema.cases[active]
|
|
1532
|
+
? this.buildTree(this.caseAsGroup(schema, active), control, label, path)
|
|
1533
|
+
: { id: path, label, children: [], group: control };
|
|
1534
|
+
node.schema = undefined;
|
|
1535
|
+
node.choice = { schema, group: control };
|
|
1536
|
+
return node;
|
|
785
1537
|
}
|
|
786
|
-
|
|
1538
|
+
if (schema.kind === 'map') {
|
|
1539
|
+
if (!(control instanceof FormGroup))
|
|
1540
|
+
return null;
|
|
1541
|
+
const complex = schema.value.kind === 'nodeGroup' ||
|
|
1542
|
+
schema.value.kind === 'choice' ||
|
|
1543
|
+
schema.value.kind === 'map' ||
|
|
1544
|
+
schema.value.kind === 'nodeGroupList';
|
|
1545
|
+
const entries = complex
|
|
1546
|
+
? Object.keys(control.controls)
|
|
1547
|
+
.map((key) => {
|
|
1548
|
+
// Index access, not .get(): entry keys are arbitrary runtime data
|
|
1549
|
+
// and .get() would split a key like '10.0.0.1' into a dotted path.
|
|
1550
|
+
const entryNode = this.buildChildNode(schema.value, control.controls[key], key, this.join(path, key));
|
|
1551
|
+
if (entryNode) {
|
|
1552
|
+
entryNode.mapEntry = { mapGroup: control, mapSchema: schema, key };
|
|
1553
|
+
}
|
|
1554
|
+
return entryNode;
|
|
1555
|
+
})
|
|
1556
|
+
.filter((n) => n !== null)
|
|
1557
|
+
: [];
|
|
1558
|
+
return {
|
|
1559
|
+
id: path,
|
|
1560
|
+
label,
|
|
1561
|
+
children: entries,
|
|
1562
|
+
group: null,
|
|
1563
|
+
map: { schema, group: control, complex },
|
|
1564
|
+
};
|
|
1565
|
+
}
|
|
1566
|
+
return null;
|
|
1567
|
+
}
|
|
1568
|
+
/** Synthetic group over a case's normalized fields, so a case body builds like any group. */
|
|
1569
|
+
caseAsGroup(choice, caseName) {
|
|
1570
|
+
return {
|
|
1571
|
+
kind: 'nodeGroup',
|
|
1572
|
+
name: choice.name,
|
|
1573
|
+
children: choice.cases[caseName] ? caseFields(choice.cases[caseName]) : {},
|
|
1574
|
+
};
|
|
1575
|
+
}
|
|
1576
|
+
/** A node's display label: its schema `label`, else its record key. */
|
|
1577
|
+
labelOf(node, key) {
|
|
1578
|
+
return ('label' in node ? node.label : undefined) ?? key;
|
|
1579
|
+
}
|
|
1580
|
+
/** Join a parent path and a segment; the root's path is the empty string. */
|
|
1581
|
+
join(parent, segment) {
|
|
1582
|
+
const seg = this.escapeSeg(segment);
|
|
1583
|
+
return parent ? `${parent}/${seg}` : seg;
|
|
1584
|
+
}
|
|
1585
|
+
/**
|
|
1586
|
+
* Escape a path segment: `/` is the id separator, and map entry keys are
|
|
1587
|
+
* arbitrary runtime data that may contain it. Labels stay unescaped — only
|
|
1588
|
+
* node identities encode.
|
|
1589
|
+
*/
|
|
1590
|
+
escapeSeg(segment) {
|
|
1591
|
+
return segment.replace(/%/g, '%25').replace(/\//g, '%2F');
|
|
787
1592
|
}
|
|
788
1593
|
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.3.9", ngImport: i0, type: ConfigEditorComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
|
|
789
|
-
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "20.3.9", type: ConfigEditorComponent, isStandalone: true, selector: "nff-config-editor", inputs: { schema: "schema", formGroup: "formGroup", editable: "editable" }, ngImport: i0, template: "<div class=\"editor\">\n <nav class=\"tree\">\n <ng-template #treeNode let-node let-depth=\"depth\" let-parent=\"parent\">\n <div\n class=\"tree-row\"\n [class.selected]=\"node === selected\"\n [style.padding-left.px]=\"8 + depth * 16\"\n (click)=\"select(node)\"\n >\n @if (node.children.length) {\n <button\n matIconButton\n class=\"twisty\"\n (click)=\"toggle(node); $event.stopPropagation()\"\n [attr.aria-label]=\"expanded.has(node.id) ? 'Collapse' : 'Expand'\"\n >\n <mat-icon>{{ expanded.has(node.id) ? 'expand_more' : 'chevron_right' }}</mat-icon>\n </button>\n } @else {\n <span class=\"twisty-spacer\"></span>\n }\n\n @if (node.presence) {\n <mat-checkbox\n class=\"presence-check\"\n [checked]=\"!!node.present\"\n [disabled]=\"!editable\"\n (change)=\"setPresence(node, $event.checked)\"\n (click)=\"$event.stopPropagation()\"\n ></mat-checkbox>\n }\n <span class=\"tree-label\" [class.absent]=\"node.presence && !node.present\">{{ node.label }}</span>\n\n @if (editable && node.list) {\n <button\n matIconButton\n class=\"row-btn add\"\n [matTooltip]=\"'Add ' + node.list.itemLabel\"\n (click)=\"addItem(node); $event.stopPropagation()\"\n >\n <mat-icon>add</mat-icon>\n </button>\n }\n @if (editable && node.removable) {\n <button\n matIconButton\n class=\"row-btn remove\"\n matTooltip=\"Remove\"\n (click)=\"removeItem(parent, node); $event.stopPropagation()\"\n >\n <mat-icon>delete</mat-icon>\n </button>\n }\n </div>\n\n @if (expanded.has(node.id)) {\n @for (child of node.children; track child.id) {\n <ng-container\n *ngTemplateOutlet=\"treeNode; context: { $implicit: child, depth: depth + 1, parent: node }\"\n />\n }\n }\n </ng-template>\n\n <ng-container *ngTemplateOutlet=\"treeNode; context: { $implicit: root, depth: 0 }\" />\n </nav>\n\n <section class=\"detail\">\n @if (selected) {\n <nav class=\"breadcrumb\" aria-label=\"Breadcrumb\">\n @for (crumb of pathTo(selected); track crumb.id; let last = $last) {\n @if (last) {\n <span class=\"crumb-current\" aria-current=\"page\">{{ crumb.label }}</span>\n } @else {\n <button type=\"button\" class=\"item-link crumb-link\" (click)=\"select(crumb)\">{{ crumb.label }}</button>\n <span class=\"crumb-sep\" aria-hidden=\"true\">/</span>\n }\n }\n </nav>\n }\n @if (selected && selected.group) {\n <div class=\"group-body\">\n <div class=\"fields\" [formGroup]=\"selected.group\">\n @for (leaf of selected.leaves; track leaf.key) {\n <nff-leaf-renderer\n [leaf_]=\"leaf.node\"\n [control]=\"asFormControl(selected.group.get(leaf.key))\"\n [editable]=\"editable\"\n />\n }\n @for (list of selected.leafLists; track list.key) {\n <nff-leaf-list-renderer\n [leaf_]=\"list.node\"\n [formArray]=\"asFormArray(selected.group.get(list.key))\"\n [editable]=\"editable\"\n />\n }\n @if (!selected.leaves.length && !selected.leafLists.length && !selected.children.length) {\n <p class=\"empty\">This node has no fields.</p>\n }\n </div>\n @if (selected.children.length) {\n <nav class=\"child-links\">\n <h4 class=\"child-links-title\">Sections</h4>\n @for (child of selected.children; track child.id) {\n <button type=\"button\" class=\"item-link child-link\" (click)=\"open(selected, child)\">\n <mat-icon>chevron_right</mat-icon>{{ child.label }}\n </button>\n }\n </nav>\n }\n </div>\n } @else if (selected && selected.presence && !selected.present) {\n <p class=\"empty\">This optional group is off. Tick its box in the tree to add it.</p>\n } @else if (selected && selected.list) {\n @if (selected.children.length) {\n <ul class=\"item-list\">\n @for (item of selected.children; track item.id) {\n <li class=\"item-row\">\n <button type=\"button\" class=\"item-link\" (click)=\"open(selected, item)\">{{ item.label }}</button>\n @if (editable && item.removable) {\n <button\n matIconButton\n class=\"row-btn remove-item\"\n matTooltip=\"Remove\"\n (click)=\"removeItem(selected, item)\"\n >\n <mat-icon>delete</mat-icon>\n </button>\n }\n </li>\n }\n </ul>\n } @else {\n <p class=\"empty\">No {{ selected.list.itemLabel }} items yet.</p>\n }\n @if (editable) {\n <div class=\"list-actions\">\n <button matButton (click)=\"addItem(selected)\">\n <mat-icon>add</mat-icon> Add {{ selected.list.itemLabel }}\n </button>\n </div>\n }\n } @else {\n <p class=\"empty\">Select a node on the left to edit its fields.</p>\n }\n </section>\n</div>\n", styles: [":host{display:block;width:100%}.editor{display:flex;align-items:stretch;gap:16px;min-height:320px}.tree{flex:0 0 260px;overflow:auto;border:1px solid var(--mat-sys-outline-variant, rgba(0, 0, 0, .12));border-radius:8px;padding:4px 0}.tree-row{display:flex;align-items:center;gap:4px;height:32px;cursor:pointer;-webkit-user-select:none;user-select:none;white-space:nowrap}.tree-row:hover{background:var(--mat-sys-surface-container-high, rgba(0, 0, 0, .04))}.tree-row.selected{background:var(--mat-sys-secondary-container, rgba(103, 80, 164, .12));font-weight:600}.twisty{flex:0 0 auto;--mat-icon-button-state-layer-size: 28px;--mat-icon-button-icon-size: 18px}.twisty mat-icon{font-size:18px;width:18px;height:18px}.twisty-spacer{flex:0 0 auto;width:28px}.presence-check{flex:0 0 auto;margin-right:2px}.tree-label{flex:1 1 auto;overflow:hidden;text-overflow:ellipsis}.tree-label.absent{color:var(--mat-sys-on-surface-variant, rgba(0, 0, 0, .55));font-style:italic}.row-btn{flex:0 0 auto;--mat-icon-button-state-layer-size: 28px;--mat-icon-button-icon-size: 18px}.row-btn mat-icon{font-size:18px;width:18px;height:18px}.row-btn.remove{visibility:hidden}.tree-row:hover .row-btn.remove,.tree-row.selected .row-btn.remove{visibility:visible}.detail{flex:1 1 auto;min-width:0;overflow:auto}.breadcrumb{display:flex;flex-wrap:wrap;align-items:baseline;gap:6px;margin:4px 0 12px;font-size:1.15rem}.crumb-current{font-weight:600}.crumb-link{font-size:inherit;padding:0}.crumb-sep{color:var(--mat-sys-on-surface-variant, rgba(0, 0, 0, .5))}.fields{display:flex;flex-direction:column;gap:4px}.empty{color:var(--mat-sys-on-surface-variant, rgba(0, 0, 0, .6));font-style:italic}.item-list{list-style:none;margin:0 0 8px;padding:0}.item-row{display:flex;align-items:center;gap:4px}.item-link{min-width:0;padding:4px 0;border:0;background:none;text-align:left;cursor:pointer;color:var(--mat-sys-primary, #6750a4);font:inherit;text-overflow:ellipsis;overflow:hidden;white-space:nowrap}.item-link:hover{text-decoration:underline}.remove-item{flex:0 0 auto}.list-actions{display:flex;justify-content:flex-end}.group-body{display:flex;align-items:flex-start;gap:24px}.group-body .fields{flex:1 1 auto;min-width:0}.child-links{flex:0 0 200px;display:flex;flex-direction:column;align-items:flex-start;gap:2px;padding-left:16px;border-left:1px solid var(--mat-sys-outline-variant, rgba(0, 0, 0, .12))}.child-links-title{margin:0 0 4px;font-size:.75rem;font-weight:600;letter-spacing:.05em;text-transform:uppercase;color:var(--mat-sys-on-surface-variant, rgba(0, 0, 0, .6))}.child-link{display:inline-flex;align-items:center;gap:2px}.child-link mat-icon{font-size:18px;width:18px;height:18px}\n"], dependencies: [{ kind: "ngmodule", type: ReactiveFormsModule }, { kind: "directive", type: i1.NgControlStatusGroup, selector: "[formGroupName],[formArrayName],[ngModelGroup],[formGroup],form:not([ngNoForm]),[ngForm]" }, { kind: "directive", type: i1.FormGroupDirective, selector: "[formGroup]", inputs: ["formGroup"], outputs: ["ngSubmit"], exportAs: ["ngForm"] }, { kind: "directive", type: NgTemplateOutlet, selector: "[ngTemplateOutlet]", inputs: ["ngTemplateOutletContext", "ngTemplateOutlet", "ngTemplateOutletInjector"] }, { kind: "ngmodule", type: MatIconModule }, { kind: "component", type: i1$1.MatIcon, selector: "mat-icon", inputs: ["color", "inline", "svgIcon", "fontSet", "fontIcon"], exportAs: ["matIcon"] }, { kind: "ngmodule", type: MatButtonModule }, { kind: "component", type: i3$2.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: i3$2.MatIconButton, selector: "button[mat-icon-button], a[mat-icon-button], button[matIconButton], a[matIconButton]", exportAs: ["matButton", "matAnchor"] }, { kind: "ngmodule", type: MatCheckboxModule }, { kind: "component", type: i4.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: "directive", type: MatTooltip, selector: "[matTooltip]", inputs: ["matTooltipPosition", "matTooltipPositionAtOrigin", "matTooltipDisabled", "matTooltipShowDelay", "matTooltipHideDelay", "matTooltipTouchGestures", "matTooltip", "matTooltipClass"], exportAs: ["matTooltip"] }, { kind: "component", type: LeafRendererComponent, selector: "nff-leaf-renderer", inputs: ["leaf_", "control", "initialValue", "removable", "editable"], outputs: ["remove"] }, { kind: "component", type: LeafListRendererComponent, selector: "nff-leaf-list-renderer", inputs: ["leaf_", "initialValue", "formArray", "editable", "minItems"], outputs: ["message"] }] });
|
|
1594
|
+
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "20.3.9", type: ConfigEditorComponent, isStandalone: true, selector: "nff-config-editor", inputs: { schema: { classPropertyName: "schema", publicName: "schema", isSignal: true, isRequired: true, transformFunction: null }, formGroup: { classPropertyName: "formGroup", publicName: "formGroup", isSignal: true, isRequired: true, transformFunction: null }, editable: { classPropertyName: "editable", publicName: "editable", isSignal: true, isRequired: false, transformFunction: null } }, ngImport: i0, template: "<div class=\"editor\">\n <nav class=\"tree\" role=\"tree\" aria-label=\"Configuration structure\">\n <ng-template #treeNode let-node let-depth=\"depth\" let-parent=\"parent\">\n <div\n class=\"tree-row\"\n role=\"treeitem\"\n tabindex=\"0\"\n [class.selected]=\"node === selected\"\n [class.error]=\"hasError(node)\"\n [attr.aria-selected]=\"node === selected\"\n [attr.aria-expanded]=\"hasExpandableContent(node) ? expanded.has(node.id) : null\"\n [attr.aria-level]=\"depth + 1\"\n [style.padding-left.px]=\"8 + depth * 16\"\n (click)=\"select(node)\"\n (keydown)=\"onRowKeydown($event, node)\"\n >\n @if (hasExpandableContent(node)) {\n <button\n matIconButton\n class=\"twisty\"\n tabindex=\"-1\"\n (click)=\"toggle(node); $event.stopPropagation()\"\n [attr.aria-label]=\"expanded.has(node.id) ? 'Collapse' : 'Expand'\"\n >\n <mat-icon>{{ expanded.has(node.id) ? 'expand_more' : 'chevron_right' }}</mat-icon>\n </button>\n } @else {\n <span class=\"twisty-spacer\"></span>\n }\n\n <span class=\"tree-label\" [matTooltip]=\"node.label\">{{ node.label }}</span>\n @if (node.choice && activeCaseLabel(node)) {\n <span class=\"tree-sublabel\" [matTooltip]=\"activeCaseLabel(node)\">{{ activeCaseLabel(node) }}</span>\n }\n @if (hasError(node)) {\n <!-- A non-color error signal beside the red row text. -->\n <mat-icon class=\"row-error-icon\" aria-hidden=\"false\" role=\"img\" aria-label=\"Has validation errors\"\n >error_outline</mat-icon\n >\n }\n\n @if (editable() && node.list && !listAtMax(node)) {\n <button\n matIconButton\n class=\"row-btn add\"\n [matTooltip]=\"'Add ' + node.list.itemLabel\"\n [attr.aria-label]=\"'Add ' + node.list.itemLabel\"\n (click)=\"addItem(node); $event.stopPropagation()\"\n >\n <mat-icon>add</mat-icon>\n </button>\n }\n @if (editable() && node.map?.complex && !mapAtMax(node)) {\n <button\n matIconButton\n class=\"row-btn add\"\n matTooltip=\"Add entry\"\n [attr.aria-label]=\"'Add ' + node.label + ' entry'\"\n (click)=\"addTreeMapEntry(node); $event.stopPropagation()\"\n >\n <mat-icon>add</mat-icon>\n </button>\n }\n @if (editable() && node.removable && !listAtMin(parent)) {\n <button\n matIconButton\n class=\"row-btn remove\"\n matTooltip=\"Remove\"\n [attr.aria-label]=\"'Remove ' + node.label\"\n (click)=\"removeItem(parent, node); $event.stopPropagation()\"\n >\n <mat-icon>delete</mat-icon>\n </button>\n }\n @if (editable() && node.presenceRemovable) {\n <button\n matIconButton\n class=\"row-btn remove\"\n matTooltip=\"Remove\"\n [attr.aria-label]=\"'Remove ' + node.label\"\n (click)=\"removeOptional(parent, node); $event.stopPropagation()\"\n >\n <mat-icon>delete</mat-icon>\n </button>\n }\n @if (editable() && node.mapEntry && !mapAtMin(parent)) {\n <button\n matIconButton\n class=\"row-btn remove\"\n matTooltip=\"Remove entry\"\n [attr.aria-label]=\"'Remove ' + node.label + ' entry'\"\n (click)=\"removeTreeMapEntry(parent, node); $event.stopPropagation()\"\n >\n <mat-icon>delete</mat-icon>\n </button>\n }\n </div>\n\n @if (expanded.has(node.id)) {\n @for (child of node.children; track child.id) {\n <ng-container\n *ngTemplateOutlet=\"treeNode; context: { $implicit: child, depth: depth + 1, parent: node }\"\n />\n }\n @if (editable() && node.optionals?.length) {\n <button\n type=\"button\"\n class=\"tree-row optional-row\"\n [style.padding-left.px]=\"8 + (depth + 1) * 16\"\n [matMenuTriggerFor]=\"treeOptionalsMenu\"\n (click)=\"$event.stopPropagation()\"\n >\n <span class=\"twisty-spacer\"></span>\n <mat-icon class=\"optional-icon\">add</mat-icon>\n <span class=\"optional-label\">Optional field</span>\n </button>\n <mat-menu #treeOptionalsMenu=\"matMenu\">\n @for (opt of node.optionals; track opt.key) {\n <button mat-menu-item (click)=\"addOptional(node, opt)\">{{ opt.label }}</button>\n }\n </mat-menu>\n }\n }\n </ng-template>\n\n <ng-container *ngTemplateOutlet=\"treeNode; context: { $implicit: root, depth: 0 }\" />\n </nav>\n\n <section class=\"detail\">\n @if (selected) {\n <nav class=\"breadcrumb\" aria-label=\"Breadcrumb\">\n @for (crumb of breadcrumb; track crumb.id; let last = $last) {\n @if (last) {\n <span class=\"crumb-current\" aria-current=\"page\">{{ crumb.label }}</span>\n } @else {\n <button type=\"button\" class=\"item-link crumb-link\" (click)=\"select(crumb)\">{{ crumb.label }}</button>\n <span class=\"crumb-sep\" aria-hidden=\"true\">/</span>\n }\n }\n </nav>\n\n <!-- The selected subtree as a flat section list: each descendant's fields\n under a breadcrumb heading \u2014 the headings, not nesting, mark the\n boundary between one child and the next. -->\n @for (s of sections; track s.node.id) {\n @if (s.trail.length > 1) {\n <!-- A heading, not a nav landmark: dozens of identical \"Section\"\n landmarks would flood the assistive-tech landmark list. Its\n accessible name is the trail itself. -->\n <div class=\"section-heading\" role=\"heading\" aria-level=\"3\">\n @for (crumb of s.trail; track crumb.id; let last = $last) {\n @if (last) {\n <span class=\"crumb-current\">{{ crumb.label }}</span>\n } @else {\n <button type=\"button\" class=\"item-link crumb-link\" (click)=\"select(crumb)\">{{ crumb.label }}</button>\n <span class=\"crumb-sep\" aria-hidden=\"true\">/</span>\n }\n }\n </div>\n }\n\n @if (s.node.mapEntry; as entry) {\n <mat-form-field class=\"key-field\" [appearance]=\"editable() ? 'fill' : 'outline'\">\n <mat-label>{{ entry.mapSchema.keyLabel ?? 'Key' }}</mat-label>\n <input\n matInput\n #entryKey\n [readonly]=\"!editable()\"\n [value]=\"entry.key\"\n (change)=\"renameTreeMapEntry(s.node, entryKey.value); entryKey.value = entry.key\"\n />\n </mat-form-field>\n }\n\n @if (s.node.choice; as choice) {\n @if (editable()) {\n <mat-form-field class=\"case-select\" appearance=\"fill\">\n <mat-label>Selected option</mat-label>\n <mat-select [value]=\"activeCase(s.node)\" (selectionChange)=\"switchTreeCase(s.node, $event.value)\">\n @for (caseName of objectKeys(choice.schema.cases); track caseName) {\n <mat-option [value]=\"caseName\">{{ caseLabel(choice.schema, caseName) }}</mat-option>\n }\n </mat-select>\n </mat-form-field>\n } @else {\n <mat-form-field class=\"case-select\" appearance=\"outline\">\n <mat-label>Selected option</mat-label>\n <input matInput readonly [value]=\"activeCaseLabel(s.node) ?? ''\" />\n </mat-form-field>\n }\n }\n\n @if (s.schema && s.group) {\n <nff-dynamic-recursive-form\n [schema]=\"s.schema\"\n [formGroup]=\"s.group\"\n [editable]=\"editable()\"\n [focusLeaf]=\"s.node.id === focusSectionId ? focusLeafKey : null\"\n />\n }\n\n @if (s.node.map && !s.node.map.complex) {\n <nff-node-map-renderer\n [nodeMap]=\"s.node.map.schema\"\n [formGroup]=\"s.node.map.group\"\n [editable]=\"editable()\"\n />\n }\n\n @if (editable() && s.node.map?.complex && !mapAtMax(s.node)) {\n <div class=\"section-actions\">\n <button matButton (click)=\"addTreeMapEntry(s.node)\"><mat-icon>add</mat-icon> Add entry</button>\n </div>\n }\n @if (editable() && s.node.list && !listAtMax(s.node)) {\n <div class=\"section-actions\">\n <button matButton (click)=\"addItem(s.node)\">\n <mat-icon>add</mat-icon> Add {{ s.node.list.itemLabel }}\n </button>\n </div>\n }\n @if (emptySectionHint(s); as hint) {\n <p class=\"empty\">{{ hint }}</p>\n }\n }\n\n @if (\n sections.length === 1 &&\n !sections[0].schema &&\n !sections[0].node.choice &&\n !sections[0].node.map &&\n !sections[0].node.list &&\n !sections[0].node.mapEntry\n ) {\n <p class=\"empty\">This node has no fields.</p>\n }\n } @else {\n <p class=\"empty\">Select a node on the left to edit its fields.</p>\n }\n </section>\n</div>\n", styles: ["@charset \"UTF-8\";:host{display:block;width:100%}.editor{display:flex;align-items:stretch;gap:16px;min-height:320px}.tree{flex:0 0 360px;min-width:360px;overflow:auto;border-right:1px solid var(--mat-sys-outline-variant);padding:4px 12px 4px 0}.tree-row{display:flex;align-items:center;gap:4px;height:32px;cursor:pointer;-webkit-user-select:none;user-select:none;white-space:nowrap}.tree-row:hover{background:var(--mat-sys-surface-container-high)}.tree-row.selected{background:var(--mat-sys-secondary-container);font-weight:600}.tree-row.error .tree-label{color:var(--mat-sys-error)}.row-error-icon{flex:0 0 auto;font-size:18px;width:18px;height:18px;color:var(--mat-sys-error)}.tree-row:focus-visible{outline:2px solid var(--mat-sys-primary);outline-offset:-2px}.twisty{flex:0 0 auto;--mat-icon-button-state-layer-size: 28px;--mat-icon-button-icon-size: 18px}.twisty mat-icon{font-size:18px;width:18px;height:18px}.twisty-spacer{flex:0 0 auto;width:28px}.tree-label{flex:1 1 auto;overflow:hidden;text-overflow:ellipsis}.tree-sublabel{flex:0 1 auto;overflow:hidden;text-overflow:ellipsis;font-size:.85em;color:var(--mat-sys-on-surface-variant)}.tree-sublabel:before{content:\"\\b7 \"}.optional-row{width:100%;border:0;background:none;font:inherit;text-align:left}.optional-icon{flex:0 0 auto;font-size:18px;width:18px;height:18px;color:var(--mat-sys-primary)}.optional-label{flex:1 1 auto;overflow:hidden;text-overflow:ellipsis;color:var(--mat-sys-on-surface-variant);font-style:italic}.row-btn{flex:0 0 auto;--mat-icon-button-state-layer-size: 28px;--mat-icon-button-icon-size: 18px}.row-btn mat-icon{font-size:18px;width:18px;height:18px}.row-btn.add{visibility:visible}.row-btn.remove{visibility:hidden}.tree-row:hover .row-btn.remove,.tree-row.selected .row-btn.remove,.tree-row:focus-within .row-btn.remove{visibility:visible}.detail{flex:1 1 auto;min-width:0;overflow:auto}.breadcrumb{display:flex;flex-wrap:wrap;align-items:baseline;gap:6px;margin:4px 0 12px;font-size:1.15rem}.crumb-current{font-weight:600}.crumb-link{font-size:inherit;padding:0}.crumb-sep{color:var(--mat-sys-on-surface-variant)}.section-heading{display:flex;flex-wrap:wrap;align-items:baseline;gap:6px;margin:20px 0 8px;padding-top:12px;border-top:1px solid var(--mat-sys-outline-variant);font-size:.95rem}.section-heading .crumb-current{font-weight:600}.key-field,.case-select{display:block;max-width:320px;margin-bottom:8px}.section-actions{display:flex;justify-content:flex-start;margin-bottom:8px}.empty{color:var(--mat-sys-on-surface-variant);font-style:italic}.item-link{min-width:0;padding:4px 0;border:0;background:none;text-align:left;cursor:pointer;color:var(--mat-sys-primary);font:inherit;text-overflow:ellipsis;overflow:hidden;white-space:nowrap}.item-link:hover{text-decoration:underline}\n"], dependencies: [{ kind: "directive", type: NgTemplateOutlet, selector: "[ngTemplateOutlet]", inputs: ["ngTemplateOutletContext", "ngTemplateOutlet", "ngTemplateOutletInjector"] }, { kind: "ngmodule", type: MatIconModule }, { kind: "component", type: i1$1.MatIcon, selector: "mat-icon", inputs: ["color", "inline", "svgIcon", "fontSet", "fontIcon"], exportAs: ["matIcon"] }, { kind: "ngmodule", type: MatButtonModule }, { kind: "component", type: i2$1.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$1.MatIconButton, selector: "button[mat-icon-button], a[mat-icon-button], button[matIconButton], a[matIconButton]", exportAs: ["matButton", "matAnchor"] }, { kind: "ngmodule", type: MatMenuModule }, { kind: "component", type: i3$1.MatMenu, selector: "mat-menu", inputs: ["backdropClass", "aria-label", "aria-labelledby", "aria-describedby", "xPosition", "yPosition", "overlapTrigger", "hasBackdrop", "class", "classList"], outputs: ["closed", "close"], exportAs: ["matMenu"] }, { kind: "component", type: i3$1.MatMenuItem, selector: "[mat-menu-item]", inputs: ["role", "disabled", "disableRipple"], exportAs: ["matMenuItem"] }, { kind: "directive", type: i3$1.MatMenuTrigger, selector: "[mat-menu-trigger-for], [matMenuTriggerFor]", inputs: ["mat-menu-trigger-for", "matMenuTriggerFor", "matMenuTriggerData", "matMenuTriggerRestoreFocus"], outputs: ["menuOpened", "onMenuOpen", "menuClosed", "onMenuClose"], exportAs: ["matMenuTrigger"] }, { kind: "ngmodule", type: MatFormFieldModule }, { kind: "component", type: i2.MatFormField, selector: "mat-form-field", inputs: ["hideRequiredMarker", "color", "floatLabel", "appearance", "subscriptSizing", "hintLabel"], exportAs: ["matFormField"] }, { kind: "directive", type: i2.MatLabel, selector: "mat-label" }, { kind: "ngmodule", type: MatInputModule }, { kind: "directive", type: i5.MatInput, selector: "input[matInput], textarea[matInput], select[matNativeControl], input[matNativeControl], textarea[matNativeControl]", inputs: ["disabled", "id", "placeholder", "name", "required", "type", "errorStateMatcher", "aria-describedby", "value", "readonly", "disabledInteractive"], exportAs: ["matInput"] }, { kind: "ngmodule", type: MatSelectModule }, { kind: "component", type: i3.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: i3.MatOption, selector: "mat-option", inputs: ["value", "id", "disabled"], outputs: ["onSelectionChange"], exportAs: ["matOption"] }, { kind: "directive", type: MatTooltip, selector: "[matTooltip]", inputs: ["matTooltipPosition", "matTooltipPositionAtOrigin", "matTooltipDisabled", "matTooltipShowDelay", "matTooltipHideDelay", "matTooltipTouchGestures", "matTooltip", "matTooltipClass"], exportAs: ["matTooltip"] }, { kind: "component", type: DynamicRecursiveFormComponent, selector: "nff-dynamic-recursive-form", inputs: ["schema", "initialValue", "formGroup", "index", "removable", "title", "editable", "addButtonCallback", "focusLeaf"], outputs: ["remove", "editableChange"] }, { kind: "component", type: NodeMapRendererComponent, selector: "nff-node-map-renderer", inputs: ["nodeMap", "formGroup", "editable"] }] });
|
|
790
1595
|
}
|
|
791
1596
|
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.9", ngImport: i0, type: ConfigEditorComponent, decorators: [{
|
|
792
1597
|
type: Component,
|
|
793
1598
|
args: [{ selector: 'nff-config-editor', standalone: true, imports: [
|
|
794
|
-
ReactiveFormsModule,
|
|
795
1599
|
NgTemplateOutlet,
|
|
796
1600
|
MatIconModule,
|
|
797
1601
|
MatButtonModule,
|
|
798
|
-
|
|
1602
|
+
MatMenuModule,
|
|
1603
|
+
MatFormFieldModule,
|
|
1604
|
+
MatInputModule,
|
|
1605
|
+
MatSelectModule,
|
|
799
1606
|
MatTooltip,
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
], template: "<div class=\"editor\">\n <nav class=\"tree\">\n <ng-template #treeNode let-node let-depth=\"depth\" let-parent=\"parent\">\n <div\n class=\"tree-row\"\n [class.selected]=\"node === selected\"\n [style.padding-left.px]=\"8 + depth * 16\"\n (click)=\"select(node)\"\n >\n @if (node.children.length) {\n <button\n matIconButton\n class=\"twisty\"\n (click)=\"toggle(node); $event.stopPropagation()\"\n [attr.aria-label]=\"expanded.has(node.id) ? 'Collapse' : 'Expand'\"\n >\n <mat-icon>{{ expanded.has(node.id) ? 'expand_more' : 'chevron_right' }}</mat-icon>\n </button>\n } @else {\n <span class=\"twisty-spacer\"></span>\n }\n\n @if (node.presence) {\n <mat-checkbox\n class=\"presence-check\"\n [checked]=\"!!node.present\"\n [disabled]=\"!editable\"\n (change)=\"setPresence(node, $event.checked)\"\n (click)=\"$event.stopPropagation()\"\n ></mat-checkbox>\n }\n <span class=\"tree-label\" [class.absent]=\"node.presence && !node.present\">{{ node.label }}</span>\n\n @if (editable && node.list) {\n <button\n matIconButton\n class=\"row-btn add\"\n [matTooltip]=\"'Add ' + node.list.itemLabel\"\n (click)=\"addItem(node); $event.stopPropagation()\"\n >\n <mat-icon>add</mat-icon>\n </button>\n }\n @if (editable && node.removable) {\n <button\n matIconButton\n class=\"row-btn remove\"\n matTooltip=\"Remove\"\n (click)=\"removeItem(parent, node); $event.stopPropagation()\"\n >\n <mat-icon>delete</mat-icon>\n </button>\n }\n </div>\n\n @if (expanded.has(node.id)) {\n @for (child of node.children; track child.id) {\n <ng-container\n *ngTemplateOutlet=\"treeNode; context: { $implicit: child, depth: depth + 1, parent: node }\"\n />\n }\n }\n </ng-template>\n\n <ng-container *ngTemplateOutlet=\"treeNode; context: { $implicit: root, depth: 0 }\" />\n </nav>\n\n <section class=\"detail\">\n @if (selected) {\n <nav class=\"breadcrumb\" aria-label=\"Breadcrumb\">\n @for (crumb of pathTo(selected); track crumb.id; let last = $last) {\n @if (last) {\n <span class=\"crumb-current\" aria-current=\"page\">{{ crumb.label }}</span>\n } @else {\n <button type=\"button\" class=\"item-link crumb-link\" (click)=\"select(crumb)\">{{ crumb.label }}</button>\n <span class=\"crumb-sep\" aria-hidden=\"true\">/</span>\n }\n }\n </nav>\n }\n @if (selected && selected.group) {\n <div class=\"group-body\">\n <div class=\"fields\" [formGroup]=\"selected.group\">\n @for (leaf of selected.leaves; track leaf.key) {\n <nff-leaf-renderer\n [leaf_]=\"leaf.node\"\n [control]=\"asFormControl(selected.group.get(leaf.key))\"\n [editable]=\"editable\"\n />\n }\n @for (list of selected.leafLists; track list.key) {\n <nff-leaf-list-renderer\n [leaf_]=\"list.node\"\n [formArray]=\"asFormArray(selected.group.get(list.key))\"\n [editable]=\"editable\"\n />\n }\n @if (!selected.leaves.length && !selected.leafLists.length && !selected.children.length) {\n <p class=\"empty\">This node has no fields.</p>\n }\n </div>\n @if (selected.children.length) {\n <nav class=\"child-links\">\n <h4 class=\"child-links-title\">Sections</h4>\n @for (child of selected.children; track child.id) {\n <button type=\"button\" class=\"item-link child-link\" (click)=\"open(selected, child)\">\n <mat-icon>chevron_right</mat-icon>{{ child.label }}\n </button>\n }\n </nav>\n }\n </div>\n } @else if (selected && selected.presence && !selected.present) {\n <p class=\"empty\">This optional group is off. Tick its box in the tree to add it.</p>\n } @else if (selected && selected.list) {\n @if (selected.children.length) {\n <ul class=\"item-list\">\n @for (item of selected.children; track item.id) {\n <li class=\"item-row\">\n <button type=\"button\" class=\"item-link\" (click)=\"open(selected, item)\">{{ item.label }}</button>\n @if (editable && item.removable) {\n <button\n matIconButton\n class=\"row-btn remove-item\"\n matTooltip=\"Remove\"\n (click)=\"removeItem(selected, item)\"\n >\n <mat-icon>delete</mat-icon>\n </button>\n }\n </li>\n }\n </ul>\n } @else {\n <p class=\"empty\">No {{ selected.list.itemLabel }} items yet.</p>\n }\n @if (editable) {\n <div class=\"list-actions\">\n <button matButton (click)=\"addItem(selected)\">\n <mat-icon>add</mat-icon> Add {{ selected.list.itemLabel }}\n </button>\n </div>\n }\n } @else {\n <p class=\"empty\">Select a node on the left to edit its fields.</p>\n }\n </section>\n</div>\n", styles: [":host{display:block;width:100%}.editor{display:flex;align-items:stretch;gap:16px;min-height:320px}.tree{flex:0 0 260px;overflow:auto;border:1px solid var(--mat-sys-outline-variant, rgba(0, 0, 0, .12));border-radius:8px;padding:4px 0}.tree-row{display:flex;align-items:center;gap:4px;height:32px;cursor:pointer;-webkit-user-select:none;user-select:none;white-space:nowrap}.tree-row:hover{background:var(--mat-sys-surface-container-high, rgba(0, 0, 0, .04))}.tree-row.selected{background:var(--mat-sys-secondary-container, rgba(103, 80, 164, .12));font-weight:600}.twisty{flex:0 0 auto;--mat-icon-button-state-layer-size: 28px;--mat-icon-button-icon-size: 18px}.twisty mat-icon{font-size:18px;width:18px;height:18px}.twisty-spacer{flex:0 0 auto;width:28px}.presence-check{flex:0 0 auto;margin-right:2px}.tree-label{flex:1 1 auto;overflow:hidden;text-overflow:ellipsis}.tree-label.absent{color:var(--mat-sys-on-surface-variant, rgba(0, 0, 0, .55));font-style:italic}.row-btn{flex:0 0 auto;--mat-icon-button-state-layer-size: 28px;--mat-icon-button-icon-size: 18px}.row-btn mat-icon{font-size:18px;width:18px;height:18px}.row-btn.remove{visibility:hidden}.tree-row:hover .row-btn.remove,.tree-row.selected .row-btn.remove{visibility:visible}.detail{flex:1 1 auto;min-width:0;overflow:auto}.breadcrumb{display:flex;flex-wrap:wrap;align-items:baseline;gap:6px;margin:4px 0 12px;font-size:1.15rem}.crumb-current{font-weight:600}.crumb-link{font-size:inherit;padding:0}.crumb-sep{color:var(--mat-sys-on-surface-variant, rgba(0, 0, 0, .5))}.fields{display:flex;flex-direction:column;gap:4px}.empty{color:var(--mat-sys-on-surface-variant, rgba(0, 0, 0, .6));font-style:italic}.item-list{list-style:none;margin:0 0 8px;padding:0}.item-row{display:flex;align-items:center;gap:4px}.item-link{min-width:0;padding:4px 0;border:0;background:none;text-align:left;cursor:pointer;color:var(--mat-sys-primary, #6750a4);font:inherit;text-overflow:ellipsis;overflow:hidden;white-space:nowrap}.item-link:hover{text-decoration:underline}.remove-item{flex:0 0 auto}.list-actions{display:flex;justify-content:flex-end}.group-body{display:flex;align-items:flex-start;gap:24px}.group-body .fields{flex:1 1 auto;min-width:0}.child-links{flex:0 0 200px;display:flex;flex-direction:column;align-items:flex-start;gap:2px;padding-left:16px;border-left:1px solid var(--mat-sys-outline-variant, rgba(0, 0, 0, .12))}.child-links-title{margin:0 0 4px;font-size:.75rem;font-weight:600;letter-spacing:.05em;text-transform:uppercase;color:var(--mat-sys-on-surface-variant, rgba(0, 0, 0, .6))}.child-link{display:inline-flex;align-items:center;gap:2px}.child-link mat-icon{font-size:18px;width:18px;height:18px}\n"] }]
|
|
803
|
-
}], propDecorators: { schema: [{
|
|
804
|
-
type: Input,
|
|
805
|
-
args: [{ required: true }]
|
|
806
|
-
}], formGroup: [{
|
|
807
|
-
type: Input,
|
|
808
|
-
args: [{ required: true }]
|
|
809
|
-
}], editable: [{
|
|
810
|
-
type: Input
|
|
811
|
-
}] } });
|
|
1607
|
+
DynamicRecursiveFormComponent,
|
|
1608
|
+
NodeMapRendererComponent,
|
|
1609
|
+
], template: "<div class=\"editor\">\n <nav class=\"tree\" role=\"tree\" aria-label=\"Configuration structure\">\n <ng-template #treeNode let-node let-depth=\"depth\" let-parent=\"parent\">\n <div\n class=\"tree-row\"\n role=\"treeitem\"\n tabindex=\"0\"\n [class.selected]=\"node === selected\"\n [class.error]=\"hasError(node)\"\n [attr.aria-selected]=\"node === selected\"\n [attr.aria-expanded]=\"hasExpandableContent(node) ? expanded.has(node.id) : null\"\n [attr.aria-level]=\"depth + 1\"\n [style.padding-left.px]=\"8 + depth * 16\"\n (click)=\"select(node)\"\n (keydown)=\"onRowKeydown($event, node)\"\n >\n @if (hasExpandableContent(node)) {\n <button\n matIconButton\n class=\"twisty\"\n tabindex=\"-1\"\n (click)=\"toggle(node); $event.stopPropagation()\"\n [attr.aria-label]=\"expanded.has(node.id) ? 'Collapse' : 'Expand'\"\n >\n <mat-icon>{{ expanded.has(node.id) ? 'expand_more' : 'chevron_right' }}</mat-icon>\n </button>\n } @else {\n <span class=\"twisty-spacer\"></span>\n }\n\n <span class=\"tree-label\" [matTooltip]=\"node.label\">{{ node.label }}</span>\n @if (node.choice && activeCaseLabel(node)) {\n <span class=\"tree-sublabel\" [matTooltip]=\"activeCaseLabel(node)\">{{ activeCaseLabel(node) }}</span>\n }\n @if (hasError(node)) {\n <!-- A non-color error signal beside the red row text. -->\n <mat-icon class=\"row-error-icon\" aria-hidden=\"false\" role=\"img\" aria-label=\"Has validation errors\"\n >error_outline</mat-icon\n >\n }\n\n @if (editable() && node.list && !listAtMax(node)) {\n <button\n matIconButton\n class=\"row-btn add\"\n [matTooltip]=\"'Add ' + node.list.itemLabel\"\n [attr.aria-label]=\"'Add ' + node.list.itemLabel\"\n (click)=\"addItem(node); $event.stopPropagation()\"\n >\n <mat-icon>add</mat-icon>\n </button>\n }\n @if (editable() && node.map?.complex && !mapAtMax(node)) {\n <button\n matIconButton\n class=\"row-btn add\"\n matTooltip=\"Add entry\"\n [attr.aria-label]=\"'Add ' + node.label + ' entry'\"\n (click)=\"addTreeMapEntry(node); $event.stopPropagation()\"\n >\n <mat-icon>add</mat-icon>\n </button>\n }\n @if (editable() && node.removable && !listAtMin(parent)) {\n <button\n matIconButton\n class=\"row-btn remove\"\n matTooltip=\"Remove\"\n [attr.aria-label]=\"'Remove ' + node.label\"\n (click)=\"removeItem(parent, node); $event.stopPropagation()\"\n >\n <mat-icon>delete</mat-icon>\n </button>\n }\n @if (editable() && node.presenceRemovable) {\n <button\n matIconButton\n class=\"row-btn remove\"\n matTooltip=\"Remove\"\n [attr.aria-label]=\"'Remove ' + node.label\"\n (click)=\"removeOptional(parent, node); $event.stopPropagation()\"\n >\n <mat-icon>delete</mat-icon>\n </button>\n }\n @if (editable() && node.mapEntry && !mapAtMin(parent)) {\n <button\n matIconButton\n class=\"row-btn remove\"\n matTooltip=\"Remove entry\"\n [attr.aria-label]=\"'Remove ' + node.label + ' entry'\"\n (click)=\"removeTreeMapEntry(parent, node); $event.stopPropagation()\"\n >\n <mat-icon>delete</mat-icon>\n </button>\n }\n </div>\n\n @if (expanded.has(node.id)) {\n @for (child of node.children; track child.id) {\n <ng-container\n *ngTemplateOutlet=\"treeNode; context: { $implicit: child, depth: depth + 1, parent: node }\"\n />\n }\n @if (editable() && node.optionals?.length) {\n <button\n type=\"button\"\n class=\"tree-row optional-row\"\n [style.padding-left.px]=\"8 + (depth + 1) * 16\"\n [matMenuTriggerFor]=\"treeOptionalsMenu\"\n (click)=\"$event.stopPropagation()\"\n >\n <span class=\"twisty-spacer\"></span>\n <mat-icon class=\"optional-icon\">add</mat-icon>\n <span class=\"optional-label\">Optional field</span>\n </button>\n <mat-menu #treeOptionalsMenu=\"matMenu\">\n @for (opt of node.optionals; track opt.key) {\n <button mat-menu-item (click)=\"addOptional(node, opt)\">{{ opt.label }}</button>\n }\n </mat-menu>\n }\n }\n </ng-template>\n\n <ng-container *ngTemplateOutlet=\"treeNode; context: { $implicit: root, depth: 0 }\" />\n </nav>\n\n <section class=\"detail\">\n @if (selected) {\n <nav class=\"breadcrumb\" aria-label=\"Breadcrumb\">\n @for (crumb of breadcrumb; track crumb.id; let last = $last) {\n @if (last) {\n <span class=\"crumb-current\" aria-current=\"page\">{{ crumb.label }}</span>\n } @else {\n <button type=\"button\" class=\"item-link crumb-link\" (click)=\"select(crumb)\">{{ crumb.label }}</button>\n <span class=\"crumb-sep\" aria-hidden=\"true\">/</span>\n }\n }\n </nav>\n\n <!-- The selected subtree as a flat section list: each descendant's fields\n under a breadcrumb heading \u2014 the headings, not nesting, mark the\n boundary between one child and the next. -->\n @for (s of sections; track s.node.id) {\n @if (s.trail.length > 1) {\n <!-- A heading, not a nav landmark: dozens of identical \"Section\"\n landmarks would flood the assistive-tech landmark list. Its\n accessible name is the trail itself. -->\n <div class=\"section-heading\" role=\"heading\" aria-level=\"3\">\n @for (crumb of s.trail; track crumb.id; let last = $last) {\n @if (last) {\n <span class=\"crumb-current\">{{ crumb.label }}</span>\n } @else {\n <button type=\"button\" class=\"item-link crumb-link\" (click)=\"select(crumb)\">{{ crumb.label }}</button>\n <span class=\"crumb-sep\" aria-hidden=\"true\">/</span>\n }\n }\n </div>\n }\n\n @if (s.node.mapEntry; as entry) {\n <mat-form-field class=\"key-field\" [appearance]=\"editable() ? 'fill' : 'outline'\">\n <mat-label>{{ entry.mapSchema.keyLabel ?? 'Key' }}</mat-label>\n <input\n matInput\n #entryKey\n [readonly]=\"!editable()\"\n [value]=\"entry.key\"\n (change)=\"renameTreeMapEntry(s.node, entryKey.value); entryKey.value = entry.key\"\n />\n </mat-form-field>\n }\n\n @if (s.node.choice; as choice) {\n @if (editable()) {\n <mat-form-field class=\"case-select\" appearance=\"fill\">\n <mat-label>Selected option</mat-label>\n <mat-select [value]=\"activeCase(s.node)\" (selectionChange)=\"switchTreeCase(s.node, $event.value)\">\n @for (caseName of objectKeys(choice.schema.cases); track caseName) {\n <mat-option [value]=\"caseName\">{{ caseLabel(choice.schema, caseName) }}</mat-option>\n }\n </mat-select>\n </mat-form-field>\n } @else {\n <mat-form-field class=\"case-select\" appearance=\"outline\">\n <mat-label>Selected option</mat-label>\n <input matInput readonly [value]=\"activeCaseLabel(s.node) ?? ''\" />\n </mat-form-field>\n }\n }\n\n @if (s.schema && s.group) {\n <nff-dynamic-recursive-form\n [schema]=\"s.schema\"\n [formGroup]=\"s.group\"\n [editable]=\"editable()\"\n [focusLeaf]=\"s.node.id === focusSectionId ? focusLeafKey : null\"\n />\n }\n\n @if (s.node.map && !s.node.map.complex) {\n <nff-node-map-renderer\n [nodeMap]=\"s.node.map.schema\"\n [formGroup]=\"s.node.map.group\"\n [editable]=\"editable()\"\n />\n }\n\n @if (editable() && s.node.map?.complex && !mapAtMax(s.node)) {\n <div class=\"section-actions\">\n <button matButton (click)=\"addTreeMapEntry(s.node)\"><mat-icon>add</mat-icon> Add entry</button>\n </div>\n }\n @if (editable() && s.node.list && !listAtMax(s.node)) {\n <div class=\"section-actions\">\n <button matButton (click)=\"addItem(s.node)\">\n <mat-icon>add</mat-icon> Add {{ s.node.list.itemLabel }}\n </button>\n </div>\n }\n @if (emptySectionHint(s); as hint) {\n <p class=\"empty\">{{ hint }}</p>\n }\n }\n\n @if (\n sections.length === 1 &&\n !sections[0].schema &&\n !sections[0].node.choice &&\n !sections[0].node.map &&\n !sections[0].node.list &&\n !sections[0].node.mapEntry\n ) {\n <p class=\"empty\">This node has no fields.</p>\n }\n } @else {\n <p class=\"empty\">Select a node on the left to edit its fields.</p>\n }\n </section>\n</div>\n", styles: ["@charset \"UTF-8\";:host{display:block;width:100%}.editor{display:flex;align-items:stretch;gap:16px;min-height:320px}.tree{flex:0 0 360px;min-width:360px;overflow:auto;border-right:1px solid var(--mat-sys-outline-variant);padding:4px 12px 4px 0}.tree-row{display:flex;align-items:center;gap:4px;height:32px;cursor:pointer;-webkit-user-select:none;user-select:none;white-space:nowrap}.tree-row:hover{background:var(--mat-sys-surface-container-high)}.tree-row.selected{background:var(--mat-sys-secondary-container);font-weight:600}.tree-row.error .tree-label{color:var(--mat-sys-error)}.row-error-icon{flex:0 0 auto;font-size:18px;width:18px;height:18px;color:var(--mat-sys-error)}.tree-row:focus-visible{outline:2px solid var(--mat-sys-primary);outline-offset:-2px}.twisty{flex:0 0 auto;--mat-icon-button-state-layer-size: 28px;--mat-icon-button-icon-size: 18px}.twisty mat-icon{font-size:18px;width:18px;height:18px}.twisty-spacer{flex:0 0 auto;width:28px}.tree-label{flex:1 1 auto;overflow:hidden;text-overflow:ellipsis}.tree-sublabel{flex:0 1 auto;overflow:hidden;text-overflow:ellipsis;font-size:.85em;color:var(--mat-sys-on-surface-variant)}.tree-sublabel:before{content:\"\\b7 \"}.optional-row{width:100%;border:0;background:none;font:inherit;text-align:left}.optional-icon{flex:0 0 auto;font-size:18px;width:18px;height:18px;color:var(--mat-sys-primary)}.optional-label{flex:1 1 auto;overflow:hidden;text-overflow:ellipsis;color:var(--mat-sys-on-surface-variant);font-style:italic}.row-btn{flex:0 0 auto;--mat-icon-button-state-layer-size: 28px;--mat-icon-button-icon-size: 18px}.row-btn mat-icon{font-size:18px;width:18px;height:18px}.row-btn.add{visibility:visible}.row-btn.remove{visibility:hidden}.tree-row:hover .row-btn.remove,.tree-row.selected .row-btn.remove,.tree-row:focus-within .row-btn.remove{visibility:visible}.detail{flex:1 1 auto;min-width:0;overflow:auto}.breadcrumb{display:flex;flex-wrap:wrap;align-items:baseline;gap:6px;margin:4px 0 12px;font-size:1.15rem}.crumb-current{font-weight:600}.crumb-link{font-size:inherit;padding:0}.crumb-sep{color:var(--mat-sys-on-surface-variant)}.section-heading{display:flex;flex-wrap:wrap;align-items:baseline;gap:6px;margin:20px 0 8px;padding-top:12px;border-top:1px solid var(--mat-sys-outline-variant);font-size:.95rem}.section-heading .crumb-current{font-weight:600}.key-field,.case-select{display:block;max-width:320px;margin-bottom:8px}.section-actions{display:flex;justify-content:flex-start;margin-bottom:8px}.empty{color:var(--mat-sys-on-surface-variant);font-style:italic}.item-link{min-width:0;padding:4px 0;border:0;background:none;text-align:left;cursor:pointer;color:var(--mat-sys-primary);font:inherit;text-overflow:ellipsis;overflow:hidden;white-space:nowrap}.item-link:hover{text-decoration:underline}\n"] }]
|
|
1610
|
+
}], ctorParameters: () => [], propDecorators: { schema: [{ type: i0.Input, args: [{ isSignal: true, alias: "schema", required: true }] }], formGroup: [{ type: i0.Input, args: [{ isSignal: true, alias: "formGroup", required: true }] }], editable: [{ type: i0.Input, args: [{ isSignal: true, alias: "editable", required: false }] }] } });
|
|
812
1611
|
|
|
813
1612
|
/*
|
|
814
1613
|
* Public API Surface of ng-form-foundry
|
|
@@ -818,5 +1617,5 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.9", ngImpor
|
|
|
818
1617
|
* Generated bundle index. Do not edit.
|
|
819
1618
|
*/
|
|
820
1619
|
|
|
821
|
-
export { CASE_KEY, ConfigEditorComponent, DynamicRecursiveFormComponent, LeafEnumRendererComponent, LeafListRendererComponent, LeafRendererComponent, NodeGroupListRendererComponent, buildControl, buildFormFromSchema, defineSchema };
|
|
1620
|
+
export { CASE_KEY, ConfigEditorComponent, DynamicRecursiveFormComponent, LeafEnumRendererComponent, LeafListRendererComponent, LeafRendererComponent, NodeGroupListRendererComponent, NodeMapRendererComponent, addMapEntry, buildControl, buildFormFromSchema, caseFields, defineSchema, removeMapEntry, renameMapEntry, resolveChoiceCase, switchChoiceCase };
|
|
822
1621
|
//# sourceMappingURL=ng-form-foundry.mjs.map
|