@jarenjs/forms 0.9.2 → 0.34.2
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 +204 -7
- package/dist/types/data.d.ts +23 -4
- package/dist/types/deps.d.ts +30 -0
- package/dist/types/index.d.ts +3 -1
- package/dist/types/messages.d.ts +36 -0
- package/dist/types/model.d.ts +34 -25
- package/dist/types/rules.d.ts +138 -26
- package/dist/types/validate.d.ts +15 -4
- package/dist/types/viewmodel.d.ts +373 -0
- package/package.json +6 -5
- package/src/data.js +133 -46
- package/src/deps.js +179 -0
- package/src/formats.js +35 -10
- package/src/index.js +13 -0
- package/src/messages.js +106 -0
- package/src/model.js +140 -42
- package/src/rules.js +454 -55
- package/src/validate.js +79 -59
- package/src/viewmodel.js +419 -0
package/src/rules.js
CHANGED
|
@@ -11,7 +11,9 @@
|
|
|
11
11
|
* enabled - EBV query: should the field accept input?
|
|
12
12
|
* assert - EBV query: cross-field preemptive validation
|
|
13
13
|
* computed - query whose plain-JSON result is the field's derived value
|
|
14
|
-
* message -
|
|
14
|
+
* message - MessageSpec shown when `assert` fails: an inline template
|
|
15
|
+
* string, or `{ "$msgid": ..., "message"?: ..., "params"?: ... }`
|
|
16
|
+
* resolving through a message catalog (see messages.js)
|
|
15
17
|
*
|
|
16
18
|
* Unknown members are ignored (forward compatibility). Every rule kind
|
|
17
19
|
* shares one query context: the input document `$` is the WHOLE form data
|
|
@@ -51,10 +53,24 @@ import {
|
|
|
51
53
|
|
|
52
54
|
import { escapePointerKey } from './model.js';
|
|
53
55
|
|
|
56
|
+
import { setValueAtPointer, changedPointers } from './data.js';
|
|
57
|
+
|
|
58
|
+
import {
|
|
59
|
+
queryDependencies, mergeDependencies, dependencyTouched, ALL_POINTERS,
|
|
60
|
+
} from './deps.js';
|
|
61
|
+
|
|
62
|
+
import {
|
|
63
|
+
compileMessageTemplate,
|
|
64
|
+
renderFormsMessage,
|
|
65
|
+
formsMessages,
|
|
66
|
+
} from './messages.js';
|
|
67
|
+
|
|
54
68
|
/** The externals every rule query may reference, and no others. */
|
|
55
69
|
const ALLOWED_EXTERNALS = ['value', 'pointer'];
|
|
56
70
|
|
|
57
|
-
|
|
71
|
+
/** The msgid of the default assert failure text ('Invalid value'), in the
|
|
72
|
+
* built-in English catalog (messages.js). */
|
|
73
|
+
const DEFAULT_ASSERT_MSGID = 'x-form/assert';
|
|
58
74
|
|
|
59
75
|
/**
|
|
60
76
|
* @typedef {object} RuleResult
|
|
@@ -62,8 +78,9 @@ const DEFAULT_ASSERT_MESSAGE = 'Invalid value';
|
|
|
62
78
|
* @property {boolean} [enabled] - EBV of the field's `enabled` rule
|
|
63
79
|
* @property {any} [computed] - Plain-JSON result of the `computed` rule
|
|
64
80
|
* @property {Array<import('./validate.js').FieldError>} [errors]
|
|
65
|
-
* `[{ keyword: 'x-form/assert', message }]` when the
|
|
66
|
-
* (the validateField error shape, so error
|
|
81
|
+
* `[{ keyword: 'x-form/assert', params, msgid, message }]` when the
|
|
82
|
+
* `assert` rule fails (the validateField error shape, so error
|
|
83
|
+
* rendering works unchanged; `params` always carries the `pointer`)
|
|
67
84
|
*/
|
|
68
85
|
|
|
69
86
|
/**
|
|
@@ -96,6 +113,17 @@ function compileRuleQuery(doc, fieldPointer, member, options) {
|
|
|
96
113
|
return query;
|
|
97
114
|
}
|
|
98
115
|
|
|
116
|
+
/**
|
|
117
|
+
* A compiled `x-form.message` MessageSpec: inline text compiles into a
|
|
118
|
+
* render closure; a `$msgid` form resolves through the active catalog at
|
|
119
|
+
* failure time (English fallback), with the inline `message` template as
|
|
120
|
+
* the catalog-miss fallback.
|
|
121
|
+
* @typedef {object} CompiledRuleMessage
|
|
122
|
+
* @property {string|null} msgid - Catalog key, or null for a plain inline message
|
|
123
|
+
* @property {((params: object) => string)|null} render - Compiled inline template
|
|
124
|
+
* @property {object|null} params - Author params, merged into the error's params
|
|
125
|
+
*/
|
|
126
|
+
|
|
99
127
|
/**
|
|
100
128
|
* @typedef {object} CompiledFieldRules
|
|
101
129
|
* @property {string} pointer - The field's data pointer (template pointers keep `-`)
|
|
@@ -106,9 +134,41 @@ function compileRuleQuery(doc, fieldPointer, member, options) {
|
|
|
106
134
|
* @property {function|null} enabled
|
|
107
135
|
* @property {function|null} assert
|
|
108
136
|
* @property {function|null} computed
|
|
109
|
-
* @property {
|
|
137
|
+
* @property {CompiledRuleMessage|null} message
|
|
138
|
+
* @property {string[]} deps - Pointer prefixes this rule reads (deps.js);
|
|
139
|
+
* the memo re-runs it only when a change touches one of them
|
|
110
140
|
*/
|
|
111
141
|
|
|
142
|
+
/**
|
|
143
|
+
* Compile the `message` member of an `x-form` rule. A plain string stays
|
|
144
|
+
* valid (backward compatible: it is the inline-template MessageSpec);
|
|
145
|
+
* the object form carries `$msgid`/`message`/`params`.
|
|
146
|
+
* @param {unknown} raw - The rule's `message` value
|
|
147
|
+
* @param {string} fieldPointer - The field's data pointer, for compile errors
|
|
148
|
+
* @returns {CompiledRuleMessage|null} The compiled spec, or null when absent
|
|
149
|
+
*/
|
|
150
|
+
function compileRuleMessageSpec(raw, fieldPointer) {
|
|
151
|
+
if (raw === undefined || raw === null) return null;
|
|
152
|
+
if (typeof raw === 'string')
|
|
153
|
+
return { msgid: null, render: compileMessageTemplate(raw), params: null };
|
|
154
|
+
if (typeof raw !== 'object' || Array.isArray(raw))
|
|
155
|
+
throw new Error(`${fieldPointer} x-form/message: must be a string or a MessageSpec object`);
|
|
156
|
+
const spec = /** @type {any} */ (raw);
|
|
157
|
+
if (spec.$msgid !== undefined && typeof spec.$msgid !== 'string')
|
|
158
|
+
throw new Error(`${fieldPointer} x-form/message: '$msgid' must be a string`);
|
|
159
|
+
if (spec.message !== undefined && typeof spec.message !== 'string')
|
|
160
|
+
throw new Error(`${fieldPointer} x-form/message: 'message' must be a string`);
|
|
161
|
+
if (spec.$msgid === undefined && spec.message === undefined)
|
|
162
|
+
throw new Error(`${fieldPointer} x-form/message: needs '$msgid' and/or 'message'`);
|
|
163
|
+
if (spec.params !== undefined && (spec.params === null || typeof spec.params !== 'object' || Array.isArray(spec.params)))
|
|
164
|
+
throw new Error(`${fieldPointer} x-form/message: 'params' must be an object`);
|
|
165
|
+
return {
|
|
166
|
+
msgid: spec.$msgid !== undefined ? spec.$msgid : null,
|
|
167
|
+
render: spec.message !== undefined ? compileMessageTemplate(spec.message) : null,
|
|
168
|
+
params: spec.params !== undefined ? spec.params : null,
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
|
|
112
172
|
/**
|
|
113
173
|
* @typedef {object} CompiledRules
|
|
114
174
|
* @property {Array<CompiledFieldRules>} rules
|
|
@@ -138,6 +198,16 @@ function compileRuleQuery(doc, fieldPointer, member, options) {
|
|
|
138
198
|
* results['/vatId']; // { visible: true, errors: [{ keyword: 'x-form/assert', ... }] }
|
|
139
199
|
*/
|
|
140
200
|
export function compileFormRules(model, options = {}) {
|
|
201
|
+
// a root-level `visible` rule is a modeling error, rejected here so
|
|
202
|
+
// it can never fire: hiding the whole form would make the render
|
|
203
|
+
// tree AND the session summary vanish (`buildFormViewModel` → null),
|
|
204
|
+
// silently voiding the dirty/navigation authority. Whole-form
|
|
205
|
+
// visibility belongs to the host at the mount boundary.
|
|
206
|
+
if (model?.rules?.visible !== undefined) {
|
|
207
|
+
throw new TypeError(
|
|
208
|
+
'compileFormRules: a root-level x-form "visible" rule is not allowed - '
|
|
209
|
+
+ 'gate the whole form at the mount boundary instead');
|
|
210
|
+
}
|
|
141
211
|
const queryOptions = options.compileTypeTest !== undefined
|
|
142
212
|
? { compileTypeTest: options.compileTypeTest }
|
|
143
213
|
: {};
|
|
@@ -167,7 +237,8 @@ function walkField(field, parts, rules, queryOptions) {
|
|
|
167
237
|
? compileRuleQuery(raw.assert, pointer, 'assert', queryOptions) : null,
|
|
168
238
|
computed: raw.computed !== undefined
|
|
169
239
|
? compileRuleQuery(raw.computed, pointer, 'computed', queryOptions) : null,
|
|
170
|
-
message:
|
|
240
|
+
message: compileRuleMessageSpec(raw.message, pointer),
|
|
241
|
+
deps: fieldDependencies(raw, parts),
|
|
171
242
|
});
|
|
172
243
|
}
|
|
173
244
|
|
|
@@ -183,13 +254,38 @@ function walkField(field, parts, rules, queryOptions) {
|
|
|
183
254
|
walkField(field.item, [...parts, ITEM], rules, queryOptions);
|
|
184
255
|
}
|
|
185
256
|
|
|
257
|
+
/**
|
|
258
|
+
* What one field's rules read: every root-anchored path in its four
|
|
259
|
+
* query documents, plus its own location — the `value` external binds
|
|
260
|
+
* from there, and for a templated field the whole array above the item
|
|
261
|
+
* slot, since adding or removing an element changes which pointers the
|
|
262
|
+
* rule even produces.
|
|
263
|
+
* @param {any} raw - The authored `x-form` object
|
|
264
|
+
* @param {Array<string|symbol>} parts - Decoded path segments; ITEM marks a slot
|
|
265
|
+
* @returns {string[]}
|
|
266
|
+
*/
|
|
267
|
+
function fieldDependencies(raw, parts) {
|
|
268
|
+
let own = '';
|
|
269
|
+
for (const part of parts) {
|
|
270
|
+
if (part === ITEM) break;
|
|
271
|
+
own += `/${escapePointerKey(/** @type {string} */ (part))}`;
|
|
272
|
+
}
|
|
273
|
+
return mergeDependencies([
|
|
274
|
+
[own],
|
|
275
|
+
raw.visible !== undefined ? queryDependencies(raw.visible) : [],
|
|
276
|
+
raw.enabled !== undefined ? queryDependencies(raw.enabled) : [],
|
|
277
|
+
raw.assert !== undefined ? queryDependencies(raw.assert) : [],
|
|
278
|
+
raw.computed !== undefined ? queryDependencies(raw.computed) : [],
|
|
279
|
+
]);
|
|
280
|
+
}
|
|
281
|
+
|
|
186
282
|
/**
|
|
187
283
|
* Evaluate one field's compiled rules against the data root.
|
|
188
284
|
* The externals object is reused across rules: the compiled query copies
|
|
189
285
|
* externals into its frame before evaluating (see the query engine), so
|
|
190
286
|
* mutation between calls is safe and allocation-free.
|
|
191
287
|
*/
|
|
192
|
-
function evaluateOne(rule, data, value, pointer, ext, results) {
|
|
288
|
+
function evaluateOne(rule, data, value, pointer, ext, results, catalog, written) {
|
|
193
289
|
ext.value = value === undefined ? null : value;
|
|
194
290
|
ext.pointer = pointer;
|
|
195
291
|
|
|
@@ -218,13 +314,34 @@ function evaluateOne(rule, data, value, pointer, ext, results) {
|
|
|
218
314
|
ok = false; // fail closed: an uncomputable assertion is not satisfied
|
|
219
315
|
}
|
|
220
316
|
if (!ok) {
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
317
|
+
const spec = rule.message;
|
|
318
|
+
const msgid = spec !== null && spec.msgid !== null ? spec.msgid : DEFAULT_ASSERT_MSGID;
|
|
319
|
+
const params = spec !== null && spec.params !== null
|
|
320
|
+
? { ...spec.params, pointer }
|
|
321
|
+
: { pointer };
|
|
322
|
+
let message;
|
|
323
|
+
if (spec !== null) {
|
|
324
|
+
if (spec.msgid !== null) {
|
|
325
|
+
// D-M5 chain: active catalog, then built-in English, then the
|
|
326
|
+
// spec's inline template, then the assert default text
|
|
327
|
+
let render = catalog !== undefined ? catalog[spec.msgid] : undefined;
|
|
328
|
+
if (render === undefined) render = formsMessages[spec.msgid];
|
|
329
|
+
if (render !== undefined) message = render(params);
|
|
330
|
+
else if (spec.render !== null) message = spec.render(params);
|
|
331
|
+
else message = renderFormsMessage(catalog, DEFAULT_ASSERT_MSGID, params);
|
|
332
|
+
}
|
|
333
|
+
else {
|
|
334
|
+
message = /** @type {(params: object) => string} */ (spec.render)(params);
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
else {
|
|
338
|
+
message = renderFormsMessage(catalog, DEFAULT_ASSERT_MSGID, params);
|
|
339
|
+
}
|
|
340
|
+
result.errors = [{ keyword: 'x-form/assert', params, msgid, message }];
|
|
225
341
|
}
|
|
226
342
|
}
|
|
227
343
|
results[pointer] = result;
|
|
344
|
+
if (written !== null) written.push(pointer);
|
|
228
345
|
}
|
|
229
346
|
|
|
230
347
|
function ebvFailOpen(query, data, ext) {
|
|
@@ -247,14 +364,16 @@ function ebvFailOpen(query, data, ext) {
|
|
|
247
364
|
* dispatcher deciding per node what it applies to. Keep the mechanism in
|
|
248
365
|
* this function.
|
|
249
366
|
*/
|
|
250
|
-
function expandItemRule(rule, data, node, partIndex, pointer, ext, results) {
|
|
367
|
+
function expandItemRule(rule, data, node, partIndex, pointer, ext, results, catalog, written) {
|
|
251
368
|
const parts = rule.parts;
|
|
252
369
|
for (let i = partIndex; i < parts.length; i++) {
|
|
253
370
|
const part = parts[i];
|
|
254
371
|
if (part === ITEM) {
|
|
255
372
|
if (!Array.isArray(node)) return; // nothing to expand into
|
|
256
|
-
for (let index = 0; index < node.length; index++)
|
|
257
|
-
expandItemRule(rule, data, node[index], i + 1, `${pointer}/${index}`,
|
|
373
|
+
for (let index = 0; index < node.length; index++) {
|
|
374
|
+
expandItemRule(rule, data, node[index], i + 1, `${pointer}/${index}`,
|
|
375
|
+
ext, results, catalog, written);
|
|
376
|
+
}
|
|
258
377
|
return;
|
|
259
378
|
}
|
|
260
379
|
pointer = `${pointer}/${escapePointerKey(part)}`;
|
|
@@ -262,7 +381,7 @@ function expandItemRule(rule, data, node, partIndex, pointer, ext, results) {
|
|
|
262
381
|
? node[/** @type {string} */ (part)]
|
|
263
382
|
: undefined;
|
|
264
383
|
}
|
|
265
|
-
evaluateOne(rule, data, node, pointer, ext, results);
|
|
384
|
+
evaluateOne(rule, data, node, pointer, ext, results, catalog, written);
|
|
266
385
|
}
|
|
267
386
|
|
|
268
387
|
/**
|
|
@@ -272,31 +391,219 @@ function expandItemRule(rule, data, node, partIndex, pointer, ext, results) {
|
|
|
272
391
|
* field declares. Rules on array item templates are evaluated once per
|
|
273
392
|
* element of the actual array, keyed by the expanded pointer.
|
|
274
393
|
*
|
|
394
|
+
* Pass a `memo` from {@link createRuleMemo} to re-evaluate only the
|
|
395
|
+
* rules a change can have affected. The memo diffs the previous
|
|
396
|
+
* document against this one — reference-equal subtrees are skipped
|
|
397
|
+
* whole, so an immutable edit costs O(change) — and re-runs a rule only
|
|
398
|
+
* when a changed pointer touches one of its declared dependencies
|
|
399
|
+
* (deps.js). The result is identical to an unmemoized evaluation.
|
|
400
|
+
*
|
|
401
|
+
* The memo OWNS the map it returns and patches it on later calls: a
|
|
402
|
+
* caller must read it before evaluating again, and must not keep it as
|
|
403
|
+
* a snapshot (`buildFormViewModel` reads it synchronously, which is the
|
|
404
|
+
* intended shape). Handing back a fresh map instead would put a write
|
|
405
|
+
* per rule back on the hot path — on a wide form, the rules that did
|
|
406
|
+
* NOT change are the work worth skipping.
|
|
407
|
+
*
|
|
275
408
|
* @param {CompiledRules} compiled - From compileFormRules
|
|
276
409
|
* @param {any} data - The form data root (the query input `$`)
|
|
410
|
+
* @param {Readonly<Record<string, (params: object, error?: object) => string>>} [catalog] - Optional compiled message catalog (see messages.js), default English
|
|
411
|
+
* @param {RuleMemo} [memo] - Reused across calls; mutated in place
|
|
277
412
|
* @returns {Record<string, RuleResult>}
|
|
278
413
|
* @example
|
|
279
414
|
* const results = evaluateFormRules(compiled, { company: 'ACME', vatId: '' });
|
|
280
415
|
* results['/vatId'].errors; // [{ keyword: 'x-form/assert', message: '...' }]
|
|
281
416
|
*/
|
|
282
|
-
export function evaluateFormRules(compiled, data) {
|
|
417
|
+
export function evaluateFormRules(compiled, data, catalog = undefined, memo = undefined) {
|
|
418
|
+
const rules = compiled.rules;
|
|
419
|
+
// a catalog swap (a locale switch) invalidates every rendered message
|
|
420
|
+
const reuse = memo !== undefined && memo.results !== null && memo.catalog === catalog;
|
|
421
|
+
// A declared write is taken at its word; otherwise the documents are
|
|
422
|
+
// diffed. Diffing is the honest default — it needs nothing from the
|
|
423
|
+
// caller — but it must scan the members of every container that
|
|
424
|
+
// changed identity, which a host that just wrote `/lines/2/amount`
|
|
425
|
+
// can simply tell us instead.
|
|
426
|
+
const changed = reuse ? (memo.touched ?? changedPointers(memo.data, data)) : null;
|
|
427
|
+
if (memo !== undefined) memo.touched = null;
|
|
428
|
+
if (reuse && changed.length === 0) return memo.results;
|
|
429
|
+
|
|
430
|
+
const ext = { value: null, pointer: '' };
|
|
431
|
+
if (reuse) {
|
|
432
|
+
// Patch the previous map in place. Rebuilding it would put a write
|
|
433
|
+
// per rule back on the hot path, which is most of what there was to
|
|
434
|
+
// save: on a wide form the untouched rules ARE the work.
|
|
435
|
+
const results = memo.results;
|
|
436
|
+
for (let i = 0; i < rules.length; i++) {
|
|
437
|
+
const rule = rules[i];
|
|
438
|
+
if (!dependenciesAffected(rule.deps, changed)) continue;
|
|
439
|
+
if (!rule.templated) {
|
|
440
|
+
// it writes its own pointer and nothing else, every time — no
|
|
441
|
+
// key set to track and nothing that can go stale
|
|
442
|
+
const value = rule.getValue(data);
|
|
443
|
+
evaluateOne(rule, data, value === JSONPOINTER_NOTHING ? undefined : value,
|
|
444
|
+
rule.pointer, ext, results, catalog, null);
|
|
445
|
+
continue;
|
|
446
|
+
}
|
|
447
|
+
const written = [];
|
|
448
|
+
expandItemRule(rule, data, data, 0, '', ext, results, catalog, written);
|
|
449
|
+
// an item-template rule's key set follows the array's length, so
|
|
450
|
+
// an entry it no longer writes has to go
|
|
451
|
+
const previous = memo.keys[i];
|
|
452
|
+
for (let k = 0; k < previous.length; k++) {
|
|
453
|
+
if (!written.includes(previous[k])) delete results[previous[k]];
|
|
454
|
+
}
|
|
455
|
+
memo.keys[i] = written;
|
|
456
|
+
}
|
|
457
|
+
memo.data = data;
|
|
458
|
+
return results;
|
|
459
|
+
}
|
|
460
|
+
|
|
283
461
|
/** @type {Record<string, RuleResult>} */
|
|
284
462
|
const results = {};
|
|
285
|
-
|
|
286
|
-
const
|
|
463
|
+
/** @type {string[][]|null} */
|
|
464
|
+
const keys = memo !== undefined ? new Array(rules.length) : null;
|
|
287
465
|
for (let i = 0; i < rules.length; i++) {
|
|
288
466
|
const rule = rules[i];
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
467
|
+
// only a template rule's key set is data-dependent; every other
|
|
468
|
+
// rule writes exactly its own pointer
|
|
469
|
+
const written = keys !== null && rule.templated ? [] : null;
|
|
470
|
+
evaluateRule(rule, data, ext, results, catalog, written);
|
|
471
|
+
if (keys !== null) keys[i] = written ?? [rule.pointer];
|
|
472
|
+
}
|
|
473
|
+
if (memo !== undefined) {
|
|
474
|
+
memo.data = data;
|
|
475
|
+
memo.catalog = catalog;
|
|
476
|
+
memo.results = results;
|
|
477
|
+
memo.keys = keys;
|
|
296
478
|
}
|
|
297
479
|
return results;
|
|
298
480
|
}
|
|
299
481
|
|
|
482
|
+
/** Evaluate one compiled rule into the results map. */
|
|
483
|
+
function evaluateRule(rule, data, ext, results, catalog, written) {
|
|
484
|
+
if (rule.templated) {
|
|
485
|
+
expandItemRule(rule, data, data, 0, '', ext, results, catalog, written);
|
|
486
|
+
return;
|
|
487
|
+
}
|
|
488
|
+
const value = rule.getValue(data);
|
|
489
|
+
evaluateOne(rule, data, value === JSONPOINTER_NOTHING ? undefined : value,
|
|
490
|
+
rule.pointer, ext, results, catalog, written);
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
/**
|
|
494
|
+
* The memo {@link evaluateFormRules} carries between keystrokes: the
|
|
495
|
+
* document it last saw, the results it produced, and which result keys
|
|
496
|
+
* each rule wrote (an item-template rule writes one per element, so the
|
|
497
|
+
* count is data-dependent and has to be recorded, not derived).
|
|
498
|
+
* @typedef {object} RuleMemo
|
|
499
|
+
* @property {any} data
|
|
500
|
+
* @property {any} catalog
|
|
501
|
+
* @property {Record<string, RuleResult>|null} results
|
|
502
|
+
* @property {string[][]|null} keys
|
|
503
|
+
* @property {string[]|null} touched - Pointers declared through
|
|
504
|
+
* {@link RuleMemo.touch}, consumed by the next evaluation
|
|
505
|
+
* @property {(pointer: string) => RuleMemo} touch
|
|
506
|
+
*/
|
|
507
|
+
|
|
508
|
+
/**
|
|
509
|
+
* Create an empty rule memo. One per form session: it is bound to the
|
|
510
|
+
* document lineage it has seen, so sharing it between two forms would
|
|
511
|
+
* diff unrelated documents (correct, but pointlessly expensive).
|
|
512
|
+
*
|
|
513
|
+
* `memo.touch(pointer)` declares a write before the next evaluation.
|
|
514
|
+
* It is an optimization AND a promise: the evaluation then trusts the
|
|
515
|
+
* declaration instead of diffing, so a caller that touches one pointer
|
|
516
|
+
* while changing another gets stale results for the rules it did not
|
|
517
|
+
* name. Say nothing and the diff works it out.
|
|
518
|
+
* @returns {RuleMemo}
|
|
519
|
+
* @example
|
|
520
|
+
* const memo = createRuleMemo();
|
|
521
|
+
* data = setValueAtPointer(data, '/lines/2/amount', 9);
|
|
522
|
+
* const state = evaluateFormRules(compiled, data, catalog, memo.touch('/lines/2/amount'));
|
|
523
|
+
*/
|
|
524
|
+
export function createRuleMemo() {
|
|
525
|
+
/** @type {RuleMemo} */
|
|
526
|
+
const memo = {
|
|
527
|
+
data: undefined,
|
|
528
|
+
catalog: undefined,
|
|
529
|
+
results: null,
|
|
530
|
+
keys: null,
|
|
531
|
+
touched: null,
|
|
532
|
+
touch(pointer) {
|
|
533
|
+
(memo.touched ??= []).push(pointer);
|
|
534
|
+
return memo;
|
|
535
|
+
},
|
|
536
|
+
};
|
|
537
|
+
return memo;
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
/** Whether any changed pointer touches any of a rule's dependencies. */
|
|
541
|
+
function dependenciesAffected(deps, changed) {
|
|
542
|
+
if (deps === ALL_POINTERS) return true;
|
|
543
|
+
for (let i = 0; i < deps.length; i++) {
|
|
544
|
+
for (let k = 0; k < changed.length; k++) {
|
|
545
|
+
if (dependencyTouched(deps[i], changed[k])) return true;
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
return false;
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
/**
|
|
552
|
+
* Drop the values of fields their `visible` rules currently hide, for a
|
|
553
|
+
* caller about to submit.
|
|
554
|
+
*
|
|
555
|
+
* The policy this settles: hidden values are KEPT while editing (a
|
|
556
|
+
* field that reappears must not have forgotten what the operator typed)
|
|
557
|
+
* and dropped only here, at the submit boundary, by a caller who asked.
|
|
558
|
+
* Nothing prunes implicitly — `buildFormViewModel` still never touches
|
|
559
|
+
* the data, and this returns a copy.
|
|
560
|
+
*
|
|
561
|
+
* Visibility is evaluated ONCE against the incoming document, so a
|
|
562
|
+
* `visible` rule that reads a value this call removes still sees it.
|
|
563
|
+
* Hidden array ELEMENTS are removed and their siblings renumber, which
|
|
564
|
+
* is right for a document being sent but means the returned pointers no
|
|
565
|
+
* longer match the ones the view model rendered.
|
|
566
|
+
*
|
|
567
|
+
* @param {CompiledRules} compiled - From compileFormRules
|
|
568
|
+
* @param {any} data - The form data root
|
|
569
|
+
* @param {Readonly<Record<string, (params: object, error?: object) => string>>} [catalog]
|
|
570
|
+
* @returns {any} A copy without the hidden values (`data` itself when
|
|
571
|
+
* nothing is hidden; untouched subtrees are shared)
|
|
572
|
+
* @example
|
|
573
|
+
* const submitted = pruneHiddenValues(compiled, session.data);
|
|
574
|
+
*/
|
|
575
|
+
export function pruneHiddenValues(compiled, data, catalog = undefined) {
|
|
576
|
+
const results = evaluateFormRules(compiled, data, catalog);
|
|
577
|
+
const hidden = [];
|
|
578
|
+
for (const pointer of Object.keys(results)) {
|
|
579
|
+
if (results[pointer].visible === false && pointer !== '') hidden.push(pointer);
|
|
580
|
+
}
|
|
581
|
+
if (hidden.length === 0) return data;
|
|
582
|
+
// Deepest first, and higher array indexes before lower ones: removing
|
|
583
|
+
// an element renumbers its siblings, so every pointer still to be
|
|
584
|
+
// processed must address a location the removal cannot have moved.
|
|
585
|
+
hidden.sort(comparePointersDescending);
|
|
586
|
+
let out = data;
|
|
587
|
+
for (const pointer of hidden)
|
|
588
|
+
out = setValueAtPointer(out, pointer, undefined);
|
|
589
|
+
return out;
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
/** Order two pointers deepest-first, numeric segments by value. */
|
|
593
|
+
function comparePointersDescending(a, b) {
|
|
594
|
+
const left = a.split('/');
|
|
595
|
+
const right = b.split('/');
|
|
596
|
+
const shared = Math.min(left.length, right.length);
|
|
597
|
+
for (let i = 1; i < shared; i++) {
|
|
598
|
+
if (left[i] === right[i]) continue;
|
|
599
|
+
const na = Number(left[i]);
|
|
600
|
+
const nb = Number(right[i]);
|
|
601
|
+
if (Number.isInteger(na) && Number.isInteger(nb)) return nb - na;
|
|
602
|
+
return left[i] < right[i] ? 1 : -1;
|
|
603
|
+
}
|
|
604
|
+
return right.length - left.length;
|
|
605
|
+
}
|
|
606
|
+
|
|
300
607
|
//#region $query synergy
|
|
301
608
|
|
|
302
609
|
/**
|
|
@@ -324,23 +631,43 @@ function pathNameSelector(key) {
|
|
|
324
631
|
* Pure schema-to-schema transform - no validator import; the output only
|
|
325
632
|
* spells the keyword.
|
|
326
633
|
*
|
|
327
|
-
* The
|
|
328
|
-
* instance root, matching the rule context),
|
|
329
|
-
*
|
|
330
|
-
*
|
|
331
|
-
*
|
|
332
|
-
*
|
|
333
|
-
*
|
|
334
|
-
*
|
|
634
|
+
* The asserts land on the ROOT schema (where the query input `$` is the
|
|
635
|
+
* instance root, matching the rule context), each as its OWN `allOf`
|
|
636
|
+
* branch `{ "$query": <wrapped>, "errorMessage": { "$query": <spec> } }`
|
|
637
|
+
* so per-assert identity - and the rule's authored message - survives
|
|
638
|
+
* into submit validation. Each assert is wrapped to rebuild its bindings:
|
|
639
|
+
* `value` binds to the field's location, `pointer` to its pointer string.
|
|
640
|
+
* An assert on an array item template quantifies with `$every` over the
|
|
641
|
+
* actual elements (`pointer` then stays the template pointer - element
|
|
642
|
+
* indexes are a render-time notion). An existing root `$query` is left
|
|
643
|
+
* untouched on the root itself.
|
|
644
|
+
*
|
|
645
|
+
* The carried message spec is the rule's `x-form.message` - inline string
|
|
646
|
+
* as an inline `message`, `$msgid` form passed through - with `params`
|
|
647
|
+
* merged over `{ pointer: <field pointer> }`; a rule with no message gets
|
|
648
|
+
* `{ "$msgid": "x-form/assert", "params": { "pointer": ... } }`. EVERY
|
|
649
|
+
* submit-time `$query` failure therefore carries the owning field's
|
|
650
|
+
* pointer in `params`, which lets UIs map root-level `$query` errors onto
|
|
651
|
+
* fields.
|
|
335
652
|
*
|
|
336
653
|
* The transform follows the same structural spine as buildFormModel
|
|
337
654
|
* (`properties`, `items`, `prefixItems`, `allOf`) but does not resolve
|
|
338
655
|
* `$ref`s - a `$def`'s data location depends on its use site.
|
|
339
656
|
*
|
|
657
|
+
* Absent fields bind exactly as they do per keystroke: `null`. A path
|
|
658
|
+
* that selects nothing is the empty sequence, which compares unequal to
|
|
659
|
+
* everything and would make `$ne`/`$eq` mean the opposite thing on the
|
|
660
|
+
* two sides of the same authored rule - so the binding is wrapped in a
|
|
661
|
+
* `$default` against `null`. For the same reason an item-template
|
|
662
|
+
* assert quantifies over the ELEMENTS rather than over the selected
|
|
663
|
+
* leaf values: quantifying over the leaves silently skips an element
|
|
664
|
+
* that lacks the member, where the keystroke path evaluates it with
|
|
665
|
+
* `null`.
|
|
666
|
+
*
|
|
340
667
|
* @param {object|boolean} schema - The root JSON schema
|
|
341
668
|
* @returns {object|boolean} A new root schema (input is not mutated;
|
|
342
|
-
* untouched subtrees are shared) with the collected `$query
|
|
343
|
-
* input itself when there is nothing to copy
|
|
669
|
+
* untouched subtrees are shared) with the collected `$query` branches,
|
|
670
|
+
* or the input itself when there is nothing to copy
|
|
344
671
|
* @example
|
|
345
672
|
* const submitSchema = formRulesToQueryAssertions(schema);
|
|
346
673
|
* const validate = new JarenValidator().compile(submitSchema); // caller-side
|
|
@@ -349,55 +676,127 @@ export function formRulesToQueryAssertions(schema) {
|
|
|
349
676
|
if (schema == null || typeof schema !== 'object' || Array.isArray(schema))
|
|
350
677
|
return schema;
|
|
351
678
|
|
|
352
|
-
/** @type {any
|
|
679
|
+
/** @type {Array<{query: any, pointer: string, message: unknown}>} */
|
|
353
680
|
const assertions = [];
|
|
354
|
-
collectAsserts(schema, '', '$',
|
|
681
|
+
collectAsserts(schema, '', ['$'], assertions);
|
|
355
682
|
if (assertions.length === 0)
|
|
356
683
|
return schema;
|
|
357
684
|
|
|
358
|
-
const
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
685
|
+
const branches = assertions.map(assert => ({
|
|
686
|
+
$query: assert.query,
|
|
687
|
+
errorMessage: { $query: assertMessageSpec(assert.message, assert.pointer) },
|
|
688
|
+
}));
|
|
689
|
+
const allOf = Array.isArray(schema.allOf) ? schema.allOf : [];
|
|
690
|
+
return { ...schema, allOf: [...allOf, ...branches] };
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
/**
|
|
694
|
+
* Build the `errorMessage.$query` MessageSpec carried into the submit
|
|
695
|
+
* schema for one assert: the rule's message with `params` merged over
|
|
696
|
+
* `{ pointer }`, or the `x-form/assert` catalog default.
|
|
697
|
+
* @param {unknown} message - The rule's raw `x-form.message`, if any
|
|
698
|
+
* @param {string} pointer - The owning field's data pointer
|
|
699
|
+
* @returns {object} The MessageSpec for the transformed schema
|
|
700
|
+
*/
|
|
701
|
+
function assertMessageSpec(message, pointer) {
|
|
702
|
+
if (typeof message === 'string')
|
|
703
|
+
return { message, params: { pointer } };
|
|
704
|
+
if (message != null && typeof message === 'object' && !Array.isArray(message)) {
|
|
705
|
+
const spec = /** @type {any} */ (message);
|
|
706
|
+
return { ...spec, params: { pointer, ...(spec.params || {}) } };
|
|
362
707
|
}
|
|
363
|
-
return {
|
|
708
|
+
return { $msgid: 'x-form/assert', params: { pointer } };
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
/**
|
|
712
|
+
* The loop-variable prefix for element quantification. The leading
|
|
713
|
+
* underscore keeps it clear of an author's own `$let` names while
|
|
714
|
+
* staying inside the engine's variable grammar.
|
|
715
|
+
*/
|
|
716
|
+
const ITEM_VAR = '_item';
|
|
717
|
+
|
|
718
|
+
/**
|
|
719
|
+
* Append one path selector to the last chunk of a chunk list, returning
|
|
720
|
+
* a new list (chunks are split at `[*]`; see {@link assertQuery}).
|
|
721
|
+
*/
|
|
722
|
+
function extendChunks(chunks, selector) {
|
|
723
|
+
const next = chunks.slice();
|
|
724
|
+
next[next.length - 1] += selector;
|
|
725
|
+
return next;
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
/**
|
|
729
|
+
* Build the `$query` document for one assert from its data location,
|
|
730
|
+
* expressed as path chunks split at each array expansion: every chunk
|
|
731
|
+
* but the last ends with `[*]`, and the last is the tail after the
|
|
732
|
+
* final one (`''` when the assert sits on the element itself).
|
|
733
|
+
*
|
|
734
|
+
* Zero expansions is a plain binding; each expansion becomes an
|
|
735
|
+
* `$every` over the elements at that level, so the innermost binding
|
|
736
|
+
* reads its member off ONE element. Binding through `$default` means an
|
|
737
|
+
* absent location arrives as `null`, exactly as the keystroke path
|
|
738
|
+
* binds it.
|
|
739
|
+
*
|
|
740
|
+
* A field that also declares `visible` has its assert guarded by it:
|
|
741
|
+
* the assert holds vacuously while the field is hidden. That is what
|
|
742
|
+
* the keystroke path already does — `buildFormViewModel` drops hidden
|
|
743
|
+
* nodes, so their assert errors never render and never count — and an
|
|
744
|
+
* unguarded copy would let a field the operator cannot see or fix block
|
|
745
|
+
* submit forever.
|
|
746
|
+
* @param {string[]} chunks
|
|
747
|
+
* @param {any} assert - The authored rule document
|
|
748
|
+
* @param {string} pointer - The field's data pointer
|
|
749
|
+
* @param {any} [visible] - The field's `visible` rule, when it has one
|
|
750
|
+
* @returns {any} The wrapped query document
|
|
751
|
+
*/
|
|
752
|
+
function assertQuery(chunks, assert, pointer, visible) {
|
|
753
|
+
const depth = chunks.length - 1;
|
|
754
|
+
const at = (k) => k === 0 ? chunks[0] : `$${ITEM_VAR}${k - 1}${chunks[k]}`;
|
|
755
|
+
const body = visible === undefined
|
|
756
|
+
? assert
|
|
757
|
+
: { $or: [{ $not: visible }, assert] };
|
|
758
|
+
let query = {
|
|
759
|
+
$let: { value: { $default: [at(depth), { $const: null }] }, pointer: { $const: pointer } },
|
|
760
|
+
$return: body,
|
|
761
|
+
};
|
|
762
|
+
for (let k = depth - 1; k >= 0; k--)
|
|
763
|
+
query = { $every: { [`${ITEM_VAR}${k}`]: at(k) }, $satisfies: query };
|
|
764
|
+
return query;
|
|
364
765
|
}
|
|
365
766
|
|
|
366
767
|
/**
|
|
367
768
|
* Depth-first collection of `x-form.assert` documents with the pointer
|
|
368
|
-
* and
|
|
369
|
-
* enclosing `[*]` expansions: inside one, `value` must quantify per
|
|
370
|
-
* element instead of binding the selected sequence.
|
|
769
|
+
* and the path chunks of their data location.
|
|
371
770
|
*/
|
|
372
|
-
function collectAsserts(schema, pointer,
|
|
771
|
+
function collectAsserts(schema, pointer, chunks, out) {
|
|
373
772
|
if (schema == null || typeof schema !== 'object' || Array.isArray(schema))
|
|
374
773
|
return;
|
|
375
774
|
|
|
376
775
|
const rules = schema['x-form'];
|
|
377
776
|
if (rules != null && typeof rules === 'object' && !Array.isArray(rules)
|
|
378
777
|
&& rules.assert !== undefined) {
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
:
|
|
383
|
-
|
|
778
|
+
out.push({
|
|
779
|
+
query: assertQuery(chunks, rules.assert, pointer, rules.visible),
|
|
780
|
+
pointer,
|
|
781
|
+
message: rules.message,
|
|
782
|
+
});
|
|
384
783
|
}
|
|
385
784
|
|
|
386
785
|
if (schema.properties != null && typeof schema.properties === 'object') {
|
|
387
786
|
for (const [key, sub] of Object.entries(schema.properties)) {
|
|
388
787
|
collectAsserts(sub, `${pointer}/${escapePointerKey(key)}`,
|
|
389
|
-
|
|
788
|
+
extendChunks(chunks, pathNameSelector(key)), out);
|
|
390
789
|
}
|
|
391
790
|
}
|
|
392
791
|
if (Array.isArray(schema.prefixItems)) {
|
|
393
792
|
for (let i = 0; i < schema.prefixItems.length; i++)
|
|
394
|
-
collectAsserts(schema.prefixItems[i], `${pointer}/${i}`,
|
|
793
|
+
collectAsserts(schema.prefixItems[i], `${pointer}/${i}`, extendChunks(chunks, `[${i}]`), out);
|
|
395
794
|
}
|
|
396
795
|
if (schema.items != null && typeof schema.items === 'object' && !Array.isArray(schema.items))
|
|
397
|
-
collectAsserts(schema.items, `${pointer}/-`,
|
|
796
|
+
collectAsserts(schema.items, `${pointer}/-`, [...extendChunks(chunks, '[*]'), ''], out);
|
|
398
797
|
if (Array.isArray(schema.allOf)) {
|
|
399
798
|
for (const branch of schema.allOf)
|
|
400
|
-
collectAsserts(branch, pointer,
|
|
799
|
+
collectAsserts(branch, pointer, chunks, out);
|
|
401
800
|
}
|
|
402
801
|
}
|
|
403
802
|
|