@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.
@@ -0,0 +1,419 @@
1
+ //@ts-check
2
+ /**
3
+ * @file The form view model — the render tree.
4
+ *
5
+ * `buildFormViewModel` composes everything this package knows about a
6
+ * form at one instant — the field tree (model.js), the current data,
7
+ * per-field validation (validate.js) and `x-form` rule state (rules.js)
8
+ * — into ONE plain-JSON tree of render nodes. It is the "computed view"
9
+ * layer the package README promised: no DOM, no framework, just the
10
+ * document any renderer needs — a React component, a template engine,
11
+ * or a JSLT stylesheet (the standard form rules of `@jarenjs/app`
12
+ * dispatch over exactly this shape).
13
+ *
14
+ * Policies inherited from rules.js: a field whose rule state says
15
+ * `visible: false` is EXCLUDED from the tree (renderers cannot leak
16
+ * hidden data by accident); `enabled` and `computed` are carried on the
17
+ * node. Array item templates expand per element of the actual data, so
18
+ * node pointers are always concrete (`/lines/2/amount`), matching the
19
+ * pointer keys of `evaluateFormRules` and `validateAllFields`.
20
+ */
21
+
22
+ import { equalsJson } from '@jarenjs/core/object';
23
+ import { encodeJSONPointerSegment } from '@jarenjs/json/pointer';
24
+
25
+ import { getValueAtPointer, createItemValue, changedPointers } from './data.js';
26
+ import { evaluateFormRules } from './rules.js';
27
+ import { validateAllFields } from './validate.js';
28
+
29
+ /** @typedef {import('./model.js').FormField} FormField */
30
+
31
+ const EMPTY_STATE = Object.freeze({});
32
+
33
+ /**
34
+ * One node of the render tree. Everything is plain JSON.
35
+ * @typedef {Object} FormViewNode
36
+ * @property {string} pointer - Concrete data pointer ('' for the root).
37
+ * @property {string} key - Property name, or the element index as a string.
38
+ * @property {string} label
39
+ * @property {string|null} description
40
+ * @property {string} kind - The field kind (model.js).
41
+ * @property {string} control - The rendering hint (model.js).
42
+ * @property {boolean} required
43
+ * @property {boolean} readOnly
44
+ * @property {boolean} enabled - `x-form.enabled`, defaulting true.
45
+ * @property {string|null} placeholder
46
+ * @property {any} value - The current value (`x-form.computed` wins);
47
+ * `null` when the field is absent from the data.
48
+ * @property {Array<{value: any, key: string, label: string, selected: boolean}>|null} options
49
+ * Select options, `selected` precomputed against the current value.
50
+ * `key` is the option's value as JSON text — what a string-valued
51
+ * control (a DOM `<option>`) can carry and hand back losslessly.
52
+ * @property {string} [json] - The value as indented JSON text, on
53
+ * `json`-control nodes only: the editable text for a structured
54
+ * value, precomputed like `options`.
55
+ * @property {string[]} errors - Localized messages: field validation
56
+ * first, then rule asserts.
57
+ * @property {boolean} element - True when this node is an array element
58
+ * (item or tuple slot). Writers need this: RFC 6902 `add` is
59
+ * set-or-replace for object members but INSERT for array indices, so
60
+ * element nodes must be written with `replace`.
61
+ * @property {boolean} removable - True for item-template elements
62
+ * (tuple slots are fixed).
63
+ * @property {FormViewNode[]|null} children - Object members.
64
+ * @property {FormViewNode[]|null} items - Array elements, expanded.
65
+ * @property {any} addValue - Starter value for a new array item
66
+ * (`createItemValue`); only on array nodes with an item template.
67
+ * @property {string} [id] - Stable accessible element id derived from
68
+ * the pointer (session forms only).
69
+ * @property {string|null} [describedBy] - The id of this node's error
70
+ * text (`aria-describedby` wiring), `null` when the node has no
71
+ * errors (session forms only).
72
+ * @property {boolean} [dirty] - Whether this node's value differs from
73
+ * the session's initial data — presence-aware: a member added or
74
+ * removed is dirty even when the compared values coincide as `null`
75
+ * (session forms only).
76
+ * @property {boolean} [touched] - Whether the session marked this
77
+ * pointer visited (session forms only).
78
+ * @property {string[]} [serverErrors] - Server-reported messages for
79
+ * this pointer, kept distinct from the client-side `errors` (session
80
+ * forms only).
81
+ * @property {FormSessionSummary} [session] - The root summary (root
82
+ * node of session forms only).
83
+ */
84
+
85
+ /**
86
+ * The submit/draft session of a form: the lifecycle state around one
87
+ * edited document. Everything is JSON — the
88
+ * session lives in app state; this option only folds it into the tree.
89
+ *
90
+ * Hidden-field policy: fields excluded by `x-form` `visible` rules keep
91
+ * their values in the data — the view model never prunes; whether a
92
+ * submit drops them is a product decision made at the submit boundary.
93
+ *
94
+ * @typedef {Object} FormSessionOptions
95
+ * @property {any} [initial] - The baseline document; each node's
96
+ * `dirty` is a JSON deep-compare of its value against this.
97
+ * @property {string[] | Record<string, boolean>} [touched] - Pointers
98
+ * the operator has visited.
99
+ * @property {boolean} [submitted] - Whether a submit was attempted;
100
+ * echoed in the root summary (renderers typically surface every
101
+ * error once true).
102
+ * @property {Record<string, string | string[]> | Array<{ pointer: string, message: string }>} [serverErrors]
103
+ * Server-reported messages by JSON Pointer; folded onto the matching
104
+ * nodes as `serverErrors`, never mixed into the client `errors`.
105
+ * @property {string | null} [submitStatus] - The submit task status
106
+ * (e.g. 'idle'/'pending'/'done'/'error'); echoed in the root summary.
107
+ * @property {any} [requestId] - The in-flight submit's request
108
+ * identity; echoed in the root summary.
109
+ * @property {string} [idPrefix] - Prefix for the stable accessible ids
110
+ * (default 'form'); ids are `<prefix>--<pointer segments joined
111
+ * with ->` and `'<prefix>--root'` for the root.
112
+ */
113
+
114
+ /**
115
+ * The root summary of a session form (`root.session`) — the
116
+ * navigation-guard authority: derived from a full JSON comparison of
117
+ * the initial document against the current data, independent of what
118
+ * is rendered, so removed members, hidden retained values and
119
+ * missing-versus-`null` membership changes all count. The visible
120
+ * per-node `dirty`/`errors` members remain the render-layer summary.
121
+ * @typedef {Object} FormSessionSummary
122
+ * @property {boolean} dirty - Whether the current data differs from the
123
+ * initial document ANYWHERE.
124
+ * @property {string[]} dirtyPaths - Every changed pointer between the
125
+ * initial document and the current data, in diff-walk (document)
126
+ * order. A member added or removed — including an explicit-`null`
127
+ * membership change and a shortened array tail — contributes the
128
+ * pointer of the added/removed location.
129
+ * @property {boolean} submitted
130
+ * @property {string | null} submitStatus
131
+ * @property {any} requestId
132
+ * @property {number} errorCount - Client-side error total.
133
+ * @property {number} serverErrorCount - Server-reported error total.
134
+ */
135
+
136
+ /**
137
+ * Options for `buildFormViewModel`.
138
+ * @typedef {Object} FormViewModelOptions
139
+ * @property {object} [rules] - Compiled rules from `compileFormRules`;
140
+ * when given, `x-form` state (visible/enabled/computed/asserts) is
141
+ * evaluated and folded into the tree.
142
+ * @property {boolean} [validateFields] - Run `validateAllFields` and
143
+ * fold the per-field errors in (default false: pristine forms show
144
+ * no errors until the app opts in).
145
+ * @property {object} [catalog] - Compiled message catalog for error
146
+ * texts (compileMessageCatalog), default English.
147
+ * @property {import('./rules.js').RuleMemo} [memo] - A memo from
148
+ * `createRuleMemo`, reused across calls so only the rules a change
149
+ * can reach are re-evaluated. Keep one per form session; the tree it
150
+ * produces is the same either way.
151
+ * @property {FormSessionOptions} [session] - Fold a form session
152
+ * (initial/touched/submitted/serverErrors/submit identity) into the
153
+ * tree: every node gains `id`/`describedBy`/`dirty`/`touched`/
154
+ * `serverErrors`, and the root gains a `session` summary. Absent, the
155
+ * tree is byte-identical to the sessionless shape.
156
+ */
157
+
158
+ /**
159
+ * Build the render tree for one form instant.
160
+ *
161
+ * @example
162
+ * const model = buildFormModel(schema);
163
+ * const rules = compileFormRules(model);
164
+ * const tree = buildFormViewModel(model, data, { rules, validateFields: true });
165
+ * // tree.children[0] -> { pointer: '/email', control: 'email',
166
+ * // value: 'a@b.c', errors: [], ... }
167
+ *
168
+ * @param {FormField} model - The field tree from `buildFormModel`.
169
+ * @param {any} data - The current form data.
170
+ * @param {FormViewModelOptions} [options]
171
+ * @returns {FormViewNode|null} The root render node (`null` only when a
172
+ * root rule hides the whole form).
173
+ */
174
+ export function buildFormViewModel(model, data, options = {}) {
175
+ const ruleState = options.rules !== undefined
176
+ ? evaluateFormRules(options.rules, data, options.catalog, options.memo)
177
+ : EMPTY_STATE;
178
+ const fieldErrors = options.validateFields === true
179
+ ? validateAllFields(model, data, options.catalog)
180
+ : EMPTY_STATE;
181
+ const session = options.session !== undefined
182
+ ? compileSession(options.session, data)
183
+ : null;
184
+ const root = buildNode(model, '', data, ruleState, fieldErrors, false, false, session);
185
+ if (root !== null && session !== null) {
186
+ root.session = {
187
+ dirty: session.dirtyPaths.length > 0,
188
+ dirtyPaths: session.dirtyPaths,
189
+ submitted: session.submitted,
190
+ submitStatus: session.submitStatus,
191
+ requestId: session.requestId,
192
+ errorCount: session.errorCount,
193
+ serverErrorCount: session.serverErrorCount,
194
+ };
195
+ }
196
+ return root;
197
+ }
198
+
199
+ /**
200
+ * Normalize the session options into the walk's working state.
201
+ * @param {FormSessionOptions} session
202
+ * @param {any} data
203
+ */
204
+ function compileSession(session, data) {
205
+ /** @type {Set<string>} */
206
+ const touched = new Set();
207
+ if (Array.isArray(session.touched)) {
208
+ for (const pointer of session.touched) touched.add(pointer);
209
+ }
210
+ else if (session.touched !== null && typeof session.touched === 'object') {
211
+ for (const pointer in session.touched) {
212
+ if (session.touched[pointer] === true) touched.add(pointer);
213
+ }
214
+ }
215
+ /** @type {Map<string, string[]>} */
216
+ const serverErrors = new Map();
217
+ const raw = session.serverErrors;
218
+ if (Array.isArray(raw)) {
219
+ for (const entry of raw) {
220
+ if (entry === null || typeof entry !== 'object' || typeof entry.pointer !== 'string') continue;
221
+ const list = serverErrors.get(entry.pointer) ?? [];
222
+ list.push(String(entry.message));
223
+ serverErrors.set(entry.pointer, list);
224
+ }
225
+ }
226
+ else if (raw !== null && typeof raw === 'object' && raw !== undefined) {
227
+ for (const pointer in raw) {
228
+ const value = raw[pointer];
229
+ serverErrors.set(pointer, Array.isArray(value) ? value.map(String) : [String(value)]);
230
+ }
231
+ }
232
+ const hasInitial = 'initial' in session;
233
+ // the navigation-guard evidence is a FULL diff of the two documents,
234
+ // never a walk of the rendered tree: removed members, hidden retained
235
+ // values and null-membership changes must all surface
236
+ const dirtyPaths = hasInitial ? changedPointers(session.initial, data) : [];
237
+ return {
238
+ hasInitial,
239
+ initial: session.initial,
240
+ data,
241
+ touched,
242
+ serverErrors,
243
+ submitted: session.submitted === true,
244
+ submitStatus: session.submitStatus ?? null,
245
+ requestId: session.requestId ?? null,
246
+ idPrefix: session.idPrefix ?? 'form',
247
+ dirtyPaths,
248
+ errorCount: 0,
249
+ serverErrorCount: 0,
250
+ };
251
+ }
252
+
253
+
254
+ /**
255
+ * A stable accessible element id for a pointer: the encoded segments
256
+ * joined with '-', prefixed; the empty pointer is 'root'. The encoding
257
+ * is INJECTIVE — distinct pointers always get distinct ids:
258
+ *
259
+ * - every character outside `[A-Za-z0-9]` (including `-` and `_`
260
+ * themselves) is escaped as `_<codepoint>_` BEFORE the segments are
261
+ * joined, so a literal '-' or '_' inside a member name can never
262
+ * collide with the separator or an escape (`/a/b` → `f-a-b`,
263
+ * `/a-b` → `f-a_45_b`);
264
+ * - member ids carry the structural marker `f-`, so the root
265
+ * sentinel lives in a DISJOINT namespace: a member named `root`
266
+ * (`<prefix>--f-root`) can never collide with the root itself
267
+ * (`<prefix>--root`), and an empty member name (`<prefix>--f-`) is
268
+ * distinct from both.
269
+ * @param {string} prefix
270
+ * @param {string} pointer
271
+ * @returns {string}
272
+ */
273
+ function pointerId(prefix, pointer) {
274
+ if (pointer === '') return `${prefix}--root`;
275
+ const safe = pointer.slice(1)
276
+ .replace(/[^A-Za-z0-9/]/gu, (ch) => `_${ch.codePointAt(0)}_`)
277
+ .replace(/\//g, '-');
278
+ return `${prefix}--f-${safe}`;
279
+ }
280
+
281
+ /**
282
+ * @param {FormField} field
283
+ * @param {string} pointer - The concrete RFC 6901 pointer of this node
284
+ * (segments encoded with `encodeJSONPointerSegment`, the walk
285
+ * convention shared with the model, rule and validation pointers).
286
+ * @param {any} data - The form data root.
287
+ * @param {Record<string, any>} ruleState
288
+ * @param {Record<string, any>} fieldErrors
289
+ * @param {boolean} element
290
+ * @param {boolean} removable
291
+ * @param {ReturnType<typeof compileSession> | null} [session]
292
+ * @returns {FormViewNode|null}
293
+ */
294
+ function buildNode(field, pointer, data, ruleState, fieldErrors, element, removable, session = null) {
295
+ const rs = ruleState[pointer];
296
+ if (rs !== undefined && rs.visible === false) return null;
297
+
298
+ const raw = getValueAtPointer(data, pointer);
299
+ let value = field.kind === 'const' ? field.constValue : raw;
300
+ if (rs !== undefined && rs.computed !== undefined) value = rs.computed;
301
+
302
+ /** @type {string[]} */
303
+ const errors = [];
304
+ const fe = fieldErrors[pointer];
305
+ if (fe !== undefined) {
306
+ for (const e of fe) errors.push(e.message);
307
+ }
308
+ if (rs !== undefined && rs.errors !== undefined) {
309
+ for (const e of rs.errors) errors.push(e.message);
310
+ }
311
+
312
+ /** @type {FormViewNode} */
313
+ const node = {
314
+ pointer,
315
+ key: field.key ?? '',
316
+ label: field.label ?? '',
317
+ description: field.description ?? null,
318
+ kind: field.kind,
319
+ control: field.control,
320
+ required: field.required === true,
321
+ readOnly: field.readOnly === true,
322
+ enabled: rs === undefined || rs.enabled !== false,
323
+ placeholder: field.placeholder ?? null,
324
+ value: value === undefined ? null : value,
325
+ options: null,
326
+ errors,
327
+ element,
328
+ removable,
329
+ children: null,
330
+ items: null,
331
+ addValue: undefined,
332
+ };
333
+
334
+ if (field.control === 'json') {
335
+ // the editable text for a structured value: precomputed here so the
336
+ // renderer needs no encoder, mirroring how `options` are precomputed
337
+ node.json = node.value === null || node.value === undefined
338
+ ? ''
339
+ : JSON.stringify(node.value, null, 2);
340
+ }
341
+
342
+ if (field.enumValues !== null && field.enumValues !== undefined) {
343
+ node.options = field.enumValues.map((v, i) => ({
344
+ value: v,
345
+ // `key` is the JSON text of `value`: a DOM select carries strings,
346
+ // so a renderer needs something reversible to put in the control
347
+ // and hand back. Encoding here keeps the round trip lossless for
348
+ // number, boolean and null enums, which `String(v)` is not.
349
+ key: JSON.stringify(v) ?? 'null',
350
+ label: field.enumLabels?.[i] ?? String(v),
351
+ selected: v === node.value,
352
+ }));
353
+ }
354
+
355
+ if (session !== null) {
356
+ node.id = pointerId(session.idPrefix, pointer);
357
+ node.touched = session.touched.has(pointer);
358
+ const server = session.serverErrors.get(pointer);
359
+ node.serverErrors = server !== undefined ? server.slice() : [];
360
+ session.errorCount += errors.length;
361
+ session.serverErrorCount += node.serverErrors.length;
362
+ node.describedBy = errors.length > 0 || node.serverErrors.length > 0
363
+ ? `${node.id}-error`
364
+ : null;
365
+ if (session.hasInitial) {
366
+ // presence-aware: adding or removing a member whose value is
367
+ // null is a membership change, so it is dirty
368
+ const initialValue = getValueAtPointer(session.initial, pointer);
369
+ node.dirty = (initialValue === undefined) !== (raw === undefined)
370
+ || (initialValue !== undefined && !equalsJson(initialValue, raw));
371
+ }
372
+ else {
373
+ node.dirty = false;
374
+ }
375
+ }
376
+
377
+ if (field.children !== null && field.children !== undefined) {
378
+ const children = [];
379
+ for (const child of field.children) {
380
+ const built = buildNode(
381
+ child, `${pointer}/${encodeJSONPointerSegment(child.key)}`, data, ruleState,
382
+ fieldErrors, false, false, session);
383
+ if (built !== null) children.push(built);
384
+ }
385
+ node.children = children;
386
+ }
387
+
388
+ if (field.kind === 'array') {
389
+ const array = Array.isArray(raw) ? raw : [];
390
+ const items = [];
391
+ for (let i = 0; i < array.length; i++) {
392
+ const template = field.tuple !== null && field.tuple !== undefined && i < field.tuple.length
393
+ ? field.tuple[i]
394
+ : field.item;
395
+ if (template === null || template === undefined) break;
396
+ const isTupleSlot = field.tuple !== null && field.tuple !== undefined && i < field.tuple.length;
397
+ // A tuple slot is removable only as the array's LAST element:
398
+ // dropping one from the middle would slide every later value into
399
+ // a slot with a different schema. Whether the shortened tuple is
400
+ // still valid is `minItems`' answer to give, not this layer's.
401
+ const removable = !isTupleSlot || i === array.length - 1;
402
+ const built = buildNode(
403
+ template, `${pointer}/${i}`, data, ruleState, fieldErrors, true, removable, session);
404
+ if (built !== null) items.push(built);
405
+ }
406
+ node.items = items;
407
+ if (field.item !== null && field.item !== undefined) {
408
+ node.addValue = createItemValue(field.item) ?? null;
409
+ }
410
+ else if (field.tuple !== null && field.tuple !== undefined && array.length < field.tuple.length) {
411
+ // a tuple shorter than its schema grows one slot at a time, each
412
+ // starting from ITS OWN template — that is what makes a tuple
413
+ // loaded short (or absent) fillable at all
414
+ node.addValue = createItemValue(field.tuple[array.length]) ?? null;
415
+ }
416
+ }
417
+
418
+ return node;
419
+ }