@tai42/studio-sdk 19.2.1 → 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.
Files changed (57) hide show
  1. package/dist/hooks/useApi.d.ts +9 -0
  2. package/dist/hooks/useApi.d.ts.map +1 -1
  3. package/dist/index.d.ts +1 -1
  4. package/dist/index.d.ts.map +1 -1
  5. package/dist/index.js +1 -1
  6. package/dist/index.js.map +1 -1
  7. package/dist/schema-form/classify.d.ts.map +1 -1
  8. package/dist/schema-form/classify.js +49 -4
  9. package/dist/schema-form/classify.js.map +1 -1
  10. package/dist/schema-form/default-value.js +1 -0
  11. package/dist/schema-form/default-value.js.map +1 -1
  12. package/dist/schema-form/enum-field.d.ts.map +1 -1
  13. package/dist/schema-form/enum-field.js +3 -1
  14. package/dist/schema-form/enum-field.js.map +1 -1
  15. package/dist/schema-form/field-model.d.ts +23 -0
  16. package/dist/schema-form/field-model.d.ts.map +1 -1
  17. package/dist/schema-form/field-node.d.ts.map +1 -1
  18. package/dist/schema-form/field-node.js +31 -3
  19. package/dist/schema-form/field-node.js.map +1 -1
  20. package/dist/schema-form/index.d.ts +1 -0
  21. package/dist/schema-form/index.d.ts.map +1 -1
  22. package/dist/schema-form/index.js +1 -0
  23. package/dist/schema-form/index.js.map +1 -1
  24. package/dist/schema-form/multiselect-field.d.ts +20 -0
  25. package/dist/schema-form/multiselect-field.d.ts.map +1 -0
  26. package/dist/schema-form/multiselect-field.js +24 -0
  27. package/dist/schema-form/multiselect-field.js.map +1 -0
  28. package/dist/schema-form/object-fields.d.ts.map +1 -1
  29. package/dist/schema-form/object-fields.js +5 -0
  30. package/dist/schema-form/object-fields.js.map +1 -1
  31. package/dist/schema-form/string-field.d.ts +3 -2
  32. package/dist/schema-form/string-field.d.ts.map +1 -1
  33. package/dist/schema-form/string-field.js +6 -2
  34. package/dist/schema-form/string-field.js.map +1 -1
  35. package/dist/schema-form/types.d.ts +38 -0
  36. package/dist/schema-form/types.d.ts.map +1 -1
  37. package/dist/schema-form/validate.d.ts.map +1 -1
  38. package/dist/schema-form/validate.js +156 -2
  39. package/dist/schema-form/validate.js.map +1 -1
  40. package/dist/schema-form/visibility.d.ts +21 -0
  41. package/dist/schema-form/visibility.d.ts.map +1 -0
  42. package/dist/schema-form/visibility.js +37 -0
  43. package/dist/schema-form/visibility.js.map +1 -0
  44. package/package.json +2 -2
  45. package/src/index.ts +1 -0
  46. package/src/schema-form/classify.ts +52 -3
  47. package/src/schema-form/default-value.ts +1 -0
  48. package/src/schema-form/enum-field.tsx +3 -1
  49. package/src/schema-form/field-model.ts +29 -0
  50. package/src/schema-form/field-node.tsx +61 -2
  51. package/src/schema-form/index.ts +1 -0
  52. package/src/schema-form/multiselect-field.tsx +58 -0
  53. package/src/schema-form/object-fields.tsx +4 -0
  54. package/src/schema-form/string-field.tsx +8 -1
  55. package/src/schema-form/types.ts +39 -0
  56. package/src/schema-form/validate.ts +176 -3
  57. package/src/schema-form/visibility.ts +47 -0
@@ -114,6 +114,7 @@ function seedValue(schema: JsonSchema, root: JsonSchema, mintScalars: boolean):
114
114
  case 'object':
115
115
  return seedObject(model, root, mintScalars);
116
116
  case 'array':
117
+ case 'multiselect':
117
118
  return [];
118
119
  case 'record':
119
120
  return {};
@@ -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
- const RADIO_MAX_OPTIONS = 3;
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
- const error = errors?.[path];
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={classified.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
@@ -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);
@@ -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
- const { model } = classified;
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
+ }