ng-form-foundry 0.3.1 → 0.3.3
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 +12 -0
- package/fesm2022/ng-form-foundry.mjs +127 -25
- package/fesm2022/ng-form-foundry.mjs.map +1 -1
- package/index.d.ts +26 -4
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -90,6 +90,18 @@ this.form.getRawValue(); // full value, typed to the schema
|
|
|
90
90
|
this.form.valid; // validity from the schema's constraint validators
|
|
91
91
|
```
|
|
92
92
|
|
|
93
|
+
If the schema contains `choice` nodes, `getRawValue()` carries their `__case`
|
|
94
|
+
discriminators; `serializeForm(schema, form)` returns the value with them
|
|
95
|
+
stripped — the inline wire encoding, which `buildFormFromSchema` accepts back
|
|
96
|
+
as `initial` (the active case is re-inferred from which fields are present and
|
|
97
|
+
required).
|
|
98
|
+
|
|
99
|
+
Validity mirrors what would go on the wire: `presence` fields are absent until
|
|
100
|
+
enabled and required while enabled (unless `nullable`), a `mandatory` or
|
|
101
|
+
enabled-presence `choice` errors until a case is picked, and lists start empty
|
|
102
|
+
rather than seeding a placeholder entry. A valid form serializes to a value
|
|
103
|
+
that satisfies the schema's own constraints.
|
|
104
|
+
|
|
93
105
|
## Documentation
|
|
94
106
|
|
|
95
107
|
Full guide, schema reference, and worked examples:
|
|
@@ -55,12 +55,12 @@ function hasPresence(node) {
|
|
|
55
55
|
}
|
|
56
56
|
/**
|
|
57
57
|
* Whether a presence child should start absent: there is no initial data
|
|
58
|
-
* object, or its key is missing from it. A key
|
|
59
|
-
* `null` value keeps its control — `null` is
|
|
60
|
-
* presence is about the *absent key*.
|
|
58
|
+
* object (a scalar seed counts as none), or its key is missing from it. A key
|
|
59
|
+
* that is present with an explicit `null` value keeps its control — `null` is
|
|
60
|
+
* a value (a nullable leaf's), while presence is about the *absent key*.
|
|
61
61
|
*/
|
|
62
62
|
function presenceAbsent(initial, key) {
|
|
63
|
-
return initial == null || !(key in initial);
|
|
63
|
+
return initial == null || typeof initial !== 'object' || !(key in initial);
|
|
64
64
|
}
|
|
65
65
|
function enumValidator(choices) {
|
|
66
66
|
const set = new Set(choices);
|
|
@@ -131,6 +131,14 @@ function buildLeafControl(leaf, initial) {
|
|
|
131
131
|
const validators = [];
|
|
132
132
|
if ('required' in leaf && leaf.required)
|
|
133
133
|
validators.push(Validators.required);
|
|
134
|
+
// A presence leaf only ever has a control while enabled, and enabled means
|
|
135
|
+
// the key goes on the wire — an empty materialized value would serialize as
|
|
136
|
+
// null and fail typed-schema validation. So materialized ⇒ must hold a
|
|
137
|
+
// value; disable the field to omit it. A nullable presence leaf is exempt:
|
|
138
|
+
// explicit null is one of its legal values.
|
|
139
|
+
else if (leaf.presence === true && leaf.nullable !== true) {
|
|
140
|
+
validators.push(Validators.required);
|
|
141
|
+
}
|
|
134
142
|
if ('type' in leaf && leaf.type === 'enum') {
|
|
135
143
|
const choices = leaf.enum;
|
|
136
144
|
validators.push(enumValidator(choices));
|
|
@@ -170,9 +178,11 @@ function buildLeafControl(leaf, initial) {
|
|
|
170
178
|
validators,
|
|
171
179
|
});
|
|
172
180
|
}
|
|
181
|
+
// The list value with no seed data is the empty array: a phantom null entry
|
|
182
|
+
// would fail validation of the serialized value against typed item schemas,
|
|
183
|
+
// and an empty list is the honest wire shape (renderers offer the add row).
|
|
173
184
|
function buildLeafListControl(list, initial) {
|
|
174
|
-
const values = (Array.isArray(initial) ? initial : undefined) ??
|
|
175
|
-
list.default ?? [null];
|
|
185
|
+
const values = (Array.isArray(initial) ? initial : undefined) ?? list.default ?? [];
|
|
176
186
|
return new FormArray(values.map((v) => new FormControl(v)));
|
|
177
187
|
}
|
|
178
188
|
function buildNodeGroupControl(group, initial) {
|
|
@@ -193,9 +203,10 @@ function buildNodeGroupControl(group, initial) {
|
|
|
193
203
|
return new FormGroup(controls);
|
|
194
204
|
}
|
|
195
205
|
function buildNodeGroupListControl(list, initial = null) {
|
|
196
|
-
// `initial` is the runtime data array — one group per element.
|
|
197
|
-
//
|
|
198
|
-
|
|
206
|
+
// `initial` is the runtime data array — one group per element. With no data
|
|
207
|
+
// the list is empty: seeding a phantom all-null group would put an invalid
|
|
208
|
+
// member on the wire (see buildLeafListControl).
|
|
209
|
+
const values = Array.isArray(initial) ? initial : [];
|
|
199
210
|
return new FormArray(values.map((v) => buildNodeGroupControl(list.type, v)));
|
|
200
211
|
}
|
|
201
212
|
/**
|
|
@@ -219,9 +230,10 @@ function caseFields(body) {
|
|
|
219
230
|
}
|
|
220
231
|
/**
|
|
221
232
|
* The active case of a choice: an explicit `__case` in the initial value, else
|
|
222
|
-
* the case
|
|
223
|
-
* `__case`), else the schema `default`. This lets a choice
|
|
224
|
-
* instance data whose branch is discriminated by which fields
|
|
233
|
+
* the case {@link inferChoiceCase} ranks best against the initial data (inline
|
|
234
|
+
* wire data carries no `__case`), else the schema `default`. This lets a choice
|
|
235
|
+
* seed from real instance data whose branch is discriminated by which fields
|
|
236
|
+
* are present and required.
|
|
225
237
|
*/
|
|
226
238
|
function resolveChoiceCase(choice, initial) {
|
|
227
239
|
const explicit = initial?.[CASE_KEY];
|
|
@@ -229,29 +241,64 @@ function resolveChoiceCase(choice, initial) {
|
|
|
229
241
|
return explicit;
|
|
230
242
|
return inferChoiceCase(choice, initial) ?? choice.default;
|
|
231
243
|
}
|
|
232
|
-
/**
|
|
244
|
+
/**
|
|
245
|
+
* Pick the active case from inline wire data (which carries no `__case`).
|
|
246
|
+
*
|
|
247
|
+
* Candidates are the cases sharing at least one field name with the data; when
|
|
248
|
+
* none does, the caller falls back to the schema `default`. Candidates are
|
|
249
|
+
* ranked by, in order: fewest data keys the case has no field for (the case
|
|
250
|
+
* must be able to hold the data), fewest non-presence fields absent from the
|
|
251
|
+
* data (fields the form would have to materialize empty — this is how
|
|
252
|
+
* required-set-discriminated `oneOf` branches differ, e.g. a branch requiring
|
|
253
|
+
* `{ueId, qosId}` vs one requiring only `{qosId}`), most matched fields, and
|
|
254
|
+
* finally declaration order. Presence fields are exempt from the absence count
|
|
255
|
+
* because their absence is itself a legal state of the data.
|
|
256
|
+
*/
|
|
233
257
|
function inferChoiceCase(choice, initial) {
|
|
234
|
-
if (
|
|
258
|
+
if (initial == null || typeof initial !== 'object' || Array.isArray(initial))
|
|
235
259
|
return undefined;
|
|
260
|
+
const dataKeys = new Set(Object.keys(initial).filter((k) => k !== CASE_KEY));
|
|
236
261
|
let best;
|
|
237
|
-
let
|
|
262
|
+
let bestRank;
|
|
238
263
|
for (const name of Object.keys(choice.cases)) {
|
|
239
|
-
const fields =
|
|
240
|
-
let
|
|
241
|
-
for (const
|
|
242
|
-
if (
|
|
243
|
-
|
|
244
|
-
if (
|
|
245
|
-
|
|
264
|
+
const fields = caseFields(choice.cases[name]);
|
|
265
|
+
let matched = 0;
|
|
266
|
+
for (const key of dataKeys)
|
|
267
|
+
if (key in fields)
|
|
268
|
+
matched++;
|
|
269
|
+
if (matched === 0)
|
|
270
|
+
continue;
|
|
271
|
+
const missing = Object.keys(fields).filter((f) => !hasPresence(fields[f]) && !dataKeys.has(f)).length;
|
|
272
|
+
const rank = [dataKeys.size - matched, missing, -matched];
|
|
273
|
+
if (bestRank === undefined || lexLess(rank, bestRank)) {
|
|
274
|
+
bestRank = rank;
|
|
246
275
|
best = name;
|
|
247
276
|
}
|
|
248
277
|
}
|
|
249
278
|
return best;
|
|
250
279
|
}
|
|
280
|
+
/** Strictly-less comparison of two equal-length rank vectors, first difference wins. */
|
|
281
|
+
function lexLess(a, b) {
|
|
282
|
+
for (let i = 0; i < a.length; i++) {
|
|
283
|
+
if (a[i] !== b[i])
|
|
284
|
+
return a[i] < b[i];
|
|
285
|
+
}
|
|
286
|
+
return false;
|
|
287
|
+
}
|
|
288
|
+
/**
|
|
289
|
+
* Group error while a choice that must resolve to a case has none selected.
|
|
290
|
+
* Attached to `mandatory` choices (a case is always due) and `presence` choices
|
|
291
|
+
* (enabled means the key serializes, and `{}` satisfies no case) — a plain
|
|
292
|
+
* optional choice stays validator-free, `{ __case: null }` and all.
|
|
293
|
+
*/
|
|
294
|
+
function caseRequiredValidator() {
|
|
295
|
+
return (group) => group.get(CASE_KEY)?.value == null ? { caseRequired: true } : null;
|
|
296
|
+
}
|
|
251
297
|
/**
|
|
252
298
|
* Build the FormGroup for a choice: a `__case` control holding the active case
|
|
253
299
|
* name plus that case's field controls. Only the active case's fields are built,
|
|
254
|
-
* matching the inline YANG encoding; switching the case swaps them.
|
|
300
|
+
* matching the inline YANG encoding; switching the case swaps them. Mandatory
|
|
301
|
+
* and presence choices carry {@link caseRequiredValidator}.
|
|
255
302
|
*/
|
|
256
303
|
function buildChoiceControl(choice, initial) {
|
|
257
304
|
const active = resolveChoiceCase(choice, initial);
|
|
@@ -266,7 +313,8 @@ function buildChoiceControl(choice, initial) {
|
|
|
266
313
|
controls[key] = buildControl(caseChildren[key], initial?.[key]);
|
|
267
314
|
}
|
|
268
315
|
}
|
|
269
|
-
|
|
316
|
+
const needsCase = choice.mandatory === true || choice.presence === true;
|
|
317
|
+
return new FormGroup(controls, needsCase ? { validators: caseRequiredValidator() } : undefined);
|
|
270
318
|
}
|
|
271
319
|
/**
|
|
272
320
|
* Switch a choice's FormGroup to `caseName`: sets `__case`, removes every other
|
|
@@ -469,6 +517,60 @@ function buildControl(node, initial) {
|
|
|
469
517
|
function buildFormFromSchema(schema, initial = null) {
|
|
470
518
|
return buildNodeGroupControl(schema, initial);
|
|
471
519
|
}
|
|
520
|
+
/**
|
|
521
|
+
* The wire value at `node`: `value` rebuilt with every choice discriminator
|
|
522
|
+
* removed.
|
|
523
|
+
*
|
|
524
|
+
* A choice's form value is `{ __case, ...fields }` ({@link CASE_KEY}); its wire
|
|
525
|
+
* encoding is the active case's fields inline, with no discriminator — the case
|
|
526
|
+
* is recovered from the field shape when the data is seeded back in
|
|
527
|
+
* ({@link resolveChoiceCase}). The walk is schema-driven: only positions the
|
|
528
|
+
* schema declares as choices are stripped, so a group child or map entry that
|
|
529
|
+
* happens to be named `__case` passes through untouched. Values at positions
|
|
530
|
+
* the schema does not describe pass through unchanged.
|
|
531
|
+
*/
|
|
532
|
+
function toWireValue(node, value) {
|
|
533
|
+
if (value === null || typeof value !== 'object')
|
|
534
|
+
return value;
|
|
535
|
+
if (isChoice(node)) {
|
|
536
|
+
const { [CASE_KEY]: active, ...rest } = value;
|
|
537
|
+
const fields = typeof active === 'string' && node.cases[active] ? caseFields(node.cases[active]) : {};
|
|
538
|
+
const out = {};
|
|
539
|
+
for (const key of Object.keys(rest)) {
|
|
540
|
+
out[key] = key in fields ? toWireValue(fields[key], rest[key]) : rest[key];
|
|
541
|
+
}
|
|
542
|
+
return out;
|
|
543
|
+
}
|
|
544
|
+
if (isNodeGroup(node)) {
|
|
545
|
+
const source = value;
|
|
546
|
+
const out = {};
|
|
547
|
+
for (const key of Object.keys(source)) {
|
|
548
|
+
out[key] = key in node.children ? toWireValue(node.children[key], source[key]) : source[key];
|
|
549
|
+
}
|
|
550
|
+
return out;
|
|
551
|
+
}
|
|
552
|
+
if (isNodeGroupList(node)) {
|
|
553
|
+
return Array.isArray(value) ? value.map((item) => toWireValue(node.type, item)) : value;
|
|
554
|
+
}
|
|
555
|
+
if (isMap(node)) {
|
|
556
|
+
const source = value;
|
|
557
|
+
const out = {};
|
|
558
|
+
for (const key of Object.keys(source))
|
|
559
|
+
out[key] = toWireValue(node.value, source[key]);
|
|
560
|
+
return out;
|
|
561
|
+
}
|
|
562
|
+
return value;
|
|
563
|
+
}
|
|
564
|
+
/**
|
|
565
|
+
* Serialize a form built by {@link buildFormFromSchema} to its wire value:
|
|
566
|
+
* `form.getRawValue()` with every choice's {@link CASE_KEY} discriminator
|
|
567
|
+
* stripped (see {@link toWireValue}). The result is the inline encoding that
|
|
568
|
+
* `buildFormFromSchema` accepts back as `initial`, so serialize → rebuild
|
|
569
|
+
* round-trips the value.
|
|
570
|
+
*/
|
|
571
|
+
function serializeForm(schema, form) {
|
|
572
|
+
return toWireValue(schema, form.getRawValue());
|
|
573
|
+
}
|
|
472
574
|
/**
|
|
473
575
|
* Capture a schema literal with its exact type while checking it against
|
|
474
576
|
* `NodeGroup`.
|
|
@@ -1617,5 +1719,5 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.3.9", ngImpor
|
|
|
1617
1719
|
* Generated bundle index. Do not edit.
|
|
1618
1720
|
*/
|
|
1619
1721
|
|
|
1620
|
-
export { CASE_KEY, ConfigEditorComponent, DynamicRecursiveFormComponent, LeafEnumRendererComponent, LeafListRendererComponent, LeafRendererComponent, NodeGroupListRendererComponent, NodeMapRendererComponent, addMapEntry, buildControl, buildFormFromSchema, caseFields, defineSchema, removeMapEntry, renameMapEntry, resolveChoiceCase, switchChoiceCase };
|
|
1722
|
+
export { CASE_KEY, ConfigEditorComponent, DynamicRecursiveFormComponent, LeafEnumRendererComponent, LeafListRendererComponent, LeafRendererComponent, NodeGroupListRendererComponent, NodeMapRendererComponent, addMapEntry, buildControl, buildFormFromSchema, caseFields, defineSchema, removeMapEntry, renameMapEntry, resolveChoiceCase, serializeForm, switchChoiceCase, toWireValue };
|
|
1621
1723
|
//# sourceMappingURL=ng-form-foundry.mjs.map
|