@tai42/studio-sdk 22.2.0 → 22.4.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/dist/guards.d.ts +6 -0
- package/dist/guards.d.ts.map +1 -1
- package/dist/guards.js +8 -0
- package/dist/guards.js.map +1 -1
- package/dist/hooks/useApi.d.ts +7 -0
- package/dist/hooks/useApi.d.ts.map +1 -1
- package/dist/schema-form/SchemaForm.d.ts +4 -1
- package/dist/schema-form/SchemaForm.d.ts.map +1 -1
- package/dist/schema-form/SchemaForm.js.map +1 -1
- package/dist/schema-form/classify.js +3 -3
- package/dist/schema-form/classify.js.map +1 -1
- package/dist/schema-form/default-value.js +5 -4
- package/dist/schema-form/default-value.js.map +1 -1
- package/dist/schema-form/field-model.d.ts +3 -3
- package/dist/schema-form/field-model.d.ts.map +1 -1
- package/dist/schema-form/field-node.d.ts +1 -0
- package/dist/schema-form/field-node.d.ts.map +1 -1
- package/dist/schema-form/field-node.js +2 -1
- package/dist/schema-form/field-node.js.map +1 -1
- package/dist/schema-form/multiselect-field.d.ts +1 -1
- package/dist/schema-form/object-fields.d.ts +1 -0
- package/dist/schema-form/object-fields.d.ts.map +1 -1
- package/dist/schema-form/object-fields.js +4 -3
- package/dist/schema-form/object-fields.js.map +1 -1
- package/dist/schema-form/record-field.d.ts.map +1 -1
- package/dist/schema-form/record-field.js +4 -2
- package/dist/schema-form/record-field.js.map +1 -1
- package/dist/schema-form/string-field.js +2 -2
- package/dist/schema-form/string-field.js.map +1 -1
- package/dist/schema-form/union-field.d.ts.map +1 -1
- package/dist/schema-form/union-field.js +3 -8
- package/dist/schema-form/union-field.js.map +1 -1
- package/dist/schema-form/union.js +1 -1
- package/dist/schema-form/union.js.map +1 -1
- package/dist/schema-form/validate.d.ts +14 -1
- package/dist/schema-form/validate.d.ts.map +1 -1
- package/dist/schema-form/validate.js +99 -45
- package/dist/schema-form/validate.js.map +1 -1
- package/dist/schema-form/visibility.d.ts +0 -12
- package/dist/schema-form/visibility.d.ts.map +1 -1
- package/dist/schema-form/visibility.js +14 -1
- package/dist/schema-form/visibility.js.map +1 -1
- package/package.json +2 -2
- package/src/guards.ts +12 -0
- package/src/schema-form/SchemaForm.tsx +4 -2
- package/src/schema-form/classify.ts +3 -3
- package/src/schema-form/default-value.ts +5 -4
- package/src/schema-form/field-model.ts +3 -3
- package/src/schema-form/field-node.tsx +4 -3
- package/src/schema-form/multiselect-field.tsx +1 -1
- package/src/schema-form/object-fields.tsx +8 -7
- package/src/schema-form/record-field.tsx +4 -2
- package/src/schema-form/string-field.tsx +2 -2
- package/src/schema-form/union-field.tsx +5 -6
- package/src/schema-form/union.ts +1 -1
- package/src/schema-form/validate.ts +127 -46
- package/src/schema-form/visibility.ts +4 -3
|
@@ -213,10 +213,10 @@ function classifyAllOf(
|
|
|
213
213
|
}
|
|
214
214
|
|
|
215
215
|
/**
|
|
216
|
-
* The platform date constraints on a `date`/`date-time` string property
|
|
216
|
+
* The platform date constraints on a `date`/`date-time` string property, or
|
|
217
217
|
* `undefined` for a plain or non-date string. Each key is read defensively (the schema is
|
|
218
218
|
* permissive); a date property with no constraint key classifies with `date: undefined`
|
|
219
|
-
*
|
|
219
|
+
* and draws the plain date or date-time control with no bounds.
|
|
220
220
|
*/
|
|
221
221
|
function dateConstraints(resolved: JsonSchema): DateConstraints | undefined {
|
|
222
222
|
if (resolved.format !== 'date' && resolved.format !== 'date-time') return undefined;
|
|
@@ -312,7 +312,7 @@ function classifyArray(
|
|
|
312
312
|
}
|
|
313
313
|
// An array whose items are a FIXED set of strings (an `enum` on the items, or a
|
|
314
314
|
// per-send option list that set one) is a multi-select: a checkbox group over a known
|
|
315
|
-
// option set
|
|
315
|
+
// option set. An array of free strings keeps the add/remove `array` field.
|
|
316
316
|
const items = resolveRef(resolved.items, root);
|
|
317
317
|
const itemTypes = typeList(items);
|
|
318
318
|
const isStringItems = itemTypes.length === 1 && itemTypes[0] === 'string';
|
|
@@ -66,18 +66,19 @@ function seedObject(
|
|
|
66
66
|
root: JsonSchema,
|
|
67
67
|
mintScalars: boolean,
|
|
68
68
|
): Record<string, unknown> {
|
|
69
|
-
const
|
|
69
|
+
const entries: [string, unknown][] = [];
|
|
70
70
|
for (const [name, propSchema] of model.properties) {
|
|
71
71
|
if (model.required.has(name)) {
|
|
72
72
|
const seeded = seedValue(propSchema, root, mintScalars);
|
|
73
|
-
if (seeded !== undefined)
|
|
73
|
+
if (seeded !== undefined) entries.push([name, seeded]);
|
|
74
74
|
continue;
|
|
75
75
|
}
|
|
76
76
|
const propClassified = classifySchema(propSchema, root);
|
|
77
77
|
const propDefault = explicitDefault(propSchema, propClassified.schema);
|
|
78
|
-
if (propDefault.present)
|
|
78
|
+
if (propDefault.present) entries.push([name, propDefault.value]);
|
|
79
79
|
}
|
|
80
|
-
|
|
80
|
+
// Built from entries so every property name, `__proto__` included, lands as an own key.
|
|
81
|
+
return Object.fromEntries(entries);
|
|
81
82
|
}
|
|
82
83
|
|
|
83
84
|
/**
|
|
@@ -29,8 +29,8 @@ export interface MediaUpload {
|
|
|
29
29
|
}
|
|
30
30
|
|
|
31
31
|
/**
|
|
32
|
-
* The platform date constraints classified off a `date`/`date-time` string property
|
|
33
|
-
*
|
|
32
|
+
* The platform date constraints classified off a `date`/`date-time` string property.
|
|
33
|
+
* `min`/`max` are inclusive `YYYY-MM-DD` bounds (passed to the native date
|
|
34
34
|
* control and enforced by the validator); `unavailable` is the set of excluded days
|
|
35
35
|
* (the native control cannot disable arbitrary days, so this is validator-enforced).
|
|
36
36
|
* A date RANGE is two ordinary date fields: on the END field, `rangeStart` names its
|
|
@@ -143,7 +143,7 @@ export type FieldModel =
|
|
|
143
143
|
* field into the jq expression editor (see {@link ExpressionAnnotation}). */
|
|
144
144
|
readonly expression: ExpressionAnnotation | undefined;
|
|
145
145
|
/** Present on a `date`/`date-time` string carrying any platform date
|
|
146
|
-
* constraint (bounds, unavailable days, or range pairing
|
|
146
|
+
* constraint (bounds, unavailable days, or range pairing). */
|
|
147
147
|
readonly date: DateConstraints | undefined;
|
|
148
148
|
}
|
|
149
149
|
| { readonly kind: 'number'; readonly integer: boolean }
|
|
@@ -9,6 +9,7 @@ import { memo, type ReactNode } from 'react';
|
|
|
9
9
|
import { Checkbox } from '../components/checkbox';
|
|
10
10
|
import { Field } from '../components/field';
|
|
11
11
|
import { NumberInput, TextInput } from '../components/inputs';
|
|
12
|
+
import { ownValue } from '../guards';
|
|
12
13
|
import { ArrayField } from './array-field';
|
|
13
14
|
import { classifySchema } from './classify';
|
|
14
15
|
import { EnumField } from './enum-field';
|
|
@@ -46,7 +47,7 @@ interface FieldNodeProps {
|
|
|
46
47
|
* its direct `enum`-select children draw their placeholder. Carried by the root form
|
|
47
48
|
* only; nested objects receive none.
|
|
48
49
|
*/
|
|
49
|
-
readonly fieldWording?: Readonly<Record<string, { choiceLabel?: string }>>;
|
|
50
|
+
readonly fieldWording?: Readonly<Record<string, { choiceLabel?: string; invalid?: string }>>;
|
|
50
51
|
/** This field's own picker placeholder, when it draws as an `enum` select. */
|
|
51
52
|
readonly choiceLabel?: string;
|
|
52
53
|
}
|
|
@@ -73,7 +74,7 @@ function FieldNodeImpl(props: FieldNodeProps): ReactNode {
|
|
|
73
74
|
nullable: classified.nullable,
|
|
74
75
|
heading,
|
|
75
76
|
description,
|
|
76
|
-
error: errors
|
|
77
|
+
error: ownValue(errors, path),
|
|
77
78
|
root,
|
|
78
79
|
value,
|
|
79
80
|
onChange,
|
|
@@ -100,7 +101,7 @@ interface RenderFieldArgs {
|
|
|
100
101
|
readonly required: boolean;
|
|
101
102
|
readonly errors: SchemaFormErrors | undefined;
|
|
102
103
|
readonly idPrefix: string;
|
|
103
|
-
readonly fieldWording?: Readonly<Record<string, { choiceLabel?: string }>>;
|
|
104
|
+
readonly fieldWording?: Readonly<Record<string, { choiceLabel?: string; invalid?: string }>>;
|
|
104
105
|
readonly choiceLabel?: string;
|
|
105
106
|
}
|
|
106
107
|
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The multi-select field renderer
|
|
2
|
+
* The multi-select field renderer: an array of a fixed set of strings drawn
|
|
3
3
|
* as a checkbox group, one checkbox per option, emitting a `string[]`. Classification
|
|
4
4
|
* lands an array here only when its items carry a known option set (an `enum`, or a
|
|
5
5
|
* per-send option list), so there is always a definite set of boxes to draw; an array of
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
*/
|
|
7
7
|
import type { ReactNode } from 'react';
|
|
8
8
|
|
|
9
|
+
import { ownValue } from '../guards';
|
|
9
10
|
import { DateRangeField, dateRangePairs } from './date-range-field';
|
|
10
11
|
import { FieldNode } from './field-node';
|
|
11
12
|
import { useKeyedCallbacks } from './keyed-callbacks';
|
|
@@ -45,7 +46,7 @@ export function ObjectFields({
|
|
|
45
46
|
* object only; a property whose `enum` draws as a select shows its entry's
|
|
46
47
|
* `choiceLabel` as the select placeholder. Absent for nested objects.
|
|
47
48
|
*/
|
|
48
|
-
fieldWording?: Readonly<Record<string, { choiceLabel?: string }>>;
|
|
49
|
+
fieldWording?: Readonly<Record<string, { choiceLabel?: string; invalid?: string }>>;
|
|
49
50
|
}): ReactNode {
|
|
50
51
|
const obj = isPlainObject(value) ? value : {};
|
|
51
52
|
const changeFor = useKeyedCallbacks<string, unknown>(
|
|
@@ -66,7 +67,7 @@ export function ObjectFields({
|
|
|
66
67
|
{properties.map(([name, propSchema]) => {
|
|
67
68
|
if (skip !== undefined && name === skip) return null;
|
|
68
69
|
if (skippedEnds.has(name)) return null;
|
|
69
|
-
// A field hidden by its `visibleWhen` predicate
|
|
70
|
+
// A field hidden by its `visibleWhen` predicate is not rendered; the
|
|
70
71
|
// validator skips it in step, so a hidden field is neither shown nor required.
|
|
71
72
|
if (!isFieldVisible(propSchema, obj)) return null;
|
|
72
73
|
const pair = pairByStart.get(name);
|
|
@@ -75,13 +76,13 @@ export function ObjectFields({
|
|
|
75
76
|
<DateRangeField
|
|
76
77
|
key={name}
|
|
77
78
|
pair={pair}
|
|
78
|
-
startValue={obj
|
|
79
|
-
endValue={obj
|
|
79
|
+
startValue={ownValue(obj, pair.startName)}
|
|
80
|
+
endValue={ownValue(obj, pair.endName)}
|
|
80
81
|
startRequired={requiredKeys.has(pair.startName)}
|
|
81
82
|
endRequired={requiredKeys.has(pair.endName)}
|
|
82
83
|
markRequired={markRequired}
|
|
83
|
-
startError={errors
|
|
84
|
-
endError={errors
|
|
84
|
+
startError={ownValue(errors, childPathOf(pair.startName))}
|
|
85
|
+
endError={ownValue(errors, childPathOf(pair.endName))}
|
|
85
86
|
onChangeStart={changeFor(pair.startName)}
|
|
86
87
|
onChangeEnd={changeFor(pair.endName)}
|
|
87
88
|
/>
|
|
@@ -92,7 +93,7 @@ export function ObjectFields({
|
|
|
92
93
|
key={name}
|
|
93
94
|
schema={propSchema}
|
|
94
95
|
root={root}
|
|
95
|
-
value={obj
|
|
96
|
+
value={ownValue(obj, name)}
|
|
96
97
|
onChange={changeFor(name)}
|
|
97
98
|
path={childPathOf(name)}
|
|
98
99
|
label={name}
|
|
@@ -43,7 +43,7 @@ interface Analysis {
|
|
|
43
43
|
}
|
|
44
44
|
|
|
45
45
|
function analyzeRows(rows: readonly Row[]): Analysis {
|
|
46
|
-
const
|
|
46
|
+
const entries: [string, unknown][] = [];
|
|
47
47
|
const seen = new Set<string>();
|
|
48
48
|
const blankRowIds = new Set<number>();
|
|
49
49
|
const duplicateRowIds = new Set<number>();
|
|
@@ -57,8 +57,10 @@ function analyzeRows(rows: readonly Row[]): Analysis {
|
|
|
57
57
|
continue;
|
|
58
58
|
}
|
|
59
59
|
seen.add(row.key);
|
|
60
|
-
|
|
60
|
+
entries.push([row.key, row.value]);
|
|
61
61
|
}
|
|
62
|
+
// Built from entries so every typed key, `__proto__` included, lands as an own key.
|
|
63
|
+
const value = Object.fromEntries(entries);
|
|
62
64
|
return { value, blankRowIds, duplicateRowIds };
|
|
63
65
|
}
|
|
64
66
|
|
|
@@ -142,7 +142,7 @@ export function StringField({
|
|
|
142
142
|
// numeric/decimal keyboard); `undefined` for every other control, which
|
|
143
143
|
// selects its kind through `type` instead.
|
|
144
144
|
inputMode={control.inputMode}
|
|
145
|
-
// A native date control enforces its inclusive bounds
|
|
145
|
+
// A native date control enforces its inclusive bounds; on a
|
|
146
146
|
// non-date input the browser ignores these, and the validator enforces the
|
|
147
147
|
// rest (unavailable days, range span) that the control cannot draw.
|
|
148
148
|
min={date?.min}
|
|
@@ -421,7 +421,7 @@ const INPUT_MODE_CONTROLS: Record<
|
|
|
421
421
|
};
|
|
422
422
|
|
|
423
423
|
// The native control for a string field: the `inputMode` keyword OUTRANKS
|
|
424
|
-
// `format` when present; otherwise the format mapping
|
|
424
|
+
// `format` when present; otherwise the format mapping picks the control. The value
|
|
425
425
|
// is always the typed string — `numeric`/`decimal` set only the keyboard, never a
|
|
426
426
|
// number coercion.
|
|
427
427
|
function stringControl(
|
|
@@ -65,12 +65,11 @@ function switchVariantValue(
|
|
|
65
65
|
const { model } = classifySchema(target.schema, root);
|
|
66
66
|
if (model.kind !== 'object') return seeded;
|
|
67
67
|
const targetNames = new Set(model.properties.map(([name]) => name));
|
|
68
|
-
const
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
return merged;
|
|
68
|
+
const carried = Object.entries(current).filter(
|
|
69
|
+
([name]) => name !== discriminator && targetNames.has(name),
|
|
70
|
+
);
|
|
71
|
+
// Built from entries so every property name, `__proto__` included, lands as an own key.
|
|
72
|
+
return Object.fromEntries([...Object.entries(seeded), ...carried]);
|
|
74
73
|
}
|
|
75
74
|
|
|
76
75
|
/** Title-case a property name: `kind` → "Kind", `content_type` → "Content Type". */
|
package/src/schema-form/union.ts
CHANGED
|
@@ -67,7 +67,7 @@ export function activeVariantIndex(
|
|
|
67
67
|
const required = resolved.required ?? [];
|
|
68
68
|
let score = 0;
|
|
69
69
|
for (const key of required) {
|
|
70
|
-
if (key
|
|
70
|
+
if (Object.hasOwn(value, key)) score += 1;
|
|
71
71
|
}
|
|
72
72
|
if (score > bestScore) {
|
|
73
73
|
bestScore = score;
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* container constraint on a free-form `json` field. It never throws on a
|
|
6
6
|
* malformed value — a malformed value IS the error it reports.
|
|
7
7
|
*/
|
|
8
|
-
import { isRecord } from '../guards';
|
|
8
|
+
import { isRecord, ownValue } from '../guards';
|
|
9
9
|
import { classifySchema } from './classify';
|
|
10
10
|
import type { DateConstraints, FieldModel } from './field-model';
|
|
11
11
|
import { decodedByteSize, effectiveMaxBytes, overCapMessage } from './media';
|
|
@@ -22,16 +22,62 @@ export interface ValidateOptions {
|
|
|
22
22
|
* absent both, the shared default applies.
|
|
23
23
|
*/
|
|
24
24
|
readonly maxUploadBytes?: number;
|
|
25
|
+
/**
|
|
26
|
+
* Per-field words, keyed by the root object's exact property name. A field's
|
|
27
|
+
* `invalid` sentence replaces every shape error on that field and anything inside
|
|
28
|
+
* it (a format, pattern, length, bound, type or item error). A missing required
|
|
29
|
+
* value keeps its own required message, and a `pattern` that cannot compile keeps
|
|
30
|
+
* its own message, since that is a defect of the schema, not of the value. A field
|
|
31
|
+
* with no `invalid` sentence keeps the SDK's word for the failing shape.
|
|
32
|
+
*/
|
|
33
|
+
readonly fieldWording?: Readonly<Record<string, { invalid?: string }>>;
|
|
25
34
|
}
|
|
26
35
|
|
|
27
36
|
/** The invariants every per-node validator shares while walking one value tree. */
|
|
28
37
|
interface ValidateCtx {
|
|
29
38
|
/** The document root, for `$ref` resolution during re-classification. */
|
|
30
39
|
readonly root: JsonSchema;
|
|
31
|
-
/**
|
|
40
|
+
/**
|
|
41
|
+
* The path-keyed error bag every validator writes into. It has no prototype, so a
|
|
42
|
+
* path that names a built-in object member (`toString`, `__proto__`) is an ordinary key.
|
|
43
|
+
*/
|
|
32
44
|
readonly errors: Record<string, string>;
|
|
33
45
|
/** The host's default media byte cap (a field's own cap still overrides it). */
|
|
34
46
|
readonly maxUploadBytes: number | undefined;
|
|
47
|
+
/**
|
|
48
|
+
* The root object's property the walk is inside, or `undefined` at the root itself
|
|
49
|
+
* and inside a root that is not an object or record.
|
|
50
|
+
*/
|
|
51
|
+
readonly field: string | undefined;
|
|
52
|
+
/**
|
|
53
|
+
* The paths whose error is a shape error a field's `invalid` sentence may replace,
|
|
54
|
+
* each mapped to the root property it lies inside.
|
|
55
|
+
*/
|
|
56
|
+
readonly shapeErrorFields: Map<string, string>;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Record a shape error at `path`: the value breaks a shape the schema declares. Inside
|
|
61
|
+
* a root property, that field's `invalid` sentence may replace it.
|
|
62
|
+
*/
|
|
63
|
+
function shapeError(ctx: ValidateCtx, path: string, message: string): void {
|
|
64
|
+
ctx.errors[path] = message;
|
|
65
|
+
if (ctx.field === undefined) ctx.shapeErrorFields.delete(path);
|
|
66
|
+
else ctx.shapeErrorFields.set(path, ctx.field);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Record an error at `path` that is not a shape the value breaks (a missing required
|
|
71
|
+
* value, a schema constraint that cannot run): a field's words never replace it.
|
|
72
|
+
*/
|
|
73
|
+
function keptError(ctx: ValidateCtx, path: string, message: string): void {
|
|
74
|
+
ctx.errors[path] = message;
|
|
75
|
+
ctx.shapeErrorFields.delete(path);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** The context for the child `name` of the value at `path`: a root child is its own field. */
|
|
79
|
+
function childCtx(ctx: ValidateCtx, path: string, name: string): ValidateCtx {
|
|
80
|
+
return path === '' ? { ...ctx, field: name } : ctx;
|
|
35
81
|
}
|
|
36
82
|
|
|
37
83
|
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
@@ -78,9 +124,9 @@ function validateJson(
|
|
|
78
124
|
ctx: ValidateCtx,
|
|
79
125
|
): void {
|
|
80
126
|
if (model.jsonType === 'object' && !isRecord(value)) {
|
|
81
|
-
ctx
|
|
127
|
+
shapeError(ctx, path, 'must be a JSON object');
|
|
82
128
|
} else if (model.jsonType === 'array' && !Array.isArray(value)) {
|
|
83
|
-
ctx
|
|
129
|
+
shapeError(ctx, path, 'must be a JSON array');
|
|
84
130
|
}
|
|
85
131
|
}
|
|
86
132
|
|
|
@@ -90,7 +136,7 @@ function validateConst(
|
|
|
90
136
|
path: string,
|
|
91
137
|
ctx: ValidateCtx,
|
|
92
138
|
): void {
|
|
93
|
-
if (value !== model.value) ctx
|
|
139
|
+
if (value !== model.value) shapeError(ctx, path, `must equal ${scalarLabel(model.value)}`);
|
|
94
140
|
}
|
|
95
141
|
|
|
96
142
|
function validateEnum(
|
|
@@ -100,7 +146,7 @@ function validateEnum(
|
|
|
100
146
|
ctx: ValidateCtx,
|
|
101
147
|
): void {
|
|
102
148
|
const allowed = model.options.some((option) => option.value === value);
|
|
103
|
-
if (!allowed) ctx
|
|
149
|
+
if (!allowed) shapeError(ctx, path, 'must be one of the allowed values');
|
|
104
150
|
}
|
|
105
151
|
|
|
106
152
|
function validateString(
|
|
@@ -110,7 +156,7 @@ function validateString(
|
|
|
110
156
|
ctx: ValidateCtx,
|
|
111
157
|
): void {
|
|
112
158
|
if (typeof value !== 'string') {
|
|
113
|
-
ctx
|
|
159
|
+
shapeError(ctx, path, 'must be a string');
|
|
114
160
|
return;
|
|
115
161
|
}
|
|
116
162
|
// A media-annotated string carries a byte cap; enforce it on the value's
|
|
@@ -121,22 +167,22 @@ function validateString(
|
|
|
121
167
|
const cap = effectiveMaxBytes(model.media.maxBytes, ctx.maxUploadBytes);
|
|
122
168
|
const size = decodedByteSize(value);
|
|
123
169
|
if (size > cap) {
|
|
124
|
-
ctx
|
|
170
|
+
shapeError(ctx, path, overCapMessage('The value', size, cap));
|
|
125
171
|
return;
|
|
126
172
|
}
|
|
127
173
|
}
|
|
128
174
|
if (value.length > 0 && model.format !== undefined) {
|
|
129
175
|
const message = formatError(model.format, value);
|
|
130
176
|
if (message !== undefined) {
|
|
131
|
-
ctx
|
|
177
|
+
shapeError(ctx, path, message);
|
|
132
178
|
return;
|
|
133
179
|
}
|
|
134
180
|
}
|
|
135
|
-
// Date bounds and unavailable days
|
|
181
|
+
// Date bounds and unavailable days. Range span/ordering needs the sibling
|
|
136
182
|
// start field, so it is enforced at the object level (see validateDateRanges).
|
|
137
183
|
if (model.date !== undefined && value.length > 0) {
|
|
138
184
|
const message = dateConstraintError(model.date, value);
|
|
139
|
-
if (message !== undefined) ctx
|
|
185
|
+
if (message !== undefined) shapeError(ctx, path, message);
|
|
140
186
|
}
|
|
141
187
|
}
|
|
142
188
|
|
|
@@ -159,14 +205,14 @@ function validateNumber(
|
|
|
159
205
|
ctx: ValidateCtx,
|
|
160
206
|
): void {
|
|
161
207
|
if (typeof value !== 'number' || Number.isNaN(value)) {
|
|
162
|
-
ctx
|
|
208
|
+
shapeError(ctx, path, model.integer ? 'must be an integer' : 'must be a number');
|
|
163
209
|
return;
|
|
164
210
|
}
|
|
165
|
-
if (model.integer && !Number.isInteger(value)) ctx
|
|
211
|
+
if (model.integer && !Number.isInteger(value)) shapeError(ctx, path, 'must be an integer');
|
|
166
212
|
}
|
|
167
213
|
|
|
168
214
|
function validateBoolean(value: unknown, path: string, ctx: ValidateCtx): void {
|
|
169
|
-
if (typeof value !== 'boolean') ctx
|
|
215
|
+
if (typeof value !== 'boolean') shapeError(ctx, path, 'must be true or false');
|
|
170
216
|
}
|
|
171
217
|
|
|
172
218
|
function validateArray(
|
|
@@ -176,7 +222,7 @@ function validateArray(
|
|
|
176
222
|
ctx: ValidateCtx,
|
|
177
223
|
): void {
|
|
178
224
|
if (!Array.isArray(value)) {
|
|
179
|
-
ctx
|
|
225
|
+
shapeError(ctx, path, 'must be an array');
|
|
180
226
|
return;
|
|
181
227
|
}
|
|
182
228
|
value.forEach((item, index) => {
|
|
@@ -191,40 +237,45 @@ function validateObject(
|
|
|
191
237
|
ctx: ValidateCtx,
|
|
192
238
|
): void {
|
|
193
239
|
if (!isRecord(value)) {
|
|
194
|
-
ctx
|
|
240
|
+
shapeError(ctx, path, 'must be an object');
|
|
195
241
|
return;
|
|
196
242
|
}
|
|
197
243
|
const propMap = new Map(model.properties);
|
|
198
244
|
for (const [name, propSchema] of model.properties) {
|
|
199
|
-
// A field hidden by its `visibleWhen` predicate
|
|
245
|
+
// A field hidden by its `visibleWhen` predicate is not part of the answer:
|
|
200
246
|
// it is not required and not validated, and a value left on it is dropped by the
|
|
201
247
|
// server, not an error here. Evaluated on the current values, so a field that was
|
|
202
248
|
// hidden by the predicate is skipped even if it still carries a stale value.
|
|
203
249
|
if (!isFieldVisible(propSchema, value)) continue;
|
|
204
250
|
const childPath = joinPath(path, name);
|
|
205
|
-
const childValue = value
|
|
251
|
+
const childValue = ownValue(value, name);
|
|
206
252
|
if (childValue === undefined) {
|
|
207
|
-
if (model.required.has(name))
|
|
253
|
+
if (model.required.has(name)) markRequired(childPath, name, ctx);
|
|
208
254
|
continue;
|
|
209
255
|
}
|
|
210
|
-
walk(propSchema, childValue, childPath, ctx);
|
|
256
|
+
walk(propSchema, childValue, childPath, childCtx(ctx, path, name));
|
|
211
257
|
}
|
|
212
258
|
// Required keys naming a property that somehow isn't declared still count.
|
|
213
259
|
for (const name of model.required) {
|
|
214
|
-
if (!propMap.has(name) && value
|
|
215
|
-
|
|
260
|
+
if (!propMap.has(name) && ownValue(value, name) === undefined) {
|
|
261
|
+
markRequired(joinPath(path, name), name, ctx);
|
|
216
262
|
}
|
|
217
263
|
}
|
|
218
264
|
validateDateRanges(model, value, path, ctx);
|
|
219
265
|
}
|
|
220
266
|
|
|
267
|
+
/** Record a missing required value at `path`, the one error a field's words never replace. */
|
|
268
|
+
function markRequired(path: string, name: string, ctx: ValidateCtx): void {
|
|
269
|
+
keptError(ctx, path, `"${name}" is required`);
|
|
270
|
+
}
|
|
271
|
+
|
|
221
272
|
/** Days from `start` to `end` inclusive (both `YYYY-MM-DD`); 1 when they are equal. */
|
|
222
273
|
function inclusiveDayCount(start: string, end: string): number {
|
|
223
274
|
return Math.round((Date.parse(end) - Date.parse(start)) / 86_400_000) + 1;
|
|
224
275
|
}
|
|
225
276
|
|
|
226
277
|
/**
|
|
227
|
-
* Enforce a date RANGE declared on an END field
|
|
278
|
+
* Enforce a date RANGE declared on an END field: the end is on or after its
|
|
228
279
|
* named `rangeStart` field, and the inclusive day span is within `minDays`/`maxDays`.
|
|
229
280
|
* Read from the two plain date values — nothing recombined. A missing or not-yet-valid
|
|
230
281
|
* endpoint is left to that field's own validation; a hidden end field is skipped.
|
|
@@ -239,14 +290,14 @@ function validateDateRanges(
|
|
|
239
290
|
if (!isFieldVisible(propSchema, value)) continue;
|
|
240
291
|
const resolved = resolveRef(propSchema, ctx.root);
|
|
241
292
|
if (typeof resolved.rangeStart !== 'string') continue;
|
|
242
|
-
const endValue = value
|
|
243
|
-
const startValue = value
|
|
293
|
+
const endValue = ownValue(value, name);
|
|
294
|
+
const startValue = ownValue(value, resolved.rangeStart);
|
|
244
295
|
if (typeof endValue !== 'string' || typeof startValue !== 'string') continue;
|
|
245
296
|
if (!DATE_RE.test(endValue) || !DATE_RE.test(startValue)) continue;
|
|
246
297
|
const childPath = joinPath(path, name);
|
|
247
298
|
if (ctx.errors[childPath] !== undefined) continue;
|
|
248
299
|
const message = dateRangeMessage(resolved, startValue, endValue);
|
|
249
|
-
if (message !== undefined) ctx
|
|
300
|
+
if (message !== undefined) shapeError(childCtx(ctx, path, name), childPath, message);
|
|
250
301
|
}
|
|
251
302
|
}
|
|
252
303
|
|
|
@@ -273,20 +324,20 @@ function validateMultiselect(
|
|
|
273
324
|
ctx: ValidateCtx,
|
|
274
325
|
): void {
|
|
275
326
|
if (!Array.isArray(value)) {
|
|
276
|
-
ctx
|
|
327
|
+
shapeError(ctx, path, 'must be an array');
|
|
277
328
|
return;
|
|
278
329
|
}
|
|
279
330
|
const allowed = new Set(model.options.map((option) => option.value));
|
|
280
331
|
for (const item of value) {
|
|
281
332
|
if (!allowed.has(item)) {
|
|
282
|
-
ctx
|
|
333
|
+
shapeError(ctx, path, 'must be one of the allowed values');
|
|
283
334
|
return;
|
|
284
335
|
}
|
|
285
336
|
}
|
|
286
337
|
}
|
|
287
338
|
|
|
288
339
|
/**
|
|
289
|
-
* The JSON-Schema value/length/items bounds the client mirrors from the server
|
|
340
|
+
* The JSON-Schema value/length/items bounds the client mirrors from the server:
|
|
290
341
|
* `minLength`/`maxLength`/`pattern` on a string, `minimum`/`maximum` on a number,
|
|
291
342
|
* `minItems`/`maxItems` on an array. Applied only when the kind check found no type
|
|
292
343
|
* error. A `pattern` that cannot compile surfaces as a loud field error, never a silent
|
|
@@ -306,11 +357,11 @@ function applyStringBounds(
|
|
|
306
357
|
): void {
|
|
307
358
|
const length = Array.from(value).length;
|
|
308
359
|
if (typeof schema.minLength === 'number' && length < schema.minLength) {
|
|
309
|
-
ctx
|
|
360
|
+
shapeError(ctx, path, `must be at least ${String(schema.minLength)} character(s)`);
|
|
310
361
|
return;
|
|
311
362
|
}
|
|
312
363
|
if (typeof schema.maxLength === 'number' && length > schema.maxLength) {
|
|
313
|
-
ctx
|
|
364
|
+
shapeError(ctx, path, `must be at most ${String(schema.maxLength)} character(s)`);
|
|
314
365
|
return;
|
|
315
366
|
}
|
|
316
367
|
if (typeof schema.pattern !== 'string' || schema.pattern === '') return;
|
|
@@ -318,10 +369,10 @@ function applyStringBounds(
|
|
|
318
369
|
try {
|
|
319
370
|
pattern = new RegExp(schema.pattern);
|
|
320
371
|
} catch {
|
|
321
|
-
ctx
|
|
372
|
+
keptError(ctx, path, 'has an invalid pattern constraint');
|
|
322
373
|
return;
|
|
323
374
|
}
|
|
324
|
-
if (!pattern.test(value)) ctx
|
|
375
|
+
if (!pattern.test(value)) shapeError(ctx, path, 'must match the required pattern');
|
|
325
376
|
}
|
|
326
377
|
|
|
327
378
|
function applyNumberBounds(
|
|
@@ -331,11 +382,11 @@ function applyNumberBounds(
|
|
|
331
382
|
ctx: ValidateCtx,
|
|
332
383
|
): void {
|
|
333
384
|
if (typeof schema.minimum === 'number' && value < schema.minimum) {
|
|
334
|
-
ctx
|
|
385
|
+
shapeError(ctx, path, `must be at least ${String(schema.minimum)}`);
|
|
335
386
|
return;
|
|
336
387
|
}
|
|
337
388
|
if (typeof schema.maximum === 'number' && value > schema.maximum) {
|
|
338
|
-
ctx
|
|
389
|
+
shapeError(ctx, path, `must be at most ${String(schema.maximum)}`);
|
|
339
390
|
}
|
|
340
391
|
}
|
|
341
392
|
|
|
@@ -346,11 +397,11 @@ function applyArrayBounds(
|
|
|
346
397
|
ctx: ValidateCtx,
|
|
347
398
|
): void {
|
|
348
399
|
if (typeof schema.minItems === 'number' && value.length < schema.minItems) {
|
|
349
|
-
ctx
|
|
400
|
+
shapeError(ctx, path, `must have at least ${String(schema.minItems)} item(s)`);
|
|
350
401
|
return;
|
|
351
402
|
}
|
|
352
403
|
if (typeof schema.maxItems === 'number' && value.length > schema.maxItems) {
|
|
353
|
-
ctx
|
|
404
|
+
shapeError(ctx, path, `must have at most ${String(schema.maxItems)} item(s)`);
|
|
354
405
|
}
|
|
355
406
|
}
|
|
356
407
|
|
|
@@ -361,11 +412,11 @@ function validateRecord(
|
|
|
361
412
|
ctx: ValidateCtx,
|
|
362
413
|
): void {
|
|
363
414
|
if (!isRecord(value)) {
|
|
364
|
-
ctx
|
|
415
|
+
shapeError(ctx, path, 'must be an object');
|
|
365
416
|
return;
|
|
366
417
|
}
|
|
367
418
|
for (const [key, entryValue] of Object.entries(value)) {
|
|
368
|
-
walk(model.values, entryValue, joinPath(path, key), ctx);
|
|
419
|
+
walk(model.values, entryValue, joinPath(path, key), childCtx(ctx, path, key));
|
|
369
420
|
}
|
|
370
421
|
}
|
|
371
422
|
|
|
@@ -378,7 +429,7 @@ function validateUnion(
|
|
|
378
429
|
const index = activeVariantIndex(value, model.variants, model.discriminator, ctx.root);
|
|
379
430
|
const variant = index === -1 ? undefined : model.variants[index];
|
|
380
431
|
if (variant === undefined) {
|
|
381
|
-
ctx
|
|
432
|
+
shapeError(ctx, path, 'does not match any allowed variant');
|
|
382
433
|
return;
|
|
383
434
|
}
|
|
384
435
|
walk(variant.schema, value, path, ctx);
|
|
@@ -388,12 +439,12 @@ function walk(schema: JsonSchema, value: unknown, path: string, ctx: ValidateCtx
|
|
|
388
439
|
const classified = classifySchema(schema, ctx.root);
|
|
389
440
|
|
|
390
441
|
if (value === null) {
|
|
391
|
-
if (!classified.nullable) ctx
|
|
442
|
+
if (!classified.nullable) shapeError(ctx, path, 'must not be null');
|
|
392
443
|
return;
|
|
393
444
|
}
|
|
394
445
|
|
|
395
446
|
dispatchKind(classified.model, value, path, ctx);
|
|
396
|
-
// The value/length/items bounds
|
|
447
|
+
// The value/length/items bounds mirror the server and apply only when the kind
|
|
397
448
|
// check found no type error for this field — a bound on a wrong-typed value is noise.
|
|
398
449
|
if (ctx.errors[path] === undefined) applyBounds(classified.schema, value, path, ctx);
|
|
399
450
|
}
|
|
@@ -436,18 +487,48 @@ function dispatchKind(model: FieldModel, value: unknown, path: string, ctx: Vali
|
|
|
436
487
|
}
|
|
437
488
|
}
|
|
438
489
|
|
|
490
|
+
/**
|
|
491
|
+
* Replace each shape error inside a root property whose words carry an `invalid`
|
|
492
|
+
* sentence with that sentence; every other error keeps its own message.
|
|
493
|
+
*/
|
|
494
|
+
function applyFieldWording(
|
|
495
|
+
fieldWording: Readonly<Record<string, { invalid?: string }>>,
|
|
496
|
+
errors: Record<string, string>,
|
|
497
|
+
shapeErrorFields: ReadonlyMap<string, string>,
|
|
498
|
+
): void {
|
|
499
|
+
for (const [path, field] of shapeErrorFields) {
|
|
500
|
+
if (!Object.hasOwn(fieldWording, field)) continue;
|
|
501
|
+
const invalid = fieldWording[field]?.invalid;
|
|
502
|
+
if (invalid !== undefined) errors[path] = invalid;
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
|
|
439
506
|
/**
|
|
440
507
|
* Validate `value` against `schema`, returning a per-path error bag (empty =
|
|
441
508
|
* valid). The caller runs this before submit and feeds the result back to
|
|
442
509
|
* `SchemaForm` for display. `options.maxUploadBytes` mirrors the renderer's
|
|
443
|
-
* `maxUploadBytes` prop so a media field's byte cap is enforced identically here
|
|
510
|
+
* `maxUploadBytes` prop so a media field's byte cap is enforced identically here;
|
|
511
|
+
* `options.fieldWording` puts a field's own `invalid` sentence in place of the
|
|
512
|
+
* SDK's word for any shape error on that field.
|
|
444
513
|
*/
|
|
445
514
|
export function validateAgainstSchema(
|
|
446
515
|
schema: JsonSchema,
|
|
447
516
|
value: unknown,
|
|
448
517
|
options?: ValidateOptions,
|
|
449
518
|
): SchemaFormErrors {
|
|
450
|
-
const errors
|
|
451
|
-
|
|
452
|
-
|
|
519
|
+
const errors = Object.create(null) as Record<string, string>;
|
|
520
|
+
const shapeErrorFields = new Map<string, string>();
|
|
521
|
+
walk(schema, value, '', {
|
|
522
|
+
root: schema,
|
|
523
|
+
errors,
|
|
524
|
+
maxUploadBytes: options?.maxUploadBytes,
|
|
525
|
+
field: undefined,
|
|
526
|
+
shapeErrorFields,
|
|
527
|
+
});
|
|
528
|
+
if (options?.fieldWording !== undefined) {
|
|
529
|
+
applyFieldWording(options.fieldWording, errors, shapeErrorFields);
|
|
530
|
+
}
|
|
531
|
+
// Hand back an ordinary object; its entries are defined as own keys, so a path such as
|
|
532
|
+
// `__proto__` stays an entry of the result.
|
|
533
|
+
return Object.fromEntries(Object.entries(errors));
|
|
453
534
|
}
|