@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 +23 -7
- package/package.json +5 -5
- package/src/data.js +2 -2
- package/src/model.js +2 -1
- package/src/rules.js +31 -8
- package/src/viewmodel.js +5 -5
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
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
errors never render
|
|
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.
|
|
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.
|
|
51
|
-
"@jarenjs/formats": "^0.
|
|
52
|
-
"@jarenjs/json": "^0.
|
|
53
|
-
"@jarenjs/validate": "^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
|
|
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'
|
|
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
|
|
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
|
|
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.
|
|
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;
|