@tai42/studio-sdk 19.2.1 → 20.0.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/components/icons/index.d.ts +1 -1
- package/dist/components/icons/index.d.ts.map +1 -1
- package/dist/components/icons/index.js +1 -1
- package/dist/components/icons/index.js.map +1 -1
- package/dist/components/icons/nav-map.d.ts.map +1 -1
- package/dist/components/icons/nav-map.js +2 -1
- package/dist/components/icons/nav-map.js.map +1 -1
- package/dist/components/icons/nav-marks.d.ts +2 -0
- package/dist/components/icons/nav-marks.d.ts.map +1 -1
- package/dist/components/icons/nav-marks.js +2 -0
- package/dist/components/icons/nav-marks.js.map +1 -1
- package/dist/hooks/useApi.d.ts +51 -12
- package/dist/hooks/useApi.d.ts.map +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -2
- package/dist/index.js.map +1 -1
- package/dist/navigation/types.d.ts +1 -0
- package/dist/navigation/types.d.ts.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 +2 -2
- package/src/components/icons/index.ts +1 -0
- package/src/components/icons/nav-map.ts +2 -0
- package/src/components/icons/nav-marks.tsx +10 -0
- package/src/index.ts +2 -0
- package/src/navigation/types.ts +3 -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
|
@@ -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;
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The single evaluator for a property's `visibleWhen` predicate — a static,
|
|
3
|
+
* declarative field-visibility rule (scope B). The renderer (`ObjectFields`) and the
|
|
4
|
+
* validator both call it so a field shown by the form is exactly the field validated at
|
|
5
|
+
* submit, and a hidden field is neither rendered nor required. It reads only sibling
|
|
6
|
+
* values already in the object; it never calls a consumer handler (that is scope A).
|
|
7
|
+
*
|
|
8
|
+
* A malformed predicate degrades to VISIBLE: the send-time server validation rejects a
|
|
9
|
+
* malformed predicate loudly, so one never reaches a well-formed render, and showing a
|
|
10
|
+
* field is the safe non-hiding default (a hidden-by-accident required field would dead-
|
|
11
|
+
* end a form).
|
|
12
|
+
*/
|
|
13
|
+
import type { JsonSchema } from './types';
|
|
14
|
+
|
|
15
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
16
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** A value is "empty" for a `notEmpty` test: absent/null, a blank string, an empty array. */
|
|
20
|
+
function isEmptyValue(value: unknown): boolean {
|
|
21
|
+
if (value === undefined || value === null) return true;
|
|
22
|
+
if (typeof value === 'string') return value.trim() === '';
|
|
23
|
+
if (Array.isArray(value)) return value.length === 0;
|
|
24
|
+
return false;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Whether a property carrying `schema.visibleWhen` is visible against the current
|
|
29
|
+
* `values` of its enclosing object. Absent predicate ⇒ always visible. Exactly one of
|
|
30
|
+
* `equals`/`in`/`notEmpty` must be present; otherwise the predicate is malformed and the
|
|
31
|
+
* field stays visible.
|
|
32
|
+
*/
|
|
33
|
+
export function isFieldVisible(schema: JsonSchema, values: Record<string, unknown>): boolean {
|
|
34
|
+
const predicate: unknown = schema.visibleWhen;
|
|
35
|
+
if (!isRecord(predicate) || typeof predicate.field !== 'string') return true;
|
|
36
|
+
const current = values[predicate.field];
|
|
37
|
+
|
|
38
|
+
const inList = predicate.in;
|
|
39
|
+
const hasEquals = 'equals' in predicate;
|
|
40
|
+
const hasIn = Array.isArray(inList);
|
|
41
|
+
const hasNotEmpty = predicate.notEmpty === true;
|
|
42
|
+
if (Number(hasEquals) + Number(hasIn) + Number(hasNotEmpty) !== 1) return true;
|
|
43
|
+
|
|
44
|
+
if (hasEquals) return current === predicate.equals;
|
|
45
|
+
if (hasIn) return (inList as readonly unknown[]).some((candidate) => candidate === current);
|
|
46
|
+
return !isEmptyValue(current);
|
|
47
|
+
}
|