@tai42/studio-sdk 19.2.0 → 19.3.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/hooks/useApi.d.ts +9 -0
- package/dist/hooks/useApi.d.ts.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/schema-form/classify.d.ts.map +1 -1
- package/dist/schema-form/classify.js +49 -4
- package/dist/schema-form/classify.js.map +1 -1
- package/dist/schema-form/default-value.js +1 -0
- package/dist/schema-form/default-value.js.map +1 -1
- package/dist/schema-form/enum-field.d.ts.map +1 -1
- package/dist/schema-form/enum-field.js +3 -1
- package/dist/schema-form/enum-field.js.map +1 -1
- package/dist/schema-form/field-model.d.ts +23 -0
- package/dist/schema-form/field-model.d.ts.map +1 -1
- package/dist/schema-form/field-node.d.ts.map +1 -1
- package/dist/schema-form/field-node.js +31 -3
- package/dist/schema-form/field-node.js.map +1 -1
- package/dist/schema-form/index.d.ts +1 -0
- package/dist/schema-form/index.d.ts.map +1 -1
- package/dist/schema-form/index.js +1 -0
- package/dist/schema-form/index.js.map +1 -1
- package/dist/schema-form/multiselect-field.d.ts +20 -0
- package/dist/schema-form/multiselect-field.d.ts.map +1 -0
- package/dist/schema-form/multiselect-field.js +24 -0
- package/dist/schema-form/multiselect-field.js.map +1 -0
- package/dist/schema-form/object-fields.d.ts.map +1 -1
- package/dist/schema-form/object-fields.js +5 -0
- package/dist/schema-form/object-fields.js.map +1 -1
- package/dist/schema-form/string-field.d.ts +3 -2
- package/dist/schema-form/string-field.d.ts.map +1 -1
- package/dist/schema-form/string-field.js +6 -2
- package/dist/schema-form/string-field.js.map +1 -1
- package/dist/schema-form/types.d.ts +38 -0
- package/dist/schema-form/types.d.ts.map +1 -1
- package/dist/schema-form/validate.d.ts.map +1 -1
- package/dist/schema-form/validate.js +156 -2
- package/dist/schema-form/validate.js.map +1 -1
- package/dist/schema-form/visibility.d.ts +21 -0
- package/dist/schema-form/visibility.d.ts.map +1 -0
- package/dist/schema-form/visibility.js +37 -0
- package/dist/schema-form/visibility.js.map +1 -0
- package/package.json +5 -5
- package/src/index.ts +1 -0
- package/src/schema-form/classify.ts +52 -3
- package/src/schema-form/default-value.ts +1 -0
- package/src/schema-form/enum-field.tsx +3 -1
- package/src/schema-form/field-model.ts +29 -0
- package/src/schema-form/field-node.tsx +61 -2
- package/src/schema-form/index.ts +1 -0
- package/src/schema-form/multiselect-field.tsx +58 -0
- package/src/schema-form/object-fields.tsx +4 -0
- package/src/schema-form/string-field.tsx +8 -1
- package/src/schema-form/types.ts +39 -0
- package/src/schema-form/validate.ts +176 -3
- package/src/schema-form/visibility.ts +47 -0
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
* kind (a free-form JSON textarea) — a field is never silently dropped, and a
|
|
8
8
|
* shapeless one is edited as the JSON it is rather than dead-ending on a badge.
|
|
9
9
|
*/
|
|
10
|
-
import type { ClassifiedField, UnionVariant } from './field-model';
|
|
10
|
+
import type { ClassifiedField, DateConstraints, UnionVariant } from './field-model';
|
|
11
11
|
import {
|
|
12
12
|
type EnumOption,
|
|
13
13
|
isNullSchema,
|
|
@@ -185,6 +185,35 @@ function classifyAllOf(
|
|
|
185
185
|
);
|
|
186
186
|
}
|
|
187
187
|
|
|
188
|
+
/**
|
|
189
|
+
* The platform date constraints on a `date`/`date-time` string property (scope C), or
|
|
190
|
+
* `undefined` for a plain or non-date string. Each key is read defensively (the schema is
|
|
191
|
+
* permissive); a date property with no constraint key classifies with `date: undefined`
|
|
192
|
+
* so it renders exactly as before.
|
|
193
|
+
*/
|
|
194
|
+
function dateConstraints(resolved: JsonSchema): DateConstraints | undefined {
|
|
195
|
+
if (resolved.format !== 'date' && resolved.format !== 'date-time') return undefined;
|
|
196
|
+
const min = typeof resolved.minDate === 'string' ? resolved.minDate : undefined;
|
|
197
|
+
const max = typeof resolved.maxDate === 'string' ? resolved.maxDate : undefined;
|
|
198
|
+
const unavailable = Array.isArray(resolved.unavailableDates)
|
|
199
|
+
? resolved.unavailableDates.filter((day): day is string => typeof day === 'string')
|
|
200
|
+
: [];
|
|
201
|
+
const rangeStart = typeof resolved.rangeStart === 'string' ? resolved.rangeStart : undefined;
|
|
202
|
+
const minDays = typeof resolved.minDays === 'number' ? resolved.minDays : undefined;
|
|
203
|
+
const maxDays = typeof resolved.maxDays === 'number' ? resolved.maxDays : undefined;
|
|
204
|
+
if (
|
|
205
|
+
min === undefined &&
|
|
206
|
+
max === undefined &&
|
|
207
|
+
unavailable.length === 0 &&
|
|
208
|
+
rangeStart === undefined &&
|
|
209
|
+
minDays === undefined &&
|
|
210
|
+
maxDays === undefined
|
|
211
|
+
) {
|
|
212
|
+
return undefined;
|
|
213
|
+
}
|
|
214
|
+
return { min, max, unavailable, rangeStart, minDays, maxDays };
|
|
215
|
+
}
|
|
216
|
+
|
|
188
217
|
function classifyString(
|
|
189
218
|
resolved: JsonSchema,
|
|
190
219
|
title: string | undefined,
|
|
@@ -197,6 +226,7 @@ function classifyString(
|
|
|
197
226
|
format: resolved.format,
|
|
198
227
|
media: mediaUpload(resolved),
|
|
199
228
|
expression: expressionAnnotation(resolved),
|
|
229
|
+
date: dateConstraints(resolved),
|
|
200
230
|
},
|
|
201
231
|
nullable,
|
|
202
232
|
schema: resolved,
|
|
@@ -207,6 +237,7 @@ function classifyString(
|
|
|
207
237
|
|
|
208
238
|
function classifyArray(
|
|
209
239
|
resolved: JsonSchema,
|
|
240
|
+
root: JsonSchema,
|
|
210
241
|
title: string | undefined,
|
|
211
242
|
description: string | undefined,
|
|
212
243
|
nullable: boolean,
|
|
@@ -223,6 +254,23 @@ function classifyArray(
|
|
|
223
254
|
nullable,
|
|
224
255
|
);
|
|
225
256
|
}
|
|
257
|
+
// An array whose items are a FIXED set of strings (an `enum` on the items, or a
|
|
258
|
+
// per-send option list that set one) is a multi-select: a checkbox group over a known
|
|
259
|
+
// option set (scope E). An array of free strings keeps the add/remove `array` field.
|
|
260
|
+
const items = resolveRef(resolved.items, root);
|
|
261
|
+
const itemTypes = typeList(items);
|
|
262
|
+
const isStringItems = itemTypes.length === 1 && itemTypes[0] === 'string';
|
|
263
|
+
const enumValues = items.enum;
|
|
264
|
+
if (isStringItems && enumValues !== undefined && enumValues.length > 0) {
|
|
265
|
+
const options = enumValues.map((value) => ({ value, label: scalarLabel(value) }));
|
|
266
|
+
return {
|
|
267
|
+
model: { kind: 'multiselect', options },
|
|
268
|
+
nullable,
|
|
269
|
+
schema: resolved,
|
|
270
|
+
title,
|
|
271
|
+
description,
|
|
272
|
+
};
|
|
273
|
+
}
|
|
226
274
|
return {
|
|
227
275
|
model: { kind: 'array', items: resolved.items },
|
|
228
276
|
nullable,
|
|
@@ -281,6 +329,7 @@ function classifyObject(
|
|
|
281
329
|
// editor; no type, several types, or an unrecognized one lands on the JSON fallback.
|
|
282
330
|
function classifyByType(
|
|
283
331
|
resolved: JsonSchema,
|
|
332
|
+
root: JsonSchema,
|
|
284
333
|
title: string | undefined,
|
|
285
334
|
description: string | undefined,
|
|
286
335
|
): ClassifiedField {
|
|
@@ -342,7 +391,7 @@ function classifyByType(
|
|
|
342
391
|
description,
|
|
343
392
|
};
|
|
344
393
|
case 'array':
|
|
345
|
-
return classifyArray(resolved, title, description, nullableByType);
|
|
394
|
+
return classifyArray(resolved, root, title, description, nullableByType);
|
|
346
395
|
case 'object':
|
|
347
396
|
return classifyObject(resolved, title, description, nullableByType);
|
|
348
397
|
default:
|
|
@@ -372,5 +421,5 @@ export function classifySchema(raw: JsonSchema, root: JsonSchema): ClassifiedFie
|
|
|
372
421
|
const members = unionMembers(resolved);
|
|
373
422
|
if (members !== undefined) return classifyUnion(resolved, members, root, title, description);
|
|
374
423
|
if (resolved.allOf !== undefined) return classifyAllOf(resolved, root, title, description);
|
|
375
|
-
return classifyByType(resolved, title, description);
|
|
424
|
+
return classifyByType(resolved, root, title, description);
|
|
376
425
|
}
|
|
@@ -11,7 +11,9 @@ import { RadioGroup } from '../components/radio-group';
|
|
|
11
11
|
import { Select } from '../components/select';
|
|
12
12
|
import type { FieldModel } from './field-model';
|
|
13
13
|
|
|
14
|
-
|
|
14
|
+
// The documented radio-vs-select threshold, shared across channels: a choice of this many
|
|
15
|
+
// options or fewer draws as a radio group, a longer one as a select.
|
|
16
|
+
const RADIO_MAX_OPTIONS = 5;
|
|
15
17
|
|
|
16
18
|
export function EnumField({
|
|
17
19
|
heading,
|
|
@@ -28,6 +28,24 @@ export interface MediaUpload {
|
|
|
28
28
|
readonly maxBytes: number | undefined;
|
|
29
29
|
}
|
|
30
30
|
|
|
31
|
+
/**
|
|
32
|
+
* The platform date constraints classified off a `date`/`date-time` string property
|
|
33
|
+
* (scope C). `min`/`max` are inclusive `YYYY-MM-DD` bounds (passed to the native date
|
|
34
|
+
* control and enforced by the validator); `unavailable` is the set of excluded days
|
|
35
|
+
* (the native control cannot disable arbitrary days, so this is validator-enforced).
|
|
36
|
+
* A date RANGE is two ordinary date fields: on the END field, `rangeStart` names its
|
|
37
|
+
* start field and `minDays`/`maxDays` bound the inclusive span. Absent members leave
|
|
38
|
+
* that aspect unconstrained.
|
|
39
|
+
*/
|
|
40
|
+
export interface DateConstraints {
|
|
41
|
+
readonly min: string | undefined;
|
|
42
|
+
readonly max: string | undefined;
|
|
43
|
+
readonly unavailable: readonly string[];
|
|
44
|
+
readonly rangeStart: string | undefined;
|
|
45
|
+
readonly minDays: number | undefined;
|
|
46
|
+
readonly maxDays: number | undefined;
|
|
47
|
+
}
|
|
48
|
+
|
|
31
49
|
/** One top-level key of the expression's input document, with a one-line gloss. */
|
|
32
50
|
export interface ExpressionAnnotationKey {
|
|
33
51
|
readonly name: string;
|
|
@@ -106,10 +124,21 @@ export type FieldModel =
|
|
|
106
124
|
/** Present when a well-formed `x-tai42-expression` annotation opts the
|
|
107
125
|
* field into the jq expression editor (see {@link ExpressionAnnotation}). */
|
|
108
126
|
readonly expression: ExpressionAnnotation | undefined;
|
|
127
|
+
/** Present on a `date`/`date-time` string carrying any platform date
|
|
128
|
+
* constraint (bounds, unavailable days, or range pairing — scope C). */
|
|
129
|
+
readonly date: DateConstraints | undefined;
|
|
109
130
|
}
|
|
110
131
|
| { readonly kind: 'number'; readonly integer: boolean }
|
|
111
132
|
| { readonly kind: 'boolean' }
|
|
112
133
|
| { readonly kind: 'array'; readonly items: JsonSchema }
|
|
134
|
+
| {
|
|
135
|
+
// An array of a FIXED set of strings (its `items` carry an `enum`, or a per-send
|
|
136
|
+
// option list set one): a multi-select control (a checkbox group), one checkbox per
|
|
137
|
+
// option, value a `string[]`. An array of free strings stays the add/remove
|
|
138
|
+
// `array` field — a multi-select needs a known option set to draw.
|
|
139
|
+
readonly kind: 'multiselect';
|
|
140
|
+
readonly options: readonly EnumOption[];
|
|
141
|
+
}
|
|
113
142
|
| {
|
|
114
143
|
readonly kind: 'object';
|
|
115
144
|
readonly properties: readonly (readonly [string, JsonSchema])[];
|
|
@@ -13,7 +13,9 @@ import { ArrayField } from './array-field';
|
|
|
13
13
|
import { classifySchema } from './classify';
|
|
14
14
|
import { EnumField } from './enum-field';
|
|
15
15
|
import { FieldGroup } from './field-group';
|
|
16
|
+
import type { FieldModel } from './field-model';
|
|
16
17
|
import { JsonField } from './json-field';
|
|
18
|
+
import { MultiSelectField } from './multiselect-field';
|
|
17
19
|
import { ObjectFields } from './object-fields';
|
|
18
20
|
import { RecordField } from './record-field';
|
|
19
21
|
import { scalarLabel } from './resolve';
|
|
@@ -58,8 +60,41 @@ function FieldNodeImpl(props: FieldNodeProps): ReactNode {
|
|
|
58
60
|
const base = labelText(classified.title, props.label);
|
|
59
61
|
const heading = props.markRequired === true && required ? `${base} *` : base;
|
|
60
62
|
const { model, description } = classified;
|
|
61
|
-
|
|
63
|
+
return renderField({
|
|
64
|
+
model,
|
|
65
|
+
nullable: classified.nullable,
|
|
66
|
+
heading,
|
|
67
|
+
description,
|
|
68
|
+
error: errors?.[path],
|
|
69
|
+
root,
|
|
70
|
+
value,
|
|
71
|
+
onChange,
|
|
72
|
+
path,
|
|
73
|
+
required,
|
|
74
|
+
errors,
|
|
75
|
+
idPrefix,
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** The props the per-kind renderer needs, computed once by {@link FieldNodeImpl}. */
|
|
80
|
+
interface RenderFieldArgs {
|
|
81
|
+
readonly model: FieldModel;
|
|
82
|
+
readonly nullable: boolean;
|
|
83
|
+
readonly heading: string;
|
|
84
|
+
readonly description: string | undefined;
|
|
85
|
+
readonly error: string | undefined;
|
|
86
|
+
readonly root: JsonSchema;
|
|
87
|
+
readonly value: unknown;
|
|
88
|
+
readonly onChange: (value: unknown) => void;
|
|
89
|
+
readonly path: string;
|
|
90
|
+
readonly required: boolean;
|
|
91
|
+
readonly errors: SchemaFormErrors | undefined;
|
|
92
|
+
readonly idPrefix: string;
|
|
93
|
+
}
|
|
62
94
|
|
|
95
|
+
/** Render the field component matching the classified model kind. */
|
|
96
|
+
function renderField(args: RenderFieldArgs): ReactNode {
|
|
97
|
+
const { model, nullable, heading, description, error, value, onChange, path, required } = args;
|
|
63
98
|
switch (model.kind) {
|
|
64
99
|
case 'json':
|
|
65
100
|
return (
|
|
@@ -68,7 +103,7 @@ function FieldNodeImpl(props: FieldNodeProps): ReactNode {
|
|
|
68
103
|
description={description}
|
|
69
104
|
error={error}
|
|
70
105
|
jsonType={model.jsonType}
|
|
71
|
-
nullable={
|
|
106
|
+
nullable={nullable}
|
|
72
107
|
value={value}
|
|
73
108
|
onChange={onChange}
|
|
74
109
|
/>
|
|
@@ -102,6 +137,7 @@ function FieldNodeImpl(props: FieldNodeProps): ReactNode {
|
|
|
102
137
|
format={model.format}
|
|
103
138
|
media={model.media}
|
|
104
139
|
expression={model.expression}
|
|
140
|
+
date={model.date}
|
|
105
141
|
argName={path}
|
|
106
142
|
value={value}
|
|
107
143
|
required={required}
|
|
@@ -129,6 +165,29 @@ function FieldNodeImpl(props: FieldNodeProps): ReactNode {
|
|
|
129
165
|
onChange={onChange}
|
|
130
166
|
/>
|
|
131
167
|
);
|
|
168
|
+
case 'multiselect':
|
|
169
|
+
return (
|
|
170
|
+
<MultiSelectField
|
|
171
|
+
heading={heading}
|
|
172
|
+
description={description}
|
|
173
|
+
error={error}
|
|
174
|
+
model={model}
|
|
175
|
+
value={value}
|
|
176
|
+
onChange={onChange}
|
|
177
|
+
/>
|
|
178
|
+
);
|
|
179
|
+
default:
|
|
180
|
+
return renderCompositeField(args, model);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** The composite (recursing) field kinds: array, object, record, and union. */
|
|
185
|
+
function renderCompositeField(
|
|
186
|
+
args: RenderFieldArgs,
|
|
187
|
+
model: Extract<FieldModel, { kind: 'array' | 'object' | 'record' | 'union' }>,
|
|
188
|
+
): ReactNode {
|
|
189
|
+
const { heading, description, error, root, value, onChange, path, errors, idPrefix } = args;
|
|
190
|
+
switch (model.kind) {
|
|
132
191
|
case 'array':
|
|
133
192
|
return (
|
|
134
193
|
<ArrayField
|
package/src/schema-form/index.ts
CHANGED
|
@@ -46,3 +46,4 @@ export type { SecretRef, SecretRefFieldProps } from './SecretRefField';
|
|
|
46
46
|
export { SecretRefField } from './SecretRefField';
|
|
47
47
|
export type { Discriminator, JsonSchema, JsonSchemaType, SchemaFormErrors } from './types';
|
|
48
48
|
export { validateAgainstSchema } from './validate';
|
|
49
|
+
export { isFieldVisible } from './visibility';
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The multi-select field renderer (scope E): an array of a fixed set of strings drawn
|
|
3
|
+
* as a checkbox group, one checkbox per option, emitting a `string[]`. Classification
|
|
4
|
+
* lands an array here only when its items carry a known option set (an `enum`, or a
|
|
5
|
+
* per-send option list), so there is always a definite set of boxes to draw; an array of
|
|
6
|
+
* free strings stays the add/remove `array` field.
|
|
7
|
+
*/
|
|
8
|
+
import type { ReactNode } from 'react';
|
|
9
|
+
|
|
10
|
+
import { Checkbox } from '../components/checkbox';
|
|
11
|
+
import { Field } from '../components/field';
|
|
12
|
+
import type { FieldModel } from './field-model';
|
|
13
|
+
|
|
14
|
+
export function MultiSelectField({
|
|
15
|
+
heading,
|
|
16
|
+
description,
|
|
17
|
+
error,
|
|
18
|
+
model,
|
|
19
|
+
value,
|
|
20
|
+
onChange,
|
|
21
|
+
}: {
|
|
22
|
+
heading: string;
|
|
23
|
+
description: string | undefined;
|
|
24
|
+
error: string | undefined;
|
|
25
|
+
model: Extract<FieldModel, { kind: 'multiselect' }>;
|
|
26
|
+
value: unknown;
|
|
27
|
+
onChange: (value: unknown) => void;
|
|
28
|
+
}): ReactNode {
|
|
29
|
+
const selected = Array.isArray(value)
|
|
30
|
+
? value.filter((item): item is string => typeof item === 'string')
|
|
31
|
+
: [];
|
|
32
|
+
// Toggle keeps the selection in the OPTIONS' declared order, so the emitted array reads
|
|
33
|
+
// the way the form lists the choices rather than the order the boxes were clicked.
|
|
34
|
+
const toggle = (optionValue: string, checked: boolean): void => {
|
|
35
|
+
const next = checked ? [...selected, optionValue] : selected.filter((v) => v !== optionValue);
|
|
36
|
+
const ordered = model.options
|
|
37
|
+
.map((option) => String(option.value))
|
|
38
|
+
.filter((candidate) => next.includes(candidate));
|
|
39
|
+
onChange(ordered);
|
|
40
|
+
};
|
|
41
|
+
return (
|
|
42
|
+
<Field label={heading} description={description} error={error} group>
|
|
43
|
+
{model.options.map((option, index) => {
|
|
44
|
+
const optionValue = String(option.value);
|
|
45
|
+
return (
|
|
46
|
+
<Checkbox
|
|
47
|
+
key={index}
|
|
48
|
+
label={option.label}
|
|
49
|
+
checked={selected.includes(optionValue)}
|
|
50
|
+
onCheckedChange={(checked) => {
|
|
51
|
+
toggle(optionValue, checked);
|
|
52
|
+
}}
|
|
53
|
+
/>
|
|
54
|
+
);
|
|
55
|
+
})}
|
|
56
|
+
</Field>
|
|
57
|
+
);
|
|
58
|
+
}
|
|
@@ -9,6 +9,7 @@ import type { ReactNode } from 'react';
|
|
|
9
9
|
import { FieldNode } from './field-node';
|
|
10
10
|
import { useKeyedCallbacks } from './keyed-callbacks';
|
|
11
11
|
import type { JsonSchema, SchemaFormErrors } from './types';
|
|
12
|
+
import { isFieldVisible } from './visibility';
|
|
12
13
|
|
|
13
14
|
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
14
15
|
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
@@ -49,6 +50,9 @@ export function ObjectFields({
|
|
|
49
50
|
<>
|
|
50
51
|
{properties.map(([name, propSchema]) => {
|
|
51
52
|
if (skip !== undefined && name === skip) return null;
|
|
53
|
+
// A field hidden by its `visibleWhen` predicate (scope B) is not rendered; the
|
|
54
|
+
// validator skips it in step, so a hidden field is neither shown nor required.
|
|
55
|
+
if (!isFieldVisible(propSchema, obj)) return null;
|
|
52
56
|
const childPath = path === '' ? name : `${path}.${name}`;
|
|
53
57
|
return (
|
|
54
58
|
<FieldNode
|
|
@@ -28,7 +28,7 @@ import { Textarea, TextInput } from '../components/inputs';
|
|
|
28
28
|
import { errorMessage } from '../errors';
|
|
29
29
|
import type { ExpressionFieldComponent, ExpressionInputShape } from './context';
|
|
30
30
|
import { CompletionProviderContext, ExpressionFieldContext } from './context';
|
|
31
|
-
import type { ExpressionAnnotation, MediaUpload } from './field-model';
|
|
31
|
+
import type { DateConstraints, ExpressionAnnotation, MediaUpload } from './field-model';
|
|
32
32
|
import { MediaField } from './media-field';
|
|
33
33
|
import type { CompletionProvider } from './SchemaForm';
|
|
34
34
|
|
|
@@ -39,6 +39,7 @@ export function StringField({
|
|
|
39
39
|
format,
|
|
40
40
|
media,
|
|
41
41
|
expression,
|
|
42
|
+
date,
|
|
42
43
|
argName,
|
|
43
44
|
value,
|
|
44
45
|
required,
|
|
@@ -50,6 +51,7 @@ export function StringField({
|
|
|
50
51
|
format: string | undefined;
|
|
51
52
|
media: MediaUpload | undefined;
|
|
52
53
|
expression: ExpressionAnnotation | undefined;
|
|
54
|
+
date: DateConstraints | undefined;
|
|
53
55
|
argName: string;
|
|
54
56
|
value: unknown;
|
|
55
57
|
required: boolean;
|
|
@@ -115,6 +117,11 @@ export function StringField({
|
|
|
115
117
|
) : (
|
|
116
118
|
<TextInput
|
|
117
119
|
type={stringInputType(format)}
|
|
120
|
+
// A native date control enforces its inclusive bounds (scope C); on a
|
|
121
|
+
// non-date input the browser ignores these, and the validator enforces the
|
|
122
|
+
// rest (unavailable days, range span) that the control cannot draw.
|
|
123
|
+
min={date?.min}
|
|
124
|
+
max={date?.max}
|
|
118
125
|
value={current}
|
|
119
126
|
onChange={(event) => {
|
|
120
127
|
emit(event.target.value);
|
package/src/schema-form/types.ts
CHANGED
|
@@ -60,9 +60,48 @@ export interface JsonSchema {
|
|
|
60
60
|
readonly maxLength?: number;
|
|
61
61
|
readonly minItems?: number;
|
|
62
62
|
readonly maxItems?: number;
|
|
63
|
+
readonly pattern?: string;
|
|
64
|
+
/**
|
|
65
|
+
* Platform date-constraint keys on a `string`/`date` (or `date-time`) property. They
|
|
66
|
+
* are NOT the JSON-Schema numeric keywords (which are inert on a string): `minDate`/
|
|
67
|
+
* `maxDate` are inclusive `YYYY-MM-DD` bounds, `unavailableDates` is a list of
|
|
68
|
+
* `YYYY-MM-DD` days to exclude. A date RANGE is TWO ordinary date fields: the END field
|
|
69
|
+
* names its start field with `rangeStart` and carries the inclusive span in
|
|
70
|
+
* `minDays`/`maxDays`; nothing is recombined. All optional; absent = an unconstrained
|
|
71
|
+
* date. The native control enforces what it can (min/max) and the validator enforces the
|
|
72
|
+
* rest, mirroring the server.
|
|
73
|
+
*/
|
|
74
|
+
readonly minDate?: string;
|
|
75
|
+
readonly maxDate?: string;
|
|
76
|
+
readonly unavailableDates?: readonly string[];
|
|
77
|
+
readonly rangeStart?: string;
|
|
78
|
+
readonly minDays?: number;
|
|
79
|
+
readonly maxDays?: number;
|
|
80
|
+
/**
|
|
81
|
+
* A declarative visibility predicate on a property: the field shows only when the
|
|
82
|
+
* predicate holds against the current form values. Static form description evaluated by
|
|
83
|
+
* the renderer (never a consumer reaction); a hidden field is not rendered and not
|
|
84
|
+
* validated. Absent = always visible. See {@link VisibleWhen}.
|
|
85
|
+
*/
|
|
86
|
+
readonly visibleWhen?: VisibleWhen;
|
|
63
87
|
readonly [key: string]: unknown;
|
|
64
88
|
}
|
|
65
89
|
|
|
90
|
+
/**
|
|
91
|
+
* A field-visibility predicate ({@link JsonSchema.visibleWhen}): the field is visible
|
|
92
|
+
* only when the referenced sibling `field` satisfies exactly one operator — `equals` a
|
|
93
|
+
* value, `in` a list of values, or `notEmpty` (a non-blank scalar / a non-empty array).
|
|
94
|
+
* A malformed predicate (none or several operators) degrades to VISIBLE: the send-time
|
|
95
|
+
* server validation rejects a malformed predicate loudly, so one never reaches a well-
|
|
96
|
+
* formed render, and showing a field is the safe non-hiding default.
|
|
97
|
+
*/
|
|
98
|
+
export interface VisibleWhen {
|
|
99
|
+
readonly field: string;
|
|
100
|
+
readonly equals?: unknown;
|
|
101
|
+
readonly in?: readonly unknown[];
|
|
102
|
+
readonly notEmpty?: boolean;
|
|
103
|
+
}
|
|
104
|
+
|
|
66
105
|
/**
|
|
67
106
|
* A structured, per-path bag of validation problems. Keys are dotted/bracketed
|
|
68
107
|
* paths from the form root (`""` = the root value, `"user.name"`, `"tags[0]"`);
|
|
@@ -7,11 +7,12 @@
|
|
|
7
7
|
*/
|
|
8
8
|
import { isRecord } from '../guards';
|
|
9
9
|
import { classifySchema } from './classify';
|
|
10
|
-
import type { FieldModel } from './field-model';
|
|
10
|
+
import type { DateConstraints, FieldModel } from './field-model';
|
|
11
11
|
import { decodedByteSize, effectiveMaxBytes, overCapMessage } from './media';
|
|
12
|
-
import { scalarLabel } from './resolve';
|
|
12
|
+
import { resolveRef, scalarLabel } from './resolve';
|
|
13
13
|
import type { JsonSchema, SchemaFormErrors } from './types';
|
|
14
14
|
import { activeVariantIndex } from './union';
|
|
15
|
+
import { isFieldVisible } from './visibility';
|
|
15
16
|
|
|
16
17
|
/** Options for {@link validateAgainstSchema}. */
|
|
17
18
|
export interface ValidateOptions {
|
|
@@ -126,10 +127,31 @@ function validateString(
|
|
|
126
127
|
}
|
|
127
128
|
if (value.length > 0 && model.format !== undefined) {
|
|
128
129
|
const message = formatError(model.format, value);
|
|
130
|
+
if (message !== undefined) {
|
|
131
|
+
ctx.errors[path] = message;
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
// Date bounds and unavailable days (scope C). Range span/ordering needs the sibling
|
|
136
|
+
// start field, so it is enforced at the object level (see validateDateRanges).
|
|
137
|
+
if (model.date !== undefined && value.length > 0) {
|
|
138
|
+
const message = dateConstraintError(model.date, value);
|
|
129
139
|
if (message !== undefined) ctx.errors[path] = message;
|
|
130
140
|
}
|
|
131
141
|
}
|
|
132
142
|
|
|
143
|
+
/** The inclusive bound/unavailable-day violation on a date value, or `undefined`. */
|
|
144
|
+
function dateConstraintError(date: DateConstraints, value: string): string | undefined {
|
|
145
|
+
// `YYYY-MM-DD` strings compare lexicographically in chronological order, so the bounds
|
|
146
|
+
// are plain string comparisons. A value that is not a well-formed date is left to the
|
|
147
|
+
// `format` check above; this runs only for a syntactically valid date.
|
|
148
|
+
if (!DATE_RE.test(value)) return undefined;
|
|
149
|
+
if (date.min !== undefined && value < date.min) return `must be on or after ${date.min}`;
|
|
150
|
+
if (date.max !== undefined && value > date.max) return `must be on or before ${date.max}`;
|
|
151
|
+
if (date.unavailable.includes(value)) return 'is not an available date';
|
|
152
|
+
return undefined;
|
|
153
|
+
}
|
|
154
|
+
|
|
133
155
|
function validateNumber(
|
|
134
156
|
model: ModelOf<'number'>,
|
|
135
157
|
value: unknown,
|
|
@@ -174,6 +196,11 @@ function validateObject(
|
|
|
174
196
|
}
|
|
175
197
|
const propMap = new Map(model.properties);
|
|
176
198
|
for (const [name, propSchema] of model.properties) {
|
|
199
|
+
// A field hidden by its `visibleWhen` predicate (scope B) is not part of the answer:
|
|
200
|
+
// it is not required and not validated, and a value left on it is dropped by the
|
|
201
|
+
// server, not an error here. Evaluated on the current values, so a field that was
|
|
202
|
+
// hidden by the predicate is skipped even if it still carries a stale value.
|
|
203
|
+
if (!isFieldVisible(propSchema, value)) continue;
|
|
177
204
|
const childPath = joinPath(path, name);
|
|
178
205
|
const childValue = value[name];
|
|
179
206
|
if (childValue === undefined) {
|
|
@@ -188,6 +215,143 @@ function validateObject(
|
|
|
188
215
|
ctx.errors[joinPath(path, name)] = `"${name}" is required`;
|
|
189
216
|
}
|
|
190
217
|
}
|
|
218
|
+
validateDateRanges(model, value, path, ctx);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/** Days from `start` to `end` inclusive (both `YYYY-MM-DD`); 1 when they are equal. */
|
|
222
|
+
function inclusiveDayCount(start: string, end: string): number {
|
|
223
|
+
return Math.round((Date.parse(end) - Date.parse(start)) / 86_400_000) + 1;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Enforce a date RANGE declared on an END field (scope C): the end is on or after its
|
|
228
|
+
* named `rangeStart` field, and the inclusive day span is within `minDays`/`maxDays`.
|
|
229
|
+
* Read from the two plain date values — nothing recombined. A missing or not-yet-valid
|
|
230
|
+
* endpoint is left to that field's own validation; a hidden end field is skipped.
|
|
231
|
+
*/
|
|
232
|
+
function validateDateRanges(
|
|
233
|
+
model: ModelOf<'object'>,
|
|
234
|
+
value: Record<string, unknown>,
|
|
235
|
+
path: string,
|
|
236
|
+
ctx: ValidateCtx,
|
|
237
|
+
): void {
|
|
238
|
+
for (const [name, propSchema] of model.properties) {
|
|
239
|
+
if (!isFieldVisible(propSchema, value)) continue;
|
|
240
|
+
const resolved = resolveRef(propSchema, ctx.root);
|
|
241
|
+
if (typeof resolved.rangeStart !== 'string') continue;
|
|
242
|
+
const endValue = value[name];
|
|
243
|
+
const startValue = value[resolved.rangeStart];
|
|
244
|
+
if (typeof endValue !== 'string' || typeof startValue !== 'string') continue;
|
|
245
|
+
if (!DATE_RE.test(endValue) || !DATE_RE.test(startValue)) continue;
|
|
246
|
+
const childPath = joinPath(path, name);
|
|
247
|
+
if (ctx.errors[childPath] !== undefined) continue;
|
|
248
|
+
const message = dateRangeMessage(resolved, startValue, endValue);
|
|
249
|
+
if (message !== undefined) ctx.errors[childPath] = message;
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/** The ordering/span violation on a range END field, or `undefined` when it holds. */
|
|
254
|
+
function dateRangeMessage(
|
|
255
|
+
resolved: JsonSchema,
|
|
256
|
+
startValue: string,
|
|
257
|
+
endValue: string,
|
|
258
|
+
): string | undefined {
|
|
259
|
+
if (endValue < startValue) return 'must be on or after the start date';
|
|
260
|
+
const days = inclusiveDayCount(startValue, endValue);
|
|
261
|
+
const minDays = typeof resolved.minDays === 'number' ? resolved.minDays : undefined;
|
|
262
|
+
const maxDays = typeof resolved.maxDays === 'number' ? resolved.maxDays : undefined;
|
|
263
|
+
if (minDays !== undefined && days < minDays)
|
|
264
|
+
return `must span at least ${String(minDays)} day(s)`;
|
|
265
|
+
if (maxDays !== undefined && days > maxDays) return `must span at most ${String(maxDays)} day(s)`;
|
|
266
|
+
return undefined;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function validateMultiselect(
|
|
270
|
+
model: ModelOf<'multiselect'>,
|
|
271
|
+
value: unknown,
|
|
272
|
+
path: string,
|
|
273
|
+
ctx: ValidateCtx,
|
|
274
|
+
): void {
|
|
275
|
+
if (!Array.isArray(value)) {
|
|
276
|
+
ctx.errors[path] = 'must be an array';
|
|
277
|
+
return;
|
|
278
|
+
}
|
|
279
|
+
const allowed = new Set(model.options.map((option) => option.value));
|
|
280
|
+
for (const item of value) {
|
|
281
|
+
if (!allowed.has(item)) {
|
|
282
|
+
ctx.errors[path] = 'must be one of the allowed values';
|
|
283
|
+
return;
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* The JSON-Schema value/length/items bounds the client mirrors from the server (bug 4):
|
|
290
|
+
* `minLength`/`maxLength`/`pattern` on a string, `minimum`/`maximum` on a number,
|
|
291
|
+
* `minItems`/`maxItems` on an array. Applied only when the kind check found no type
|
|
292
|
+
* error. A `pattern` that cannot compile surfaces as a loud field error, never a silent
|
|
293
|
+
* skip — the server validates every send's schema, so a well-formed form never hits it.
|
|
294
|
+
*/
|
|
295
|
+
function applyBounds(schema: JsonSchema, value: unknown, path: string, ctx: ValidateCtx): void {
|
|
296
|
+
if (typeof value === 'string') applyStringBounds(schema, value, path, ctx);
|
|
297
|
+
else if (typeof value === 'number') applyNumberBounds(schema, value, path, ctx);
|
|
298
|
+
else if (Array.isArray(value)) applyArrayBounds(schema, value, path, ctx);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
function applyStringBounds(
|
|
302
|
+
schema: JsonSchema,
|
|
303
|
+
value: string,
|
|
304
|
+
path: string,
|
|
305
|
+
ctx: ValidateCtx,
|
|
306
|
+
): void {
|
|
307
|
+
const length = Array.from(value).length;
|
|
308
|
+
if (typeof schema.minLength === 'number' && length < schema.minLength) {
|
|
309
|
+
ctx.errors[path] = `must be at least ${String(schema.minLength)} character(s)`;
|
|
310
|
+
return;
|
|
311
|
+
}
|
|
312
|
+
if (typeof schema.maxLength === 'number' && length > schema.maxLength) {
|
|
313
|
+
ctx.errors[path] = `must be at most ${String(schema.maxLength)} character(s)`;
|
|
314
|
+
return;
|
|
315
|
+
}
|
|
316
|
+
if (typeof schema.pattern !== 'string' || schema.pattern === '') return;
|
|
317
|
+
let pattern: RegExp;
|
|
318
|
+
try {
|
|
319
|
+
pattern = new RegExp(schema.pattern);
|
|
320
|
+
} catch {
|
|
321
|
+
ctx.errors[path] = 'has an invalid pattern constraint';
|
|
322
|
+
return;
|
|
323
|
+
}
|
|
324
|
+
if (!pattern.test(value)) ctx.errors[path] = 'must match the required pattern';
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
function applyNumberBounds(
|
|
328
|
+
schema: JsonSchema,
|
|
329
|
+
value: number,
|
|
330
|
+
path: string,
|
|
331
|
+
ctx: ValidateCtx,
|
|
332
|
+
): void {
|
|
333
|
+
if (typeof schema.minimum === 'number' && value < schema.minimum) {
|
|
334
|
+
ctx.errors[path] = `must be at least ${String(schema.minimum)}`;
|
|
335
|
+
return;
|
|
336
|
+
}
|
|
337
|
+
if (typeof schema.maximum === 'number' && value > schema.maximum) {
|
|
338
|
+
ctx.errors[path] = `must be at most ${String(schema.maximum)}`;
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
function applyArrayBounds(
|
|
343
|
+
schema: JsonSchema,
|
|
344
|
+
value: readonly unknown[],
|
|
345
|
+
path: string,
|
|
346
|
+
ctx: ValidateCtx,
|
|
347
|
+
): void {
|
|
348
|
+
if (typeof schema.minItems === 'number' && value.length < schema.minItems) {
|
|
349
|
+
ctx.errors[path] = `must have at least ${String(schema.minItems)} item(s)`;
|
|
350
|
+
return;
|
|
351
|
+
}
|
|
352
|
+
if (typeof schema.maxItems === 'number' && value.length > schema.maxItems) {
|
|
353
|
+
ctx.errors[path] = `must have at most ${String(schema.maxItems)} item(s)`;
|
|
354
|
+
}
|
|
191
355
|
}
|
|
192
356
|
|
|
193
357
|
function validateRecord(
|
|
@@ -228,7 +392,13 @@ function walk(schema: JsonSchema, value: unknown, path: string, ctx: ValidateCtx
|
|
|
228
392
|
return;
|
|
229
393
|
}
|
|
230
394
|
|
|
231
|
-
|
|
395
|
+
dispatchKind(classified.model, value, path, ctx);
|
|
396
|
+
// The value/length/items bounds (bug 4) mirror the server and apply only when the kind
|
|
397
|
+
// check found no type error for this field — a bound on a wrong-typed value is noise.
|
|
398
|
+
if (ctx.errors[path] === undefined) applyBounds(classified.schema, value, path, ctx);
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
function dispatchKind(model: FieldModel, value: unknown, path: string, ctx: ValidateCtx): void {
|
|
232
402
|
switch (model.kind) {
|
|
233
403
|
case 'json':
|
|
234
404
|
validateJson(model, value, path, ctx);
|
|
@@ -251,6 +421,9 @@ function walk(schema: JsonSchema, value: unknown, path: string, ctx: ValidateCtx
|
|
|
251
421
|
case 'array':
|
|
252
422
|
validateArray(model, value, path, ctx);
|
|
253
423
|
return;
|
|
424
|
+
case 'multiselect':
|
|
425
|
+
validateMultiselect(model, value, path, ctx);
|
|
426
|
+
return;
|
|
254
427
|
case 'object':
|
|
255
428
|
validateObject(model, value, path, ctx);
|
|
256
429
|
return;
|