@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/model.js
CHANGED
|
@@ -10,10 +10,18 @@
|
|
|
10
10
|
* validation (see validate.js).
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
|
+
import { isJsonObject } from '@jarenjs/core/object';
|
|
14
|
+
import { createWeakCache } from '@jarenjs/core/cache';
|
|
13
15
|
import {
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
} from '@jarenjs/
|
|
16
|
+
NUMERIC_CONSTRAINTS, STRING_CONSTRAINTS,
|
|
17
|
+
ARRAY_CONSTRAINTS, OBJECT_CONSTRAINTS,
|
|
18
|
+
} from '@jarenjs/core/schema';
|
|
19
|
+
|
|
20
|
+
import { encodeJSONPointerSegment } from '@jarenjs/json/pointer';
|
|
21
|
+
import {
|
|
22
|
+
collectSameDocumentAnchors,
|
|
23
|
+
resolveSameDocumentRef,
|
|
24
|
+
} from '@jarenjs/validate/normalize';
|
|
17
25
|
|
|
18
26
|
import {
|
|
19
27
|
getFormatInfo,
|
|
@@ -25,14 +33,16 @@ const DEFAULT_MAX_DEPTH = 24;
|
|
|
25
33
|
* @typedef {object} FormField
|
|
26
34
|
* @property {string} pointer - JSON pointer into the DATA (e.g. '/user/name')
|
|
27
35
|
* @property {string} key - Property name (or '-' for an array item template)
|
|
28
|
-
* @property {string}
|
|
36
|
+
* @property {string} msgid - The field's message-id base: `x-msgid` annotation or the pointer (the root field's base is the empty pointer '')
|
|
37
|
+
* @property {string} label - Human friendly label (schema title or humanized key), through the `t` hook
|
|
29
38
|
* @property {string|undefined} description
|
|
30
39
|
* @property {object} schema - The resolved subschema for this field
|
|
31
40
|
* @property {string} kind - 'string'|'number'|'integer'|'boolean'|'enum'|'const'|'object'|'array'|'unknown'
|
|
32
|
-
* @property {string} control - Suggested control: 'text'|'email'|'url'|'password'|'textarea'|'number'|'checkbox'|'select'|'date'|'color'|'json'
|
|
41
|
+
* @property {string} control - Suggested control: 'text'|'email'|'url'|'password'|'textarea'|'number'|'checkbox'|'select'|'date'|'datetime-local'|'time'|'color'|'json'
|
|
33
42
|
* @property {boolean} required - Whether the parent object requires this property
|
|
34
43
|
* @property {boolean} readOnly
|
|
35
44
|
* @property {Array<any>|null} enumValues - Options for a select control
|
|
45
|
+
* @property {Array<string>|null} enumLabels - Display labels parallel to enumValues (oneOf const/title idiom, through the `t` hook)
|
|
36
46
|
* @property {any} constValue - Fixed value when the schema is a const
|
|
37
47
|
* @property {any} defaultValue
|
|
38
48
|
* @property {string|undefined} placeholder
|
|
@@ -43,8 +53,22 @@ const DEFAULT_MAX_DEPTH = 24;
|
|
|
43
53
|
* @property {Array<FormField>|null} tuple - Fixed prefix fields for tuple arrays
|
|
44
54
|
*/
|
|
45
55
|
|
|
56
|
+
/**
|
|
57
|
+
* The static-text translation hook: receives a role-qualified message id
|
|
58
|
+
* (`<base>#label`, `<base>#description`, `<base>#placeholder`,
|
|
59
|
+
* `<base>#enum/<value>`) and the schema-derived fallback text; returns
|
|
60
|
+
* the text to display.
|
|
61
|
+
* @typedef {(msgid: string, fallback: string|undefined, params?: object) => string|undefined} TranslateHook
|
|
62
|
+
*/
|
|
63
|
+
|
|
64
|
+
/** @type {TranslateHook} The zero-cost identity hook. */
|
|
65
|
+
const identityT = (msgid, fallback) => fallback;
|
|
66
|
+
|
|
46
67
|
/**
|
|
47
68
|
* Convert 'firstName' / 'first_name' / 'first-name' to 'First Name'.
|
|
69
|
+
* Latin-script-oriented (word splitting on case/underscore/hyphen and
|
|
70
|
+
* ASCII capitalization); the `t` hook of buildFormModel is the override
|
|
71
|
+
* point for anything it mangles.
|
|
48
72
|
* @param {string} key
|
|
49
73
|
* @returns {string}
|
|
50
74
|
*/
|
|
@@ -59,24 +83,38 @@ export function humanizeKey(key) {
|
|
|
59
83
|
}
|
|
60
84
|
|
|
61
85
|
/**
|
|
62
|
-
*
|
|
63
|
-
*
|
|
64
|
-
*
|
|
86
|
+
* Anchor maps per root schema, computed once and held exactly as long
|
|
87
|
+
* as the root object itself (`collectSameDocumentAnchors` walks the
|
|
88
|
+
* whole document — per-field recomputation would be quadratic).
|
|
89
|
+
*/
|
|
90
|
+
const ANCHOR_MAPS = createWeakCache();
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Resolve a same-document `$ref` — `#` (the root), `#/pointer`, or
|
|
94
|
+
* `#anchor` — with the validator's own exported resolver, so a form
|
|
95
|
+
* derives its model from exactly the schema the validator would
|
|
96
|
+
* enforce. (The previous private re-implementation resolved only
|
|
97
|
+
* `#/pointer`, silently rendering `$ref: "#"` and `#anchor` fields as
|
|
98
|
+
* `unknown`.) External refs stay unresolvable by design.
|
|
65
99
|
* @param {string} ref
|
|
66
100
|
* @param {object} rootSchema
|
|
67
101
|
* @returns {object|boolean|null} The referenced schema or null when unresolvable
|
|
68
102
|
*/
|
|
69
103
|
function resolveLocalRef(ref, rootSchema) {
|
|
70
|
-
if (typeof ref !== 'string' || !ref.startsWith('
|
|
71
|
-
let
|
|
104
|
+
if (typeof ref !== 'string' || !ref.startsWith('#')) return null;
|
|
105
|
+
let fragment = ref;
|
|
72
106
|
try {
|
|
73
|
-
if (
|
|
74
|
-
const target = compileJSONPointer(pointer)(rootSchema);
|
|
75
|
-
return target === JSONPOINTER_NOTHING ? null : target;
|
|
107
|
+
if (fragment.indexOf('%') >= 0) fragment = decodeURIComponent(fragment);
|
|
76
108
|
}
|
|
77
|
-
catch (
|
|
109
|
+
catch (_e) {
|
|
78
110
|
return null; // malformed fragment: same 'unresolvable' answer as a missing target
|
|
79
111
|
}
|
|
112
|
+
const anchors = (rootSchema !== null && typeof rootSchema === 'object')
|
|
113
|
+
? ANCHOR_MAPS.getOrCreate(rootSchema, collectSameDocumentAnchors)
|
|
114
|
+
: undefined;
|
|
115
|
+
const target = resolveSameDocumentRef(fragment, rootSchema,
|
|
116
|
+
/** @type {Map<string, object>|undefined} */ (anchors));
|
|
117
|
+
return target === undefined ? null : target;
|
|
80
118
|
}
|
|
81
119
|
|
|
82
120
|
/**
|
|
@@ -98,7 +136,7 @@ export function resolveSchema(schema, rootSchema, depth = 0) {
|
|
|
98
136
|
if (target != null && typeof target === 'object') {
|
|
99
137
|
const deref = resolveSchema(target, rootSchema, depth + 1);
|
|
100
138
|
// 2019-09+: siblings apply together with the referenced schema
|
|
101
|
-
const { $ref, ...siblings } = schema;
|
|
139
|
+
const { $ref: _$ref, ...siblings } = schema;
|
|
102
140
|
resolved = (deref && typeof deref === 'object')
|
|
103
141
|
? { ...deref, ...siblings }
|
|
104
142
|
: deref;
|
|
@@ -130,6 +168,21 @@ export function resolveSchema(schema, rootSchema, depth = 0) {
|
|
|
130
168
|
return resolved;
|
|
131
169
|
}
|
|
132
170
|
|
|
171
|
+
/**
|
|
172
|
+
* The `oneOf: [{const, title}, ...]` idiom: every branch an object with a
|
|
173
|
+
* `const`. Returns the branches, or null when the idiom does not apply.
|
|
174
|
+
* @param {object} schema
|
|
175
|
+
* @returns {Array<{const: any, title?: string}>|null}
|
|
176
|
+
*/
|
|
177
|
+
function getOneOfConstBranches(schema) {
|
|
178
|
+
if (!Array.isArray(schema.oneOf) || schema.oneOf.length === 0) return null;
|
|
179
|
+
for (const branch of schema.oneOf) {
|
|
180
|
+
if (branch == null || typeof branch !== 'object' || Array.isArray(branch)
|
|
181
|
+
|| branch.const === undefined) return null;
|
|
182
|
+
}
|
|
183
|
+
return schema.oneOf;
|
|
184
|
+
}
|
|
185
|
+
|
|
133
186
|
/**
|
|
134
187
|
* Derive the field kind from a resolved schema.
|
|
135
188
|
* @param {object|boolean} schema
|
|
@@ -139,6 +192,8 @@ export function getFieldKind(schema) {
|
|
|
139
192
|
if (schema == null || typeof schema !== 'object') return 'unknown';
|
|
140
193
|
if (schema.const !== undefined) return 'const';
|
|
141
194
|
if (Array.isArray(schema.enum)) return 'enum';
|
|
195
|
+
// The oneOf const/title idiom is an enum with per-option labels
|
|
196
|
+
if (getOneOfConstBranches(schema) !== null) return 'enum';
|
|
142
197
|
|
|
143
198
|
let type = schema.type;
|
|
144
199
|
if (Array.isArray(type)) {
|
|
@@ -195,14 +250,21 @@ function getControl(kind, schema) {
|
|
|
195
250
|
* @param {object} schema
|
|
196
251
|
* @returns {object}
|
|
197
252
|
*/
|
|
253
|
+
/** The form-relevant constraint keywords: the shared groups plus the
|
|
254
|
+
* date bounds — constraints like any other; without them a date control
|
|
255
|
+
* has no min/max to offer and the user only learns the range by
|
|
256
|
+
* submitting. */
|
|
257
|
+
const FORM_CONSTRAINTS = [
|
|
258
|
+
...STRING_CONSTRAINTS,
|
|
259
|
+
...NUMERIC_CONSTRAINTS,
|
|
260
|
+
'formatMinimum', 'formatMaximum', 'formatExclusiveMinimum', 'formatExclusiveMaximum',
|
|
261
|
+
...ARRAY_CONSTRAINTS,
|
|
262
|
+
...OBJECT_CONSTRAINTS,
|
|
263
|
+
];
|
|
264
|
+
|
|
198
265
|
function getConstraints(schema) {
|
|
199
266
|
const c = {};
|
|
200
|
-
for (const key of
|
|
201
|
-
'minLength', 'maxLength', 'pattern', 'format',
|
|
202
|
-
'minimum', 'maximum', 'exclusiveMinimum', 'exclusiveMaximum', 'multipleOf',
|
|
203
|
-
'minItems', 'maxItems', 'uniqueItems',
|
|
204
|
-
'minProperties', 'maxProperties',
|
|
205
|
-
]) {
|
|
267
|
+
for (const key of FORM_CONSTRAINTS) {
|
|
206
268
|
if (schema[key] !== undefined) c[key] = schema[key];
|
|
207
269
|
}
|
|
208
270
|
return c;
|
|
@@ -216,36 +278,59 @@ function getConstraints(schema) {
|
|
|
216
278
|
* @param {string} key - Property name or '-' for an item template
|
|
217
279
|
* @param {boolean} required
|
|
218
280
|
* @param {number} depth
|
|
281
|
+
* @param {TranslateHook} t - The static-text translation hook
|
|
219
282
|
* @returns {FormField}
|
|
220
283
|
*/
|
|
221
|
-
function buildField(rawSchema, rootSchema, pointer, key, required, depth) {
|
|
284
|
+
function buildField(rawSchema, rootSchema, pointer, key, required, depth, t) {
|
|
222
285
|
const schema = resolveSchema(rawSchema, rootSchema, depth);
|
|
223
286
|
const effective = (schema != null && typeof schema === 'object') ? schema : {};
|
|
224
287
|
const kind = getFieldKind(schema);
|
|
225
288
|
const control = getControl(kind, effective);
|
|
226
289
|
const formatInfo = getFormatInfo(effective.format);
|
|
227
290
|
|
|
291
|
+
// The message-id base for static text: the x-msgid annotation, or the
|
|
292
|
+
// data pointer (the root field's base is the empty pointer '').
|
|
293
|
+
const base = typeof effective['x-msgid'] === 'string' ? effective['x-msgid'] : pointer;
|
|
294
|
+
|
|
295
|
+
const oneOfBranches = kind === 'enum' ? getOneOfConstBranches(effective) : null;
|
|
296
|
+
const enumValues = kind === 'enum'
|
|
297
|
+
? (Array.isArray(effective.enum)
|
|
298
|
+
? effective.enum
|
|
299
|
+
: oneOfBranches.map((branch) => branch.const))
|
|
300
|
+
: null;
|
|
301
|
+
const enumLabels = enumValues !== null
|
|
302
|
+
? enumValues.map((value, i) => t(
|
|
303
|
+
`${base}#enum/${String(value)}`,
|
|
304
|
+
oneOfBranches !== null && typeof oneOfBranches[i].title === 'string'
|
|
305
|
+
? oneOfBranches[i].title
|
|
306
|
+
: String(value)))
|
|
307
|
+
: null;
|
|
308
|
+
|
|
309
|
+
const placeholder = effective.examples?.[0] !== undefined
|
|
310
|
+
? String(effective.examples[0])
|
|
311
|
+
: formatInfo?.placeholder;
|
|
312
|
+
|
|
228
313
|
/** @type {FormField} */
|
|
229
314
|
const field = {
|
|
230
315
|
pointer,
|
|
231
316
|
key,
|
|
232
|
-
|
|
233
|
-
|
|
317
|
+
msgid: base,
|
|
318
|
+
label: t(`${base}#label`, effective.title || humanizeKey(key)),
|
|
319
|
+
description: t(`${base}#description`, effective.description),
|
|
234
320
|
schema: effective,
|
|
235
321
|
kind,
|
|
236
322
|
control,
|
|
237
323
|
required,
|
|
238
324
|
readOnly: effective.readOnly === true,
|
|
239
|
-
enumValues
|
|
325
|
+
enumValues,
|
|
326
|
+
enumLabels,
|
|
240
327
|
constValue: kind === 'const' ? effective.const : undefined,
|
|
241
328
|
defaultValue: effective.default,
|
|
242
|
-
placeholder:
|
|
243
|
-
? String(effective.examples[0])
|
|
244
|
-
: formatInfo?.placeholder,
|
|
329
|
+
placeholder: t(`${base}#placeholder`, placeholder),
|
|
245
330
|
constraints: getConstraints(effective),
|
|
246
331
|
// The raw `x-form` annotation only - compiling its query documents is
|
|
247
332
|
// rules.js territory, so model building stays query-engine-free.
|
|
248
|
-
rules:
|
|
333
|
+
rules: isJsonObject(effective['x-form']) ? effective['x-form'] : null,
|
|
249
334
|
children: null,
|
|
250
335
|
item: null,
|
|
251
336
|
tuple: null,
|
|
@@ -259,7 +344,7 @@ function buildField(rawSchema, rootSchema, pointer, key, required, depth) {
|
|
|
259
344
|
buildField(
|
|
260
345
|
propSchema, rootSchema,
|
|
261
346
|
`${pointer}/${escapePointerKey(name)}`, name,
|
|
262
|
-
requiredSet.has(name), depth + 1));
|
|
347
|
+
requiredSet.has(name), depth + 1, t));
|
|
263
348
|
}
|
|
264
349
|
|
|
265
350
|
if (kind === 'array') {
|
|
@@ -268,41 +353,48 @@ function buildField(rawSchema, rootSchema, pointer, key, required, depth) {
|
|
|
268
353
|
: (Array.isArray(effective.items) ? effective.items : null);
|
|
269
354
|
if (prefix) {
|
|
270
355
|
field.tuple = prefix.map((itemSchema, i) =>
|
|
271
|
-
buildField(itemSchema, rootSchema, `${pointer}/${i}`, String(i), false, depth + 1));
|
|
356
|
+
buildField(itemSchema, rootSchema, `${pointer}/${i}`, String(i), false, depth + 1, t));
|
|
272
357
|
const rest = Array.isArray(effective.items) ? effective.additionalItems : effective.items;
|
|
273
358
|
if (rest != null && typeof rest === 'object') {
|
|
274
|
-
field.item = buildField(rest, rootSchema, `${pointer}/-`, '-', false, depth + 1);
|
|
359
|
+
field.item = buildField(rest, rootSchema, `${pointer}/-`, '-', false, depth + 1, t);
|
|
275
360
|
}
|
|
276
361
|
}
|
|
277
362
|
else if (effective.items != null && typeof effective.items === 'object') {
|
|
278
|
-
field.item = buildField(effective.items, rootSchema, `${pointer}/-`, '-', false, depth + 1);
|
|
363
|
+
field.item = buildField(effective.items, rootSchema, `${pointer}/-`, '-', false, depth + 1, t);
|
|
279
364
|
}
|
|
280
365
|
else {
|
|
281
|
-
field.item = buildField({}, rootSchema, `${pointer}/-`, '-', false, depth + 1);
|
|
366
|
+
field.item = buildField({}, rootSchema, `${pointer}/-`, '-', false, depth + 1, t);
|
|
282
367
|
}
|
|
283
368
|
}
|
|
284
369
|
|
|
285
370
|
return field;
|
|
286
371
|
}
|
|
287
372
|
|
|
288
|
-
function isRulesObject(value) {
|
|
289
|
-
return value != null && typeof value === 'object' && !Array.isArray(value);
|
|
290
|
-
}
|
|
291
|
-
|
|
292
373
|
/**
|
|
293
374
|
* Encode a property name as an RFC 6901 reference token (`~` -> `~0`,
|
|
294
|
-
* `/` -> `~1`), the write-side inverse of the shared parse.
|
|
375
|
+
* `/` -> `~1`), the write-side inverse of the shared parse. An alias of
|
|
376
|
+
* `encodeJSONPointerSegment` from `@jarenjs/json/pointer`, kept for
|
|
377
|
+
* compatibility.
|
|
295
378
|
* @param {string} key
|
|
296
379
|
* @returns {string}
|
|
297
380
|
*/
|
|
298
381
|
export function escapePointerKey(key) {
|
|
299
|
-
return
|
|
382
|
+
return encodeJSONPointerSegment(key);
|
|
300
383
|
}
|
|
301
384
|
|
|
302
385
|
/**
|
|
303
386
|
* Build the form model for a JSON schema.
|
|
304
387
|
*
|
|
388
|
+
* Static text (labels, descriptions, placeholders, enum option labels) is
|
|
389
|
+
* resolved ONCE here, at model-build time - the right place for
|
|
390
|
+
* translation. The optional `t` hook receives role-qualified message ids
|
|
391
|
+
* built from each field's base (`x-msgid` annotation or data pointer):
|
|
392
|
+
* `<base>#label`, `<base>#description`, `<base>#placeholder`,
|
|
393
|
+
* `<base>#enum/<String(value)>` - and the schema-derived fallback text.
|
|
394
|
+
*
|
|
305
395
|
* @param {object|boolean} schema - The root JSON schema
|
|
396
|
+
* @param {object} [options]
|
|
397
|
+
* @param {TranslateHook} [options.t] - Static-text translation hook, default the zero-cost identity `(id, fb) => fb`
|
|
306
398
|
* @returns {FormField} The root field descriptor (kind 'object' for object schemas)
|
|
307
399
|
* @example
|
|
308
400
|
* const model = buildFormModel({
|
|
@@ -311,8 +403,14 @@ export function escapePointerKey(key) {
|
|
|
311
403
|
* required: ['email'],
|
|
312
404
|
* });
|
|
313
405
|
* model.children[0].control; // 'email'
|
|
406
|
+
* @example
|
|
407
|
+
* // static-text i18n
|
|
408
|
+
* const nlModel = buildFormModel(schema, {
|
|
409
|
+
* t: (msgid, fallback) => staticTextNl[msgid] ?? fallback,
|
|
410
|
+
* });
|
|
314
411
|
*/
|
|
315
|
-
export function buildFormModel(schema) {
|
|
412
|
+
export function buildFormModel(schema, options = undefined) {
|
|
316
413
|
const rootSchema = (schema != null && typeof schema === 'object') ? schema : {};
|
|
317
|
-
|
|
414
|
+
const t = options != null && typeof options.t === 'function' ? options.t : identityT;
|
|
415
|
+
return buildField(schema, rootSchema, '', '', false, 0, t);
|
|
318
416
|
}
|