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 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 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*.
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. Fall back to a
197
- // single empty group only when no initial data is supplied.
198
- const values = Array.isArray(initial) ? initial : [null];
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 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.
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
- /** Pick the case whose fields most overlap the initial data (none if nothing matches). */
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 (!initial)
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 bestScore = 0;
262
+ let bestRank;
238
263
  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;
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
- return new FormGroup(controls);
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