@jarenjs/forms 0.56.0 → 0.66.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 CHANGED
@@ -247,10 +247,11 @@ wrong:
247
247
  - an **item-template assert quantifies over the ELEMENTS**, not over the
248
248
  selected leaf values — quantifying over leaves silently skips an element
249
249
  that lacks the member, where the keystroke path evaluates it with `null`;
250
- - an assert on a field with a `visible` rule is **guarded by it**, holding
251
- vacuously while the field is hidden which is what the keystroke path
252
- already does, since `buildFormViewModel` drops hidden nodes and their
253
- errors never render or count.
250
+ - an assert is **guarded by the field's own and ancestor `visible` rules**,
251
+ each with its own value/pointer bindings, holding vacuously while the
252
+ field is hidden which is what the keystroke path already does, since
253
+ `buildFormViewModel` drops hidden subtrees and their errors never render
254
+ or count. Ordinary schema constraints still apply to retained values.
254
255
 
255
256
  One divergence remains, and is inherent: `$pointer` for template elements
256
257
  stays the template pointer, because element indexes are a render-time notion.
@@ -364,20 +365,23 @@ Enum option labels come from the JSON Schema idiom
364
365
  `oneOf: [{ "const": "nl", "title": "Netherlands" }, ...]` (treated as an
365
366
  enum with per-option titles) or `String(value)`, each through
366
367
  `t('<base>#enum/<value>', fallback)`; the labels land on
367
- `field.enumLabels`, parallel to `field.enumValues`.
368
+ `field.enumLabels`, parallel to `field.enumValues`. An explicit `enum`
369
+ supplies its own values and `String(value)` labels even when `oneOf` is
370
+ also present. The view model selects object and array options by JSON
371
+ equality, so values loaded from JSON keep their selection.
368
372
 
369
373
  ## Data helpers
370
374
 
371
375
  Form data keeps plain JSON semantics — an untouched field is *absent*, not an empty string. Pointers parse, read AND write through the [`@jarenjs/json`](../json) engines (RFC 6901, one implementation repo-wide): reads hit a compiled-getter cache and allocate nothing, and writes run on the same copy-on-write kernel as the patch module (`compileJSONPointerSetter` with `parents: 'create'`), so untouched siblings are shared by reference on every keystroke — which feeds the JSLT memo and the view patcher's reference-equality fast path downstream:
372
376
 
373
- - `createInitialData(model)` — defaults and `const` values filled in, everything else absent
377
+ - `createInitialData(model)` — defaults and `const` values filled in as own JSON members (including `__proto__`), everything else absent
374
378
  - `parseFieldInput(field, raw)` — input coercion (`''` → undefined, numeric strings → numbers, enum options → typed values)
375
379
  - `getValueAtPointer` / `setValueAtPointer` / `appendItem` / `removeItemAt` — immutable updates addressed by JSON pointer
376
380
  - `createItemValue(field.item)` — starter value for a new array item
377
381
 
378
382
  ## The view model — one render tree per instant
379
383
 
380
- `buildFormViewModel(model, data, options)` composes everything above — the field tree, the current data, per-field validation and `x-form` rule state — into **one plain-JSON render tree**: the "computed view" layer this README promised. Each node carries `pointer`, `label`, `control`, `value` (`x-form.computed` wins, `null` when absent), precomputed select `options` (with `selected`), localized `errors`, `enabled`, and the write discipline flags (`element`: array elements must be written with RFC 6902 `replace`, since `add` inserts; `removable`; `addValue` from `createItemValue`). Rule-hidden fields are *excluded* — a renderer cannot leak hidden data by accident. Array item templates expand per data element with concrete pointers (`/lines/2/amount`), matching the pointer keys of `evaluateFormRules` and `validateAllFields`.
384
+ `buildFormViewModel(model, data, options)` composes everything above — the field tree, the current data, per-field validation and `x-form` rule state — into **one plain-JSON render tree**: the "computed view" layer this README promised. Each node carries `pointer`, `label`, `control`, `value` (`x-form.computed` wins, `null` when absent), precomputed select `options` (with `selected`), localized `errors`, `enabled`, and the write discipline flags (`element`: array elements must be written with RFC 6902 `replace`, since `add` inserts; `removable`; `addValue` from `createItemValue`). Rule-hidden fields are *excluded* — a renderer cannot leak hidden data by accident. Array item templates expand per data element with concrete pointers (`/lines/2/amount`), matching the pointer keys of `evaluateFormRules` and `validateAllFields`. A short tuple's `addValue` comes from its next prefix slot; the tail item template supplies starters only after the prefix is filled.
381
385
 
382
386
  ```javascript
383
387
  const model = buildFormModel(schema);
@@ -459,6 +463,18 @@ tree.session;
459
463
 
460
464
  `session.dirty`/`session.dirtyPaths` are the **navigation-guard authority**: they come from a full JSON diff of `initial` against the current data — independent of what is rendered — so removed array tails, members removed or added (including explicit `null`), and values retained under rule-hidden fields all count, each contributing its pointer. The diff walks **own** keys only (`Object.hasOwn`), so hostile-but-legal member names like `constructor` or a JSON-parsed `__proto__` diff as data, never through the prototype chain, and it RFC 6901-encodes every member name as it builds the pointer (`~` → `~0`, `/` → `~1`, the same `encodeJSONPointerSegment` the model, rule and validation walks use) — so a key containing a slash or a tilde cannot collide with a nested path, and the entries of `dirtyPaths` feed straight back into `getValueAtPointer`. To keep the authority unconditional, a root-level `x-form` `visible` rule is rejected by `compileFormRules` with a `TypeError` — hiding the whole form would null the render tree *and* its summary; gate whole-form visibility at the mount boundary instead. The per-node `dirty`/`errors` members remain the render-layer, visible-only summary. Without `session`, the tree is byte-identical to the sessionless shape.
461
465
 
466
+ ## Exports
467
+
468
+ Every subpath a consumer can import, derived from the manifest by
469
+ `npm run docs:derive` (`npm run docs:check` fails when the two drift):
470
+
471
+ <!--fact:exports.forms-->
472
+ | Import | Kind | Declarations |
473
+ |---|---|---|
474
+ | `@jarenjs/forms` | JavaScript | declared |
475
+ | `@jarenjs/forms/package.json` | metadata | — |
476
+ <!--/fact-->
477
+
462
478
  ## Development
463
479
 
464
480
  Unit tests live in `test/forms/` at the repository root. See the repository [README](../../README.md) for the full Jaren documentation, and the [ROADMAP](../../docs/ROADMAP.md) for planned forms work (rule dependency memoization, hidden-field pruning on submit, computed views through JSLT).
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@jarenjs/forms",
3
3
  "private": false,
4
- "version": "0.56.0",
4
+ "version": "0.66.1",
5
5
  "type": "module",
6
6
  "main": "./src/index.js",
7
7
  "types": "./dist/types/index.d.ts",
@@ -47,9 +47,9 @@
47
47
  "prepack": "npm run build:types"
48
48
  },
49
49
  "dependencies": {
50
- "@jarenjs/core": "^0.56.0",
51
- "@jarenjs/formats": "^0.56.0",
52
- "@jarenjs/json": "^0.56.0",
53
- "@jarenjs/validate": "^0.56.0"
50
+ "@jarenjs/core": "^0.66.1",
51
+ "@jarenjs/formats": "^0.66.1",
52
+ "@jarenjs/json": "^0.66.1",
53
+ "@jarenjs/validate": "^0.66.1"
54
54
  }
55
55
  }
package/src/data.js CHANGED
@@ -13,7 +13,7 @@
13
13
  * cached by pointer string and reads are allocation-free.
14
14
  */
15
15
 
16
- import { equalsJson } from '@jarenjs/core/object';
16
+ import { equalsJson, setObjectMember } from '@jarenjs/core/object';
17
17
  import { createBoundedCache } from '@jarenjs/core/cache';
18
18
  import {
19
19
  parseJSONPointer,
@@ -243,7 +243,7 @@ export function createInitialData(field) {
243
243
  if (field.children) {
244
244
  for (const child of field.children) {
245
245
  const value = createInitialData(child);
246
- if (value !== undefined) obj[child.key] = value;
246
+ if (value !== undefined) setObjectMember(obj, child.key, value);
247
247
  }
248
248
  }
249
249
  return obj;
package/src/model.js CHANGED
@@ -293,7 +293,8 @@ function buildField(rawSchema, rootSchema, pointer, key, required, depth, t) {
293
293
  // data pointer (the root field's base is the empty pointer '').
294
294
  const base = typeof effective['x-msgid'] === 'string' ? effective['x-msgid'] : pointer;
295
295
 
296
- const oneOfBranches = kind === 'enum' ? getOneOfConstBranches(effective) : null;
296
+ const oneOfBranches = kind === 'enum' && !Array.isArray(effective.enum)
297
+ ? getOneOfConstBranches(effective) : null;
297
298
  const enumValues = kind === 'enum'
298
299
  ? (Array.isArray(effective.enum)
299
300
  ? effective.enum
package/src/rules.js CHANGED
@@ -737,7 +737,8 @@ function extendChunks(chunks, selector) {
737
737
  * absent location arrives as `null`, exactly as the keystroke path
738
738
  * binds it.
739
739
  *
740
- * A field that also declares `visible` has its assert guarded by it:
740
+ * A field's own and ancestor `visible` rules guard its assert, each
741
+ * with the value/pointer bindings of the field that owns the rule:
741
742
  * the assert holds vacuously while the field is hidden. That is what
742
743
  * the keystroke path already does — `buildFormViewModel` drops hidden
743
744
  * nodes, so their assert errors never render and never count — and an
@@ -747,9 +748,11 @@ function extendChunks(chunks, selector) {
747
748
  * @param {any} assert - The authored rule document
748
749
  * @param {string} pointer - The field's data pointer
749
750
  * @param {any} [visible] - The field's `visible` rule, when it has one
751
+ * @param {Array<{chunks: string[], pointer: string, visible: any}>} [ancestors]
752
+ * Visibility rules of ancestor fields, in root-to-parent order
750
753
  * @returns {any} The wrapped query document
751
754
  */
752
- function assertQuery(chunks, assert, pointer, visible) {
755
+ function assertQuery(chunks, assert, pointer, visible, ancestors = []) {
753
756
  const depth = chunks.length - 1;
754
757
  const at = (k) => k === 0 ? chunks[0] : `$${ITEM_VAR}${k - 1}${chunks[k]}`;
755
758
  const body = visible === undefined
@@ -759,6 +762,22 @@ function assertQuery(chunks, assert, pointer, visible) {
759
762
  $let: { value: { $default: [at(depth), { $const: null }] }, pointer: { $const: pointer } },
760
763
  $return: body,
761
764
  };
765
+ // Every enclosing item variable is in scope here. Bind an ancestor
766
+ // from its own path chunks, never from the descendant's value; a
767
+ // nested array's visibility must read its own row at each depth.
768
+ for (let i = ancestors.length - 1; i >= 0; i--) {
769
+ const ancestor = ancestors[i];
770
+ const level = ancestor.chunks.length - 1;
771
+ const path = level === 0 ? ancestor.chunks[0]
772
+ : `$${ITEM_VAR}${level - 1}${ancestor.chunks[level]}`;
773
+ query = { $or: [{ $not: {
774
+ $let: {
775
+ value: { $default: [path, { $const: null }] },
776
+ pointer: { $const: ancestor.pointer },
777
+ },
778
+ $return: ancestor.visible,
779
+ } }, query] };
780
+ }
762
781
  for (let k = depth - 1; k >= 0; k--)
763
782
  query = { $every: { [`${ITEM_VAR}${k}`]: at(k) }, $satisfies: query };
764
783
  return query;
@@ -768,7 +787,7 @@ function assertQuery(chunks, assert, pointer, visible) {
768
787
  * Depth-first collection of `x-form.assert` documents with the pointer
769
788
  * and the path chunks of their data location.
770
789
  */
771
- function collectAsserts(schema, pointer, chunks, out) {
790
+ function collectAsserts(schema, pointer, chunks, out, ancestors = []) {
772
791
  if (schema == null || typeof schema !== 'object' || Array.isArray(schema))
773
792
  return;
774
793
 
@@ -776,27 +795,31 @@ function collectAsserts(schema, pointer, chunks, out) {
776
795
  if (rules != null && typeof rules === 'object' && !Array.isArray(rules)
777
796
  && rules.assert !== undefined) {
778
797
  out.push({
779
- query: assertQuery(chunks, rules.assert, pointer, rules.visible),
798
+ query: assertQuery(chunks, rules.assert, pointer, rules.visible, ancestors),
780
799
  pointer,
781
800
  message: rules.message,
782
801
  });
783
802
  }
803
+ const parents = rules != null && typeof rules === 'object' && !Array.isArray(rules)
804
+ && rules.visible !== undefined
805
+ ? [...ancestors, { chunks, pointer, visible: rules.visible }]
806
+ : ancestors;
784
807
 
785
808
  if (schema.properties != null && typeof schema.properties === 'object') {
786
809
  for (const [key, sub] of Object.entries(schema.properties)) {
787
810
  collectAsserts(sub, `${pointer}/${escapePointerKey(key)}`,
788
- extendChunks(chunks, pathNameSelector(key)), out);
811
+ extendChunks(chunks, pathNameSelector(key)), out, parents);
789
812
  }
790
813
  }
791
814
  if (Array.isArray(schema.prefixItems)) {
792
815
  for (let i = 0; i < schema.prefixItems.length; i++)
793
- collectAsserts(schema.prefixItems[i], `${pointer}/${i}`, extendChunks(chunks, `[${i}]`), out);
816
+ collectAsserts(schema.prefixItems[i], `${pointer}/${i}`, extendChunks(chunks, `[${i}]`), out, parents);
794
817
  }
795
818
  if (schema.items != null && typeof schema.items === 'object' && !Array.isArray(schema.items))
796
- collectAsserts(schema.items, `${pointer}/-`, [...extendChunks(chunks, '[*]'), ''], out);
819
+ collectAsserts(schema.items, `${pointer}/-`, [...extendChunks(chunks, '[*]'), ''], out, parents);
797
820
  if (Array.isArray(schema.allOf)) {
798
821
  for (const branch of schema.allOf)
799
- collectAsserts(branch, pointer, chunks, out);
822
+ collectAsserts(branch, pointer, chunks, out, parents);
800
823
  }
801
824
  }
802
825
 
package/src/viewmodel.js CHANGED
@@ -352,7 +352,7 @@ function buildNode(field, pointer, data, ruleState, fieldErrors, element, remova
352
352
  // number, boolean and null enums, which `String(v)` is not.
353
353
  key: JSON.stringify(v) ?? 'null',
354
354
  label: field.enumLabels?.[i] ?? String(v),
355
- selected: v === node.value,
355
+ selected: equalsJson(v, node.value),
356
356
  }));
357
357
  }
358
358
 
@@ -408,15 +408,15 @@ function buildNode(field, pointer, data, ruleState, fieldErrors, element, remova
408
408
  if (built !== null) items.push(built);
409
409
  }
410
410
  node.items = items;
411
- if (field.item !== null && field.item !== undefined) {
412
- node.addValue = createItemValue(field.item) ?? null;
413
- }
414
- else if (field.tuple !== null && field.tuple !== undefined && array.length < field.tuple.length) {
411
+ if (field.tuple !== null && field.tuple !== undefined && array.length < field.tuple.length) {
415
412
  // a tuple shorter than its schema grows one slot at a time, each
416
413
  // starting from ITS OWN template — that is what makes a tuple
417
414
  // loaded short (or absent) fillable at all
418
415
  node.addValue = createItemValue(field.tuple[array.length]) ?? null;
419
416
  }
417
+ else if (field.item !== null && field.item !== undefined) {
418
+ node.addValue = createItemValue(field.item) ?? null;
419
+ }
420
420
  }
421
421
 
422
422
  return node;