@tai42/studio-sdk 22.4.0 → 23.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.
Files changed (43) hide show
  1. package/dist/index.d.ts +2 -2
  2. package/dist/index.d.ts.map +1 -1
  3. package/dist/index.js +1 -1
  4. package/dist/index.js.map +1 -1
  5. package/dist/schema-form/SchemaForm.d.ts +10 -12
  6. package/dist/schema-form/SchemaForm.d.ts.map +1 -1
  7. package/dist/schema-form/SchemaForm.js.map +1 -1
  8. package/dist/schema-form/array-field.d.ts +9 -2
  9. package/dist/schema-form/array-field.d.ts.map +1 -1
  10. package/dist/schema-form/array-field.js +5 -3
  11. package/dist/schema-form/array-field.js.map +1 -1
  12. package/dist/schema-form/date-range-field.d.ts +30 -7
  13. package/dist/schema-form/date-range-field.d.ts.map +1 -1
  14. package/dist/schema-form/date-range-field.js +73 -15
  15. package/dist/schema-form/date-range-field.js.map +1 -1
  16. package/dist/schema-form/field-node.d.ts +7 -8
  17. package/dist/schema-form/field-node.d.ts.map +1 -1
  18. package/dist/schema-form/field-node.js +2 -1
  19. package/dist/schema-form/field-node.js.map +1 -1
  20. package/dist/schema-form/index.d.ts +2 -2
  21. package/dist/schema-form/index.d.ts.map +1 -1
  22. package/dist/schema-form/index.js +1 -1
  23. package/dist/schema-form/index.js.map +1 -1
  24. package/dist/schema-form/object-fields.d.ts +6 -8
  25. package/dist/schema-form/object-fields.d.ts.map +1 -1
  26. package/dist/schema-form/object-fields.js +15 -6
  27. package/dist/schema-form/object-fields.js.map +1 -1
  28. package/dist/schema-form/types.d.ts +29 -0
  29. package/dist/schema-form/types.d.ts.map +1 -1
  30. package/dist/schema-form/validate.d.ts +9 -8
  31. package/dist/schema-form/validate.d.ts.map +1 -1
  32. package/dist/schema-form/validate.js +104 -20
  33. package/dist/schema-form/validate.js.map +1 -1
  34. package/package.json +2 -2
  35. package/src/index.ts +2 -0
  36. package/src/schema-form/SchemaForm.tsx +10 -9
  37. package/src/schema-form/array-field.tsx +19 -6
  38. package/src/schema-form/date-range-field.tsx +112 -25
  39. package/src/schema-form/field-node.tsx +11 -6
  40. package/src/schema-form/index.ts +2 -1
  41. package/src/schema-form/object-fields.tsx +47 -25
  42. package/src/schema-form/types.ts +30 -0
  43. package/src/schema-form/validate.ts +121 -24
@@ -10,7 +10,7 @@ import { classifySchema } from './classify';
10
10
  import type { DateConstraints, FieldModel } from './field-model';
11
11
  import { decodedByteSize, effectiveMaxBytes, overCapMessage } from './media';
12
12
  import { resolveRef, scalarLabel } from './resolve';
13
- import type { JsonSchema, SchemaFormErrors } from './types';
13
+ import type { FieldWords, JsonSchema, SchemaFormErrors } from './types';
14
14
  import { activeVariantIndex } from './union';
15
15
  import { isFieldVisible } from './visibility';
16
16
 
@@ -26,11 +26,13 @@ export interface ValidateOptions {
26
26
  * Per-field words, keyed by the root object's exact property name. A field's
27
27
  * `invalid` sentence replaces every shape error on that field and anything inside
28
28
  * it (a format, pattern, length, bound, type or item error). A missing required
29
- * value keeps its own required message, and a `pattern` that cannot compile keeps
30
- * its own message, since that is a defect of the schema, not of the value. A field
31
- * with no `invalid` sentence keeps the SDK's word for the failing shape.
29
+ * value at the field or anywhere inside it shows the field's `required` sentence
30
+ * when set, else the SDK's own required message; `invalid` never covers it. A
31
+ * `pattern` that cannot compile keeps its own message, since that is a defect of the
32
+ * schema, not of the value. A field with no `invalid` sentence keeps the SDK's word
33
+ * for the failing shape.
32
34
  */
33
- readonly fieldWording?: Readonly<Record<string, { invalid?: string }>>;
35
+ readonly fieldWording?: Readonly<Record<string, FieldWords>>;
34
36
  }
35
37
 
36
38
  /** The invariants every per-node validator shares while walking one value tree. */
@@ -54,6 +56,11 @@ interface ValidateCtx {
54
56
  * each mapped to the root property it lies inside.
55
57
  */
56
58
  readonly shapeErrorFields: Map<string, string>;
59
+ /**
60
+ * The paths whose error is a missing required value a field's `required` sentence
61
+ * may replace, each mapped to the root property it lies inside.
62
+ */
63
+ readonly requiredFields: Map<string, string>;
57
64
  }
58
65
 
59
66
  /**
@@ -62,6 +69,7 @@ interface ValidateCtx {
62
69
  */
63
70
  function shapeError(ctx: ValidateCtx, path: string, message: string): void {
64
71
  ctx.errors[path] = message;
72
+ ctx.requiredFields.delete(path);
65
73
  if (ctx.field === undefined) ctx.shapeErrorFields.delete(path);
66
74
  else ctx.shapeErrorFields.set(path, ctx.field);
67
75
  }
@@ -73,6 +81,7 @@ function shapeError(ctx: ValidateCtx, path: string, message: string): void {
73
81
  function keptError(ctx: ValidateCtx, path: string, message: string): void {
74
82
  ctx.errors[path] = message;
75
83
  ctx.shapeErrorFields.delete(path);
84
+ ctx.requiredFields.delete(path);
76
85
  }
77
86
 
78
87
  /** The context for the child `name` of the value at `path`: a root child is its own field. */
@@ -250,7 +259,7 @@ function validateObject(
250
259
  const childPath = joinPath(path, name);
251
260
  const childValue = ownValue(value, name);
252
261
  if (childValue === undefined) {
253
- if (model.required.has(name)) markRequired(childPath, name, ctx);
262
+ if (model.required.has(name)) markRequired(childPath, name, childCtx(ctx, path, name));
254
263
  continue;
255
264
  }
256
265
  walk(propSchema, childValue, childPath, childCtx(ctx, path, name));
@@ -258,15 +267,20 @@ function validateObject(
258
267
  // Required keys naming a property that somehow isn't declared still count.
259
268
  for (const name of model.required) {
260
269
  if (!propMap.has(name) && ownValue(value, name) === undefined) {
261
- markRequired(joinPath(path, name), name, ctx);
270
+ markRequired(joinPath(path, name), name, childCtx(ctx, path, name));
262
271
  }
263
272
  }
264
273
  validateDateRanges(model, value, path, ctx);
265
274
  }
266
275
 
267
- /** Record a missing required value at `path`, the one error a field's words never replace. */
276
+ /**
277
+ * Record a missing required value at `path`. `ctx` is the missing child's own context,
278
+ * so inside a root property the field's `required` sentence may replace the message
279
+ * (never its `invalid` sentence).
280
+ */
268
281
  function markRequired(path: string, name: string, ctx: ValidateCtx): void {
269
282
  keptError(ctx, path, `"${name}" is required`);
283
+ if (ctx.field !== undefined) ctx.requiredFields.set(path, ctx.field);
270
284
  }
271
285
 
272
286
  /** Days from `start` to `end` inclusive (both `YYYY-MM-DD`); 1 when they are equal. */
@@ -274,11 +288,51 @@ function inclusiveDayCount(start: string, end: string): number {
274
288
  return Math.round((Date.parse(end) - Date.parse(start)) / 86_400_000) + 1;
275
289
  }
276
290
 
291
+ const WRITTEN_DATE_RE = /^(\d{4}-\d{2}-\d{2})[Tt]/;
292
+ const TIME_RE = /^(\d{2}):(\d{2})(?::(\d{2}))?$/;
293
+
294
+ /**
295
+ * The written `YYYY-MM-DD` date of a range end's value: a `date` value as is, a
296
+ * `date-time` value's leading date. `undefined` when the value does not pass its own
297
+ * format check (that field's own validation reports it).
298
+ */
299
+ function writtenDate(schema: JsonSchema, value: unknown): string | undefined {
300
+ if (typeof value !== 'string') return undefined;
301
+ if (schema.format === 'date') return formatError('date', value) === undefined ? value : undefined;
302
+ if (schema.format === 'date-time' && formatError('date-time', value) === undefined) {
303
+ return WRITTEN_DATE_RE.exec(value)?.[1];
304
+ }
305
+ return undefined;
306
+ }
307
+
308
+ /** The seconds into the day a `HH:MM[:SS]` value names, or `undefined` when it is not a real time. */
309
+ function secondsOfDay(value: unknown): number | undefined {
310
+ if (typeof value !== 'string') return undefined;
311
+ const match = TIME_RE.exec(value);
312
+ if (match === null) return undefined;
313
+ const [hours, minutes, seconds] = [match[1], match[2], match[3] ?? '0'].map(Number);
314
+ if (hours === undefined || minutes === undefined || seconds === undefined) return undefined;
315
+ if (hours > 23 || minutes > 59 || seconds > 59) return undefined;
316
+ return hours * 3600 + minutes * 60 + seconds;
317
+ }
318
+
319
+ /** The `timeOf` time property paired with the date `dateName`, if any. */
320
+ function pairedTimeName(
321
+ dateName: string,
322
+ properties: readonly (readonly [string, JsonSchema])[],
323
+ ): string | undefined {
324
+ return properties.find(([name, schema]) => schema.timeOf === dateName && name !== dateName)?.[0];
325
+ }
326
+
277
327
  /**
278
- * Enforce a date RANGE declared on an END field: the end is on or after its
279
- * named `rangeStart` field, and the inclusive day span is within `minDays`/`maxDays`.
280
- * Read from the two plain date values — nothing recombined. A missing or not-yet-valid
281
- * endpoint is left to that field's own validation; a hidden end field is skipped.
328
+ * Enforce a date RANGE declared on an END field: the end is on or after its named
329
+ * `rangeStart` field, and the inclusive day span is within `minDays`/`maxDays`. Each end is
330
+ * read by its written date (a `date-time` end by its leading `YYYY-MM-DD`), so a mixed
331
+ * `date`/`date-time` pair is ordered by date; a pair of two `date-time` ends is left to
332
+ * the answer door, which orders it by instant. Two ends on the same date that each carry
333
+ * a readable `timeOf` time are then ordered by those times, the error on the END's time.
334
+ * Nothing is recombined. A missing or not-yet-valid endpoint is left to that field's own
335
+ * validation; a hidden end field is skipped.
282
336
  */
283
337
  function validateDateRanges(
284
338
  model: ModelOf<'object'>,
@@ -286,21 +340,55 @@ function validateDateRanges(
286
340
  path: string,
287
341
  ctx: ValidateCtx,
288
342
  ): void {
343
+ const propMap = new Map(model.properties);
289
344
  for (const [name, propSchema] of model.properties) {
290
345
  if (!isFieldVisible(propSchema, value)) continue;
291
346
  const resolved = resolveRef(propSchema, ctx.root);
292
- if (typeof resolved.rangeStart !== 'string') continue;
293
- const endValue = ownValue(value, name);
294
- const startValue = ownValue(value, resolved.rangeStart);
295
- if (typeof endValue !== 'string' || typeof startValue !== 'string') continue;
296
- if (!DATE_RE.test(endValue) || !DATE_RE.test(startValue)) continue;
347
+ const startName = resolved.rangeStart;
348
+ if (typeof startName !== 'string') continue;
349
+ const startDeclared = propMap.get(startName);
350
+ if (startDeclared === undefined) continue;
351
+ const startSchema = resolveRef(startDeclared, ctx.root);
352
+ if (resolved.format === 'date-time' && startSchema.format === 'date-time') continue;
353
+ const endDate = writtenDate(resolved, ownValue(value, name));
354
+ const startDate = writtenDate(startSchema, ownValue(value, startName));
355
+ if (endDate === undefined || startDate === undefined) continue;
297
356
  const childPath = joinPath(path, name);
298
357
  if (ctx.errors[childPath] !== undefined) continue;
299
- const message = dateRangeMessage(resolved, startValue, endValue);
358
+ if (endDate === startDate && timeOrderError(model, value, path, ctx, startName, name)) continue;
359
+ const message = dateRangeMessage(resolved, startDate, endDate);
300
360
  if (message !== undefined) shapeError(childCtx(ctx, path, name), childPath, message);
301
361
  }
302
362
  }
303
363
 
364
+ /**
365
+ * Record the order error on the END's time when two same-date ends each carry a readable
366
+ * `timeOf` time and the END's is earlier; `true` when it was recorded. An absent or
367
+ * unreadable time leaves the dates to decide.
368
+ */
369
+ function timeOrderError(
370
+ model: ModelOf<'object'>,
371
+ value: Record<string, unknown>,
372
+ path: string,
373
+ ctx: ValidateCtx,
374
+ startName: string,
375
+ endName: string,
376
+ ): boolean {
377
+ const startTimeName = pairedTimeName(startName, model.properties);
378
+ const endTimeName = pairedTimeName(endName, model.properties);
379
+ if (startTimeName === undefined || endTimeName === undefined) return false;
380
+ const startSeconds = secondsOfDay(ownValue(value, startTimeName));
381
+ const endSeconds = secondsOfDay(ownValue(value, endTimeName));
382
+ if (startSeconds === undefined || endSeconds === undefined || endSeconds >= startSeconds) {
383
+ return false;
384
+ }
385
+ const timePath = joinPath(path, endTimeName);
386
+ if (ctx.errors[timePath] === undefined) {
387
+ shapeError(childCtx(ctx, path, endTimeName), timePath, 'must be on or after the start time');
388
+ }
389
+ return true;
390
+ }
391
+
304
392
  /** The ordering/span violation on a range END field, or `undefined` when it holds. */
305
393
  function dateRangeMessage(
306
394
  resolved: JsonSchema,
@@ -489,18 +577,24 @@ function dispatchKind(model: FieldModel, value: unknown, path: string, ctx: Vali
489
577
 
490
578
  /**
491
579
  * Replace each shape error inside a root property whose words carry an `invalid`
492
- * sentence with that sentence; every other error keeps its own message.
580
+ * sentence with that sentence, and each missing required value inside a root property
581
+ * whose words carry a `required` sentence with that one; every other error keeps its
582
+ * own message.
493
583
  */
494
584
  function applyFieldWording(
495
- fieldWording: Readonly<Record<string, { invalid?: string }>>,
585
+ fieldWording: Readonly<Record<string, FieldWords>>,
496
586
  errors: Record<string, string>,
497
587
  shapeErrorFields: ReadonlyMap<string, string>,
588
+ requiredFields: ReadonlyMap<string, string>,
498
589
  ): void {
499
590
  for (const [path, field] of shapeErrorFields) {
500
- if (!Object.hasOwn(fieldWording, field)) continue;
501
- const invalid = fieldWording[field]?.invalid;
591
+ const invalid = ownValue(fieldWording, field)?.invalid;
502
592
  if (invalid !== undefined) errors[path] = invalid;
503
593
  }
594
+ for (const [path, field] of requiredFields) {
595
+ const required = ownValue(fieldWording, field)?.required;
596
+ if (required !== undefined) errors[path] = required;
597
+ }
504
598
  }
505
599
 
506
600
  /**
@@ -509,7 +603,8 @@ function applyFieldWording(
509
603
  * `SchemaForm` for display. `options.maxUploadBytes` mirrors the renderer's
510
604
  * `maxUploadBytes` prop so a media field's byte cap is enforced identically here;
511
605
  * `options.fieldWording` puts a field's own `invalid` sentence in place of the
512
- * SDK's word for any shape error on that field.
606
+ * SDK's word for any shape error on that field, and its own `required` sentence in
607
+ * place of the SDK's message for a missing required value at or inside that field.
513
608
  */
514
609
  export function validateAgainstSchema(
515
610
  schema: JsonSchema,
@@ -518,15 +613,17 @@ export function validateAgainstSchema(
518
613
  ): SchemaFormErrors {
519
614
  const errors = Object.create(null) as Record<string, string>;
520
615
  const shapeErrorFields = new Map<string, string>();
616
+ const requiredFields = new Map<string, string>();
521
617
  walk(schema, value, '', {
522
618
  root: schema,
523
619
  errors,
524
620
  maxUploadBytes: options?.maxUploadBytes,
525
621
  field: undefined,
526
622
  shapeErrorFields,
623
+ requiredFields,
527
624
  });
528
625
  if (options?.fieldWording !== undefined) {
529
- applyFieldWording(options.fieldWording, errors, shapeErrorFields);
626
+ applyFieldWording(options.fieldWording, errors, shapeErrorFields, requiredFields);
530
627
  }
531
628
  // Hand back an ordinary object; its entries are defined as own keys, so a path such as
532
629
  // `__proto__` stays an entry of the result.