@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/viewmodel.js
ADDED
|
@@ -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
|
+
}
|