@jarenjs/forms 0.9.2 → 0.34.0
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/data.js
CHANGED
|
@@ -13,11 +13,19 @@
|
|
|
13
13
|
* cached by pointer string and reads are allocation-free.
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
|
+
import { equalsJson } from '@jarenjs/core/object';
|
|
17
|
+
import { createBoundedCache } from '@jarenjs/core/cache';
|
|
16
18
|
import {
|
|
17
19
|
parseJSONPointer,
|
|
18
20
|
compileJSONPointer,
|
|
21
|
+
encodeJSONPointerSegment,
|
|
19
22
|
JSONPOINTER_NOTHING,
|
|
20
23
|
} from '@jarenjs/json/pointer';
|
|
24
|
+
import {
|
|
25
|
+
compileJSONPointerSetter,
|
|
26
|
+
compileJSONPointerRemover,
|
|
27
|
+
JsonWriteError,
|
|
28
|
+
} from '@jarenjs/json/write';
|
|
21
29
|
|
|
22
30
|
/**
|
|
23
31
|
* Split a JSON pointer into decoded segments per RFC 6901. '' -> [].
|
|
@@ -31,25 +39,17 @@ export function parsePointer(pointer) {
|
|
|
31
39
|
return parseJSONPointer(pointer);
|
|
32
40
|
}
|
|
33
41
|
|
|
34
|
-
const getterCache =
|
|
35
|
-
const GETTER_CACHE_LIMIT = 512;
|
|
42
|
+
const getterCache = createBoundedCache(512);
|
|
36
43
|
|
|
37
44
|
/**
|
|
38
|
-
* Compiled getter for a pointer string, cached
|
|
39
|
-
*
|
|
40
|
-
*
|
|
45
|
+
* Compiled getter for a pointer string, cached in the shared bounded
|
|
46
|
+
* LRU (`@jarenjs/core/cache`): form field pointers are a small, stable
|
|
47
|
+
* set, so every keystroke after the first hits the cache.
|
|
41
48
|
* @param {string} pointer
|
|
42
49
|
* @returns {(root: any) => any}
|
|
43
50
|
*/
|
|
44
51
|
function getPointerGetter(pointer) {
|
|
45
|
-
|
|
46
|
-
if (getter === undefined) {
|
|
47
|
-
getter = compileJSONPointer(pointer);
|
|
48
|
-
if (getterCache.size >= GETTER_CACHE_LIMIT)
|
|
49
|
-
getterCache.delete(getterCache.keys().next().value);
|
|
50
|
-
getterCache.set(pointer, getter);
|
|
51
|
-
}
|
|
52
|
-
return getter;
|
|
52
|
+
return getterCache.getOrCreate(pointer, compileJSONPointer);
|
|
53
53
|
}
|
|
54
54
|
|
|
55
55
|
/**
|
|
@@ -63,49 +63,49 @@ export function getValueAtPointer(data, pointer) {
|
|
|
63
63
|
return value === JSONPOINTER_NOTHING ? undefined : value;
|
|
64
64
|
}
|
|
65
65
|
|
|
66
|
-
//#region
|
|
67
|
-
//
|
|
68
|
-
//
|
|
69
|
-
//
|
|
70
|
-
//
|
|
66
|
+
//#region write operations
|
|
67
|
+
// One write engine in the whole repo: the copy-on-write kernel behind
|
|
68
|
+
// @jarenjs/json's patch and write modules. Forms adds only its two
|
|
69
|
+
// data disciplines - missing parents are CREATED (a rendered field may
|
|
70
|
+
// be the first write into an untouched branch) and setting `undefined`
|
|
71
|
+
// deletes (parseFieldInput maps a cleared input to undefined).
|
|
72
|
+
|
|
73
|
+
const setterCache = createBoundedCache(512);
|
|
74
|
+
const removerCache = createBoundedCache(512);
|
|
75
|
+
|
|
76
|
+
function getPointerSetter(pointer) {
|
|
77
|
+
return setterCache.getOrCreate(pointer,
|
|
78
|
+
(p) => compileJSONPointerSetter(p, { parents: 'create' }));
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function getPointerRemover(pointer) {
|
|
82
|
+
return removerCache.getOrCreate(pointer, compileJSONPointerRemover);
|
|
83
|
+
}
|
|
71
84
|
|
|
72
85
|
/**
|
|
73
86
|
* Return a copy of `data` with the value at `pointer` replaced.
|
|
74
|
-
* Setting `undefined` REMOVES the
|
|
75
|
-
*
|
|
76
|
-
*
|
|
77
|
-
*
|
|
87
|
+
* Setting `undefined` REMOVES the location (deleting something that does
|
|
88
|
+
* not exist is a no-op returning `data` unchanged). Missing intermediate
|
|
89
|
+
* containers are created (objects for name segments, arrays for numeric
|
|
90
|
+
* segments), and untouched siblings are shared by reference — the same
|
|
91
|
+
* copy-on-write engine as `@jarenjs/json`'s patch and write modules.
|
|
78
92
|
* @param {any} data
|
|
79
93
|
* @param {string} pointer
|
|
80
94
|
* @param {any} value
|
|
81
95
|
* @returns {any} The new root value
|
|
82
96
|
*/
|
|
83
97
|
export function setValueAtPointer(data, pointer, value) {
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
const last = keys[keys.length - 1];
|
|
96
|
-
if (value === undefined && !Array.isArray(current)) {
|
|
97
|
-
delete current[last];
|
|
98
|
-
}
|
|
99
|
-
else {
|
|
100
|
-
current[last] = value;
|
|
98
|
+
if (value === undefined) {
|
|
99
|
+
if (pointer === '') return undefined;
|
|
100
|
+
try {
|
|
101
|
+
return getPointerRemover(pointer)(data);
|
|
102
|
+
}
|
|
103
|
+
catch (error) {
|
|
104
|
+
if (error instanceof JsonWriteError) return data;
|
|
105
|
+
throw error;
|
|
106
|
+
}
|
|
101
107
|
}
|
|
102
|
-
return
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
function cloneContainer(value, nextKey) {
|
|
106
|
-
if (Array.isArray(value)) return value.slice();
|
|
107
|
-
if (value != null && typeof value === 'object') return { ...value };
|
|
108
|
-
return /^\d+$/.test(String(nextKey)) ? [] : {};
|
|
108
|
+
return getPointerSetter(pointer)(data, value);
|
|
109
109
|
}
|
|
110
110
|
|
|
111
111
|
/**
|
|
@@ -138,6 +138,93 @@ export function appendItem(data, pointer, value) {
|
|
|
138
138
|
return setValueAtPointer(data, pointer, next);
|
|
139
139
|
}
|
|
140
140
|
|
|
141
|
+
/**
|
|
142
|
+
* Every pointer whose value differs between two documents, in
|
|
143
|
+
* document order.
|
|
144
|
+
*
|
|
145
|
+
* Membership is significant: an added or removed member (or array tail
|
|
146
|
+
* slot) contributes its pointer even when both sides read back as
|
|
147
|
+
* `null` through a pointer lookup. Reference-equal subtrees are skipped
|
|
148
|
+
* whole, so over copy-on-write edits — which is how every writer in
|
|
149
|
+
* this package produces its next document — the walk costs O(change),
|
|
150
|
+
* not O(document).
|
|
151
|
+
*
|
|
152
|
+
* Two callers rely on it: the session's navigation-guard evidence
|
|
153
|
+
* (`dirtyPaths`) and the rule memo's invalidation set.
|
|
154
|
+
* @param {any} previous
|
|
155
|
+
* @param {any} current
|
|
156
|
+
* @returns {string[]}
|
|
157
|
+
*/
|
|
158
|
+
export function changedPointers(previous, current) {
|
|
159
|
+
/** @type {string[]} */
|
|
160
|
+
const out = [];
|
|
161
|
+
collectChanged(previous, current, '', out);
|
|
162
|
+
return out;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function collectChanged(previous, current, pointer, out) {
|
|
166
|
+
// Reference equality first, at every level and before any pointer
|
|
167
|
+
// string is built: over a copy-on-write edit almost every member of
|
|
168
|
+
// the touched container is the identical value, and formatting a
|
|
169
|
+
// pointer for each of them would put the document's WIDTH back into a
|
|
170
|
+
// walk whose whole point is to cost only its depth.
|
|
171
|
+
if (previous === current) return;
|
|
172
|
+
if (Array.isArray(previous) && Array.isArray(current)) {
|
|
173
|
+
const shared = Math.min(previous.length, current.length);
|
|
174
|
+
for (let i = 0; i < shared; i++) {
|
|
175
|
+
if (previous[i] !== current[i])
|
|
176
|
+
collectChanged(previous[i], current[i], `${pointer}/${i}`, out);
|
|
177
|
+
}
|
|
178
|
+
const longest = Math.max(previous.length, current.length);
|
|
179
|
+
for (let i = shared; i < longest; i++)
|
|
180
|
+
out.push(`${pointer}/${i}`); // added or removed tail slot
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
if (previous !== null && typeof previous === 'object' && !Array.isArray(previous)
|
|
184
|
+
&& current !== null && typeof current === 'object' && !Array.isArray(current)) {
|
|
185
|
+
const before = Object.keys(previous);
|
|
186
|
+
const after = Object.keys(current);
|
|
187
|
+
// Same members in the same order — which is what a copy-on-write
|
|
188
|
+
// edit of one member produces — needs no membership probing at all,
|
|
189
|
+
// just a value compare per key. The general path below is for real
|
|
190
|
+
// shape changes.
|
|
191
|
+
if (before.length === after.length && sameOrder(before, after)) {
|
|
192
|
+
for (let i = 0; i < before.length; i++) {
|
|
193
|
+
const key = before[i];
|
|
194
|
+
if (previous[key] !== current[key]) {
|
|
195
|
+
collectChanged(previous[key], current[key],
|
|
196
|
+
`${pointer}/${encodeJSONPointerSegment(key)}`, out);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
// own keys only, membership by Object.hasOwn — JSON member names
|
|
202
|
+
// like 'constructor', 'toString' or a parsed own '__proto__' are
|
|
203
|
+
// legal data and must diff as data, never through the prototype
|
|
204
|
+
// chain (null-prototype records diff identically)
|
|
205
|
+
for (const key of before) {
|
|
206
|
+
if (!Object.hasOwn(current, key))
|
|
207
|
+
out.push(`${pointer}/${encodeJSONPointerSegment(key)}`); // removed member
|
|
208
|
+
else if (previous[key] !== current[key])
|
|
209
|
+
collectChanged(previous[key], current[key], `${pointer}/${encodeJSONPointerSegment(key)}`, out);
|
|
210
|
+
}
|
|
211
|
+
for (const key of after) {
|
|
212
|
+
if (!Object.hasOwn(previous, key))
|
|
213
|
+
out.push(`${pointer}/${encodeJSONPointerSegment(key)}`); // added member
|
|
214
|
+
}
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
if (!equalsJson(previous, current)) out.push(pointer);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/** Whether two key lists hold the same names in the same positions. */
|
|
221
|
+
function sameOrder(a, b) {
|
|
222
|
+
for (let i = 0; i < a.length; i++) {
|
|
223
|
+
if (a[i] !== b[i]) return false;
|
|
224
|
+
}
|
|
225
|
+
return true;
|
|
226
|
+
}
|
|
227
|
+
|
|
141
228
|
//#endregion
|
|
142
229
|
|
|
143
230
|
/**
|
package/src/deps.js
ADDED
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
//@ts-check
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* What a rule reads: the data dependencies of an `x-form` rule query,
|
|
5
|
+
* as JSON Pointer prefixes.
|
|
6
|
+
*
|
|
7
|
+
* The point is invalidation. `evaluateFormRules` re-runs every rule on
|
|
8
|
+
* every keystroke; with a dependency set per rule it can re-run only
|
|
9
|
+
* the rules a change can possibly have affected (rules.js, the memo).
|
|
10
|
+
* A dependency set is therefore allowed to be too LARGE — a rule that
|
|
11
|
+
* re-runs needlessly is slow, not wrong — and must never be too small.
|
|
12
|
+
*
|
|
13
|
+
* The analysis is deliberately shallow, and sound because of one
|
|
14
|
+
* property of the query language: the only way into the input document
|
|
15
|
+
* is a ROOT-ANCHORED path string (`$`, `$.x`, `$['x']`). Everything
|
|
16
|
+
* else a query can read is derived from one of those — a `$let`/`$for`
|
|
17
|
+
* variable holds the result of an expression in the same document, and
|
|
18
|
+
* the only externals a form rule may bind are `value` (the field's own
|
|
19
|
+
* value, which the rule depends on anyway) and `pointer` (a string).
|
|
20
|
+
* So collecting the root-anchored paths, and reducing each to the
|
|
21
|
+
* literal prefix it starts with, over-approximates every read.
|
|
22
|
+
*
|
|
23
|
+
* Reduction stops at the first segment that is not a plain name or
|
|
24
|
+
* index: `$.a.b[*].c` reduces to `/a/b`, and a filter's own comparisons
|
|
25
|
+
* are collected separately because a filter can name another part of
|
|
26
|
+
* the document (`$.lines[?@.id == $.selected]`).
|
|
27
|
+
*
|
|
28
|
+
* RETAINED beside `analyzeQuery` (QUERY-FORMAT.md Appendix C),
|
|
29
|
+
* deliberately: the published analysis reports which OPERATORS,
|
|
30
|
+
* functions and externals a query uses — not the data-pointer prefixes
|
|
31
|
+
* this invalidation needs — and this scanner is total over any JSON
|
|
32
|
+
* value (a rule that does not even normalize still yields a sound
|
|
33
|
+
* "depends on everything" answer) where analysis would throw. Deriving
|
|
34
|
+
* prefixes from the analysis tree's `path` nodes would be exact, at the
|
|
35
|
+
* price of a full normalization per rule per form build; take that
|
|
36
|
+
* route only if the over-approximation ever measurably hurts.
|
|
37
|
+
*/
|
|
38
|
+
|
|
39
|
+
import { parseJSONPath } from '@jarenjs/json/path';
|
|
40
|
+
import { encodeJSONPointerSegment } from '@jarenjs/json/pointer';
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* The dependency set of a rule: pointer prefixes, or `ALL_POINTERS` for
|
|
44
|
+
* "any change matters" (a query that reads the whole document).
|
|
45
|
+
* @typedef {string[]} RuleDependencies
|
|
46
|
+
*/
|
|
47
|
+
|
|
48
|
+
/** A rule that reads the root depends on everything. */
|
|
49
|
+
export const ALL_POINTERS = [''];
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Collect the data dependencies of one rule query document.
|
|
53
|
+
* @param {any} doc - The authored rule document
|
|
54
|
+
* @returns {RuleDependencies} Pointer prefixes, deduplicated
|
|
55
|
+
*/
|
|
56
|
+
export function queryDependencies(doc) {
|
|
57
|
+
/** @type {Set<string>} */
|
|
58
|
+
const out = new Set();
|
|
59
|
+
collectStrings(doc, out);
|
|
60
|
+
if (out.has('')) return ALL_POINTERS;
|
|
61
|
+
return [...out];
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Merge dependency sets, collapsing to {@link ALL_POINTERS} when any of
|
|
66
|
+
* them reads the root and dropping prefixes another already covers.
|
|
67
|
+
* @param {RuleDependencies[]} sets
|
|
68
|
+
* @returns {RuleDependencies}
|
|
69
|
+
*/
|
|
70
|
+
export function mergeDependencies(sets) {
|
|
71
|
+
/** @type {Set<string>} */
|
|
72
|
+
const all = new Set();
|
|
73
|
+
for (const set of sets) {
|
|
74
|
+
for (const dep of set) {
|
|
75
|
+
if (dep === '') return ALL_POINTERS;
|
|
76
|
+
all.add(dep);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
const deps = [...all];
|
|
80
|
+
return deps.filter((dep) => !deps.some((other) => other !== dep && isPointerPrefix(other, dep)));
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Whether a change at `changed` can affect a rule depending on `dep`.
|
|
85
|
+
* True in BOTH nesting directions: a change inside a dependency
|
|
86
|
+
* (`/a` vs `/a/b`) alters what the rule reads, and a change ABOVE one
|
|
87
|
+
* (`/a/b` vs `/a`) can replace the container it reads through.
|
|
88
|
+
* @param {string} dep @param {string} changed
|
|
89
|
+
* @returns {boolean}
|
|
90
|
+
*/
|
|
91
|
+
export function dependencyTouched(dep, changed) {
|
|
92
|
+
return isPointerPrefix(dep, changed) || isPointerPrefix(changed, dep);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Whether `prefix` is `pointer` itself or an ancestor location of it. */
|
|
96
|
+
function isPointerPrefix(prefix, pointer) {
|
|
97
|
+
if (prefix === '') return true;
|
|
98
|
+
if (!pointer.startsWith(prefix)) return false;
|
|
99
|
+
return pointer.length === prefix.length || pointer.charCodeAt(prefix.length) === 0x2f;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Walk a rule document for path strings. A bare string is a path
|
|
104
|
+
* expression in this language, so every string is a candidate — except
|
|
105
|
+
* under `$const`, which is exactly the marker that its value is a
|
|
106
|
+
* literal.
|
|
107
|
+
*/
|
|
108
|
+
function collectStrings(node, out) {
|
|
109
|
+
if (typeof node === 'string') {
|
|
110
|
+
addPath(node, out);
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
if (Array.isArray(node)) {
|
|
114
|
+
for (const item of node) collectStrings(item, out);
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
if (node === null || typeof node !== 'object') return;
|
|
118
|
+
for (const key of Object.keys(node)) {
|
|
119
|
+
if (key === '$const') continue;
|
|
120
|
+
collectStrings(node[key], out);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Reduce one path string to its dependency prefix, if it is a
|
|
126
|
+
* root-anchored path at all. A variable-rooted path (`$item.price`) is
|
|
127
|
+
* not RFC 9535 and never parses here — deliberately: whatever the
|
|
128
|
+
* variable holds came from a root-anchored path in the same document,
|
|
129
|
+
* which this walk has already seen.
|
|
130
|
+
*/
|
|
131
|
+
function addPath(source, out) {
|
|
132
|
+
if (source.length === 0 || source.charCodeAt(0) !== 0x24) return; // not '$'
|
|
133
|
+
let ast;
|
|
134
|
+
try {
|
|
135
|
+
ast = parseJSONPath(source);
|
|
136
|
+
}
|
|
137
|
+
catch {
|
|
138
|
+
return; // a variable-rooted path, or not a path at all
|
|
139
|
+
}
|
|
140
|
+
out.add(prefixOf(ast.segments));
|
|
141
|
+
for (const segment of ast.segments) {
|
|
142
|
+
for (const selector of segment.selectors) {
|
|
143
|
+
if (selector.kind === 'filter') collectFilterQueries(selector.expr, out);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** The literal pointer prefix a segment list starts with. */
|
|
149
|
+
function prefixOf(segments) {
|
|
150
|
+
let pointer = '';
|
|
151
|
+
for (const segment of segments) {
|
|
152
|
+
if (segment.descendant || segment.selectors.length !== 1) break;
|
|
153
|
+
const selector = segment.selectors[0];
|
|
154
|
+
if (selector.kind === 'name')
|
|
155
|
+
pointer += `/${encodeJSONPointerSegment(selector.name)}`;
|
|
156
|
+
else if (selector.kind === 'index' && selector.index >= 0)
|
|
157
|
+
pointer += `/${selector.index}`;
|
|
158
|
+
else break;
|
|
159
|
+
}
|
|
160
|
+
return pointer;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Collect the root-anchored queries embedded in a filter expression —
|
|
165
|
+
* `$.selected` in `$.lines[?@.id == $.selected]` is a dependency of the
|
|
166
|
+
* enclosing rule as much as any top-level path is.
|
|
167
|
+
*/
|
|
168
|
+
function collectFilterQueries(node, out) {
|
|
169
|
+
if (node === null || typeof node !== 'object') return;
|
|
170
|
+
if (node.query !== undefined && Array.isArray(node.query.segments)) {
|
|
171
|
+
if (node.query.relative !== true) out.add(prefixOf(node.query.segments));
|
|
172
|
+
else collectFilterQueries(node.query, out);
|
|
173
|
+
}
|
|
174
|
+
for (const key of ['operands', 'operand', 'left', 'right', 'args', 'expr']) {
|
|
175
|
+
const child = node[key];
|
|
176
|
+
if (Array.isArray(child)) for (const item of child) collectFilterQueries(item, out);
|
|
177
|
+
else if (child !== undefined) collectFilterQueries(child, out);
|
|
178
|
+
}
|
|
179
|
+
}
|
package/src/formats.js
CHANGED
|
@@ -17,6 +17,10 @@ import {
|
|
|
17
17
|
numberFormatTesters,
|
|
18
18
|
} from '@jarenjs/formats';
|
|
19
19
|
|
|
20
|
+
import {
|
|
21
|
+
isValidGeoJson,
|
|
22
|
+
} from '@jarenjs/core/geo';
|
|
23
|
+
|
|
20
24
|
/**
|
|
21
25
|
* @typedef {object} FormatInfo
|
|
22
26
|
* @property {(value: string) => boolean} test - Synchronous validity test
|
|
@@ -26,22 +30,28 @@ import {
|
|
|
26
30
|
|
|
27
31
|
/**
|
|
28
32
|
* Rendering hints per format name. Formats without an entry render as a
|
|
29
|
-
* plain text input (number-group formats as a number input).
|
|
30
|
-
*
|
|
33
|
+
* plain text input (number-group formats as a number input). A hint may
|
|
34
|
+
* also override the canonical `test` when what a form field holds is not
|
|
35
|
+
* what the validator's format judges (a field holds `geojson` as text,
|
|
36
|
+
* the format applies to the parsed object).
|
|
37
|
+
* @type {Record<string, { control?: string, placeholder?: string, test?: (value: string) => boolean }>}
|
|
31
38
|
*/
|
|
32
39
|
const FORM_HINTS = {
|
|
33
|
-
// -- date and time
|
|
34
|
-
//
|
|
40
|
+
// -- date and time. Which formats get a native control is decided by
|
|
41
|
+
// the offset, not by convenience: HTML's `datetime-local` and `time`
|
|
42
|
+
// inputs cannot produce one, so binding them to the RFC 3339 formats
|
|
43
|
+
// would make the control emit values its own schema rejects. The ISO
|
|
44
|
+
// formats leave the offset optional and are exactly what those
|
|
45
|
+
// inputs spell, so they map losslessly.
|
|
35
46
|
'date': { control: 'date', placeholder: '2024-01-15' },
|
|
36
47
|
'time': { placeholder: '13:45:30Z' },
|
|
37
48
|
'date-time': { placeholder: '2024-01-15T13:45:30Z' },
|
|
38
|
-
'iso-date-time': { placeholder: '2024-01-15T13:45:30' },
|
|
39
|
-
'iso-time': { placeholder: '13:45:30' },
|
|
49
|
+
'iso-date-time': { control: 'datetime-local', placeholder: '2024-01-15T13:45:30' },
|
|
50
|
+
'iso-time': { control: 'time', placeholder: '13:45:30' },
|
|
40
51
|
'duration': { placeholder: 'P3DT4H' },
|
|
41
52
|
|
|
42
53
|
// -- email
|
|
43
54
|
'email': { control: 'email', placeholder: 'user@example.com' },
|
|
44
|
-
'email--full': { control: 'email', placeholder: 'user@example.com' },
|
|
45
55
|
'idn-email': { control: 'email', placeholder: 'user@example.com' },
|
|
46
56
|
|
|
47
57
|
// -- hosts and addresses
|
|
@@ -53,12 +63,9 @@ const FORM_HINTS = {
|
|
|
53
63
|
|
|
54
64
|
// -- uris
|
|
55
65
|
'uri': { control: 'url', placeholder: 'https://example.com/path' },
|
|
56
|
-
'uri--full': { control: 'url', placeholder: 'https://example.com/path' },
|
|
57
66
|
'uri-reference': { placeholder: '/relative/path' },
|
|
58
|
-
'uri-reference--full': { placeholder: '/relative/path' },
|
|
59
67
|
'uri-template': { placeholder: '/users/{id}' },
|
|
60
68
|
'url': { control: 'url', placeholder: 'https://example.com' },
|
|
61
|
-
'url--full': { control: 'url', placeholder: 'https://example.com' },
|
|
62
69
|
'iri': { control: 'url', placeholder: 'https://example.com/päth' },
|
|
63
70
|
'iri-reference': { placeholder: '/relative/päth' },
|
|
64
71
|
|
|
@@ -77,6 +84,7 @@ const FORM_HINTS = {
|
|
|
77
84
|
|
|
78
85
|
// -- misc
|
|
79
86
|
'regex': { placeholder: '^[a-z]+$' },
|
|
87
|
+
'iregexp': { placeholder: '[a-z]+' },
|
|
80
88
|
'base64': { control: 'textarea', placeholder: 'SGVsbG8=' },
|
|
81
89
|
'byte': { control: 'textarea', placeholder: 'SGVsbG8=' },
|
|
82
90
|
'alpha': { placeholder: 'alpha' },
|
|
@@ -90,6 +98,23 @@ const FORM_HINTS = {
|
|
|
90
98
|
'isbn13': { placeholder: '978-3-16-148410-0' },
|
|
91
99
|
'iban': { placeholder: 'NL91ABNA0417164300' },
|
|
92
100
|
'country2': { placeholder: 'NL' },
|
|
101
|
+
|
|
102
|
+
// -- geospatial. The geojson tester judges the OBJECT, but a form
|
|
103
|
+
// field holds its text, so the field-level test parses first.
|
|
104
|
+
'geohash': { placeholder: 'u173z' },
|
|
105
|
+
'wkt': { placeholder: 'POINT (4.9041 52.3676)' },
|
|
106
|
+
'geojson': {
|
|
107
|
+
control: 'textarea',
|
|
108
|
+
placeholder: '{"type":"Point","coordinates":[4.9,52.4]}',
|
|
109
|
+
test: (text) => {
|
|
110
|
+
try {
|
|
111
|
+
return isValidGeoJson(JSON.parse(text));
|
|
112
|
+
}
|
|
113
|
+
catch {
|
|
114
|
+
return false;
|
|
115
|
+
}
|
|
116
|
+
},
|
|
117
|
+
},
|
|
93
118
|
};
|
|
94
119
|
|
|
95
120
|
function acceptAnything() {
|
package/src/index.js
CHANGED
|
@@ -25,6 +25,8 @@ export {
|
|
|
25
25
|
compileFormRules,
|
|
26
26
|
evaluateFormRules,
|
|
27
27
|
formRulesToQueryAssertions,
|
|
28
|
+
pruneHiddenValues,
|
|
29
|
+
createRuleMemo,
|
|
28
30
|
} from './rules.js';
|
|
29
31
|
|
|
30
32
|
export {
|
|
@@ -32,6 +34,13 @@ export {
|
|
|
32
34
|
validateAllFields,
|
|
33
35
|
} from './validate.js';
|
|
34
36
|
|
|
37
|
+
export {
|
|
38
|
+
formsMessagesEn,
|
|
39
|
+
formChromeLabels,
|
|
40
|
+
compileMessageTemplate,
|
|
41
|
+
compileMessageCatalog,
|
|
42
|
+
} from './messages.js';
|
|
43
|
+
|
|
35
44
|
export {
|
|
36
45
|
createInitialData,
|
|
37
46
|
createItemValue,
|
|
@@ -47,3 +56,7 @@ export {
|
|
|
47
56
|
FORM_FORMATS,
|
|
48
57
|
getFormatInfo,
|
|
49
58
|
} from './formats.js';
|
|
59
|
+
|
|
60
|
+
export {
|
|
61
|
+
buildFormViewModel,
|
|
62
|
+
} from './viewmodel.js';
|
package/src/messages.js
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
//@ts-check
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Forms message catalogs: structured field errors, rendered late.
|
|
5
|
+
*
|
|
6
|
+
* Every failure a form check produces is identified by a stable message
|
|
7
|
+
* key (`msgid`, the `form/`-prefixed keyword or `x-form/assert`) plus raw
|
|
8
|
+
* structured `params`; the human text comes from a catalog - a plain flat
|
|
9
|
+
* object of closures (or template strings that compile into closures).
|
|
10
|
+
* Forms keeps its second-person field voice ("This field is required"),
|
|
11
|
+
* hence the separate `form/*` key space next to the validator's document
|
|
12
|
+
* voice ("must have required property 'x'").
|
|
13
|
+
*
|
|
14
|
+
* Forms and the validator must serve one locale pack
|
|
15
|
+
* (`@jarenjs/locales`) each from its own key space, without either
|
|
16
|
+
* package depending on the other - so the catalog contract they share
|
|
17
|
+
* (template syntax, compilation, value rendering) is kernel property in
|
|
18
|
+
* `@jarenjs/core/message`. Both compilers are re-exported here so a form
|
|
19
|
+
* consumer never has to reach past `@jarenjs/forms`.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import {
|
|
23
|
+
formatMessageValue,
|
|
24
|
+
compileMessageCatalog,
|
|
25
|
+
} from '@jarenjs/core/message';
|
|
26
|
+
|
|
27
|
+
export {
|
|
28
|
+
compileMessageTemplate,
|
|
29
|
+
compileMessageCatalog,
|
|
30
|
+
} from '@jarenjs/core/message';
|
|
31
|
+
|
|
32
|
+
//#region English catalog
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* The built-in English forms catalog. Key set = exactly the `form/*` keys
|
|
36
|
+
* `validateField` emits, plus `x-form/assert` (the rules default). The
|
|
37
|
+
* strings are byte-identical to the historical inline template literals.
|
|
38
|
+
* @type {Record<string, string | ((params: object, error?: object) => string)>}
|
|
39
|
+
*/
|
|
40
|
+
export const formsMessagesEn = {
|
|
41
|
+
'form/required': 'This field is required',
|
|
42
|
+
'form/type': (p) => `Must be ${(p.type === 'integer' || p.type === 'array' || p.type === 'object') ? 'an' : 'a'} ${p.type}`,
|
|
43
|
+
'form/const': (p) => `Must be ${formatMessageValue(p.constValue)}`,
|
|
44
|
+
'form/enum': (p) => `Must be one of: ${p.enumValues?.map(formatMessageValue).join(', ')}`,
|
|
45
|
+
'form/minLength': (p) => `Must be at least ${p.limit} character${p.limit === 1 ? '' : 's'} (currently ${p.len})`,
|
|
46
|
+
'form/maxLength': (p) => `Must be at most ${p.limit} character${p.limit === 1 ? '' : 's'} (currently ${p.len})`,
|
|
47
|
+
'form/pattern': 'Must match pattern {pattern}',
|
|
48
|
+
'form/format': 'Must be a valid {format}',
|
|
49
|
+
'form/minimum': 'Must be at least {limit}',
|
|
50
|
+
'form/maximum': 'Must be at most {limit}',
|
|
51
|
+
'form/exclusiveMinimum': 'Must be greater than {limit}',
|
|
52
|
+
'form/exclusiveMaximum': 'Must be less than {limit}',
|
|
53
|
+
'form/multipleOf': 'Must be a multiple of {multipleOf}',
|
|
54
|
+
'form/minItems': (p) => `Must have at least ${p.limit} item${p.limit === 1 ? '' : 's'}`,
|
|
55
|
+
'form/maxItems': (p) => `Must have at most ${p.limit} item${p.limit === 1 ? '' : 's'}`,
|
|
56
|
+
'form/uniqueItems': 'Items must be unique',
|
|
57
|
+
'form/minProperties': 'Must have at least {limit} properties',
|
|
58
|
+
'form/maxProperties': 'Must have at most {limit} properties',
|
|
59
|
+
'x-form/assert': 'Invalid value',
|
|
60
|
+
// Form CHROME, not validation: the accessible names of the array
|
|
61
|
+
// buttons. A symbol-only button ('+', '×') is unreadable to a screen
|
|
62
|
+
// reader and untranslatable as a glyph, so the name travels as a
|
|
63
|
+
// message like every other string an operator can hear.
|
|
64
|
+
'form/addItem': 'Add item',
|
|
65
|
+
'form/removeItem': 'Remove item',
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
/** The compiled built-in English catalog (module-level singleton). */
|
|
69
|
+
export const formsMessages = compileMessageCatalog(formsMessagesEn);
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Resolve a message key through a caller catalog with built-in English
|
|
73
|
+
* fallback and render it.
|
|
74
|
+
* @param {Readonly<Record<string, (params: object, error?: object) => string>>|undefined} catalog - A compiled catalog, or undefined for English
|
|
75
|
+
* @param {string} msgid - The message key
|
|
76
|
+
* @param {object} params - The structured params
|
|
77
|
+
* @returns {string} The rendered message
|
|
78
|
+
*/
|
|
79
|
+
export function renderFormsMessage(catalog, msgid, params) {
|
|
80
|
+
let render = catalog !== undefined ? catalog[msgid] : undefined;
|
|
81
|
+
if (render === undefined) render = formsMessages[msgid];
|
|
82
|
+
if (render === undefined) return msgid;
|
|
83
|
+
return render(params);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* The localized chrome strings a form renderer needs: the accessible
|
|
88
|
+
* names of the array add/remove buttons.
|
|
89
|
+
*
|
|
90
|
+
* The stylesheet that renders a form is plain JSON built once, so it
|
|
91
|
+
* cannot look anything up at render time — the host resolves these and
|
|
92
|
+
* hands them to `createFormView`. Kept next to the error catalog on
|
|
93
|
+
* purpose: one keyspace, one parity gate across every locale pack.
|
|
94
|
+
* @param {Readonly<Record<string, (params: object, error?: object) => string>>} [catalog] - A compiled catalog, or undefined for English
|
|
95
|
+
* @returns {{addItem: string, removeItem: string}}
|
|
96
|
+
* @example
|
|
97
|
+
* createFormView({ labels: formChromeLabels(catalogs[locale]) });
|
|
98
|
+
*/
|
|
99
|
+
export function formChromeLabels(catalog = undefined) {
|
|
100
|
+
return {
|
|
101
|
+
addItem: renderFormsMessage(catalog, 'form/addItem', {}),
|
|
102
|
+
removeItem: renderFormsMessage(catalog, 'form/removeItem', {}),
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
//#endregion
|