@tai42/studio-sdk 6.1.1 → 7.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 (84) hide show
  1. package/dist/components/completion-input.d.ts.map +1 -1
  2. package/dist/components/completion-input.js +19 -2
  3. package/dist/components/completion-input.js.map +1 -1
  4. package/dist/components/components.css +4 -3
  5. package/dist/components/explorer-view.d.ts.map +1 -1
  6. package/dist/components/explorer-view.js +11 -22
  7. package/dist/components/explorer-view.js.map +1 -1
  8. package/dist/components/field.d.ts +14 -1
  9. package/dist/components/field.d.ts.map +1 -1
  10. package/dist/components/field.js +5 -2
  11. package/dist/components/field.js.map +1 -1
  12. package/dist/components/open-target.d.ts +68 -0
  13. package/dist/components/open-target.d.ts.map +1 -0
  14. package/dist/components/open-target.js +48 -0
  15. package/dist/components/open-target.js.map +1 -0
  16. package/dist/expression/ExpressionField.d.ts +140 -0
  17. package/dist/expression/ExpressionField.d.ts.map +1 -0
  18. package/dist/expression/ExpressionField.js +88 -0
  19. package/dist/expression/ExpressionField.js.map +1 -0
  20. package/dist/expression/context.d.ts +50 -0
  21. package/dist/expression/context.d.ts.map +1 -0
  22. package/dist/expression/context.js +53 -0
  23. package/dist/expression/context.js.map +1 -0
  24. package/dist/expression/types.d.ts +157 -0
  25. package/dist/expression/types.d.ts.map +1 -0
  26. package/dist/expression/types.js +11 -0
  27. package/dist/expression/types.js.map +1 -0
  28. package/dist/hooks/useApi.d.ts +8 -0
  29. package/dist/hooks/useApi.d.ts.map +1 -1
  30. package/dist/index.d.ts +8 -0
  31. package/dist/index.d.ts.map +1 -1
  32. package/dist/index.js +7 -0
  33. package/dist/index.js.map +1 -1
  34. package/dist/plugin/registry.d.ts.map +1 -1
  35. package/dist/plugin/registry.js +39 -1
  36. package/dist/plugin/registry.js.map +1 -1
  37. package/dist/plugin/types.d.ts +31 -0
  38. package/dist/plugin/types.d.ts.map +1 -1
  39. package/dist/schema-editor/SchemaEditor.d.ts.map +1 -1
  40. package/dist/schema-editor/SchemaEditor.js +8 -4
  41. package/dist/schema-editor/SchemaEditor.js.map +1 -1
  42. package/dist/schema-form/classify.d.ts +5 -3
  43. package/dist/schema-form/classify.d.ts.map +1 -1
  44. package/dist/schema-form/classify.js +42 -16
  45. package/dist/schema-form/classify.js.map +1 -1
  46. package/dist/schema-form/default-value.d.ts.map +1 -1
  47. package/dist/schema-form/default-value.js +4 -1
  48. package/dist/schema-form/default-value.js.map +1 -1
  49. package/dist/schema-form/field-node.d.ts.map +1 -1
  50. package/dist/schema-form/field-node.js +3 -3
  51. package/dist/schema-form/field-node.js.map +1 -1
  52. package/dist/schema-form/json-field.d.ts +14 -0
  53. package/dist/schema-form/json-field.d.ts.map +1 -0
  54. package/dist/schema-form/json-field.js +104 -0
  55. package/dist/schema-form/json-field.js.map +1 -0
  56. package/dist/schema-form/types.d.ts +2 -2
  57. package/dist/schema-form/types.js +2 -2
  58. package/dist/schema-form/validate.d.ts.map +1 -1
  59. package/dist/schema-form/validate.js +11 -4
  60. package/dist/schema-form/validate.js.map +1 -1
  61. package/package.json +2 -2
  62. package/src/components/completion-input.tsx +19 -2
  63. package/src/components/components.css +4 -3
  64. package/src/components/explorer-view.tsx +16 -27
  65. package/src/components/field.tsx +20 -2
  66. package/src/components/open-target.ts +121 -0
  67. package/src/expression/ExpressionField.tsx +330 -0
  68. package/src/expression/context.tsx +83 -0
  69. package/src/expression/types.ts +164 -0
  70. package/src/index.ts +26 -0
  71. package/src/plugin/registry.ts +50 -1
  72. package/src/plugin/types.ts +32 -0
  73. package/src/schema-editor/SchemaEditor.tsx +8 -4
  74. package/src/schema-form/classify.ts +103 -18
  75. package/src/schema-form/default-value.ts +4 -1
  76. package/src/schema-form/field-node.tsx +7 -4
  77. package/src/schema-form/json-field.tsx +143 -0
  78. package/src/schema-form/types.ts +2 -2
  79. package/src/schema-form/validate.ts +10 -4
  80. package/dist/schema-form/unsupported-notice.d.ts +0 -18
  81. package/dist/schema-form/unsupported-notice.d.ts.map +0 -1
  82. package/dist/schema-form/unsupported-notice.js +0 -8
  83. package/dist/schema-form/unsupported-notice.js.map +0 -1
  84. package/src/schema-form/unsupported-notice.tsx +0 -39
@@ -18,11 +18,21 @@ import type {
18
18
  SettingsTabContribution,
19
19
  ToolPanelContribution,
20
20
  } from './types';
21
+ import {
22
+ EXPRESSION_EDITOR_CONTRACT_VERSION,
23
+ type ExpressionEditorContribution,
24
+ } from '../expression/types';
21
25
 
22
26
  const toolPanels = new Map<string, ToolPanelContribution>();
23
27
  const pages: RegisteredPage[] = [];
24
28
  const settingsTabs: RegisteredSettingsTab[] = [];
25
29
  const navEntries: RegisteredNavEntry[] = [];
30
+ // `let`, never mutated in place: {@link ExpressionEditorsProvider} memoizes its
31
+ // context value on this map's IDENTITY, so committing an editor must turn the
32
+ // identity over — otherwise a field mounted on a core route (which never waits
33
+ // for the plugin load pass) would keep the stale empty map and never grow its
34
+ // launcher. The commit path below reassigns; nothing calls `.set` on it.
35
+ let expressionEditors = new Map<string, ExpressionEditorContribution>();
26
36
 
27
37
  /**
28
38
  * Load one plugin: call its `register` entry with a context bound to `pluginId`,
@@ -50,6 +60,7 @@ export async function loadPlugin(pluginId: string, entry: PluginEntry): Promise<
50
60
  const stagedSettingsTabs: RegisteredSettingsTab[] = [];
51
61
  const stagedTabIds = new Set<string>();
52
62
  const stagedNavEntries: RegisteredNavEntry[] = [];
63
+ const stagedExpressionEditors = new Map<string, ExpressionEditorContribution>();
53
64
  let sealed = false;
54
65
 
55
66
  const context: PluginContext = {
@@ -117,6 +128,38 @@ export async function loadPlugin(pluginId: string, entry: PluginEntry): Promise<
117
128
  }
118
129
  stagedNavEntries.push({ ...contribution, pluginId });
119
130
  },
131
+ registerExpressionEditor(contribution: ExpressionEditorContribution): void {
132
+ if (sealed) {
133
+ throw new Error(
134
+ 'registration is closed: a plugin must register during register(), not after it resolves',
135
+ );
136
+ }
137
+ // Contract-version mismatch is a LOUD per-plugin registration error, never a
138
+ // silent skip: an editor built against a different props contract would mount
139
+ // with the wrong props, so it is rejected during register() and becomes this
140
+ // plugin's error card. Checked before the duplicate guard so a mismatched
141
+ // contribution never claims a language.
142
+ if (contribution.contractVersion !== EXPRESSION_EDITOR_CONTRACT_VERSION) {
143
+ throw new Error(
144
+ `plugin “${pluginId}” registered an expression editor for “${contribution.language}” ` +
145
+ `targeting contract v${String(contribution.contractVersion)} but this host is ` +
146
+ `v${String(EXPRESSION_EDITOR_CONTRACT_VERSION)} — the editor must be rebuilt`,
147
+ );
148
+ }
149
+ // A language is a GLOBAL key: at most one editor across the deployment. A
150
+ // second contribution for a language already staged or committed (by this
151
+ // plugin or another) would leave one editor unreachable — fail loudly, as
152
+ // registerToolPanel does for a tool name.
153
+ if (
154
+ stagedExpressionEditors.has(contribution.language) ||
155
+ expressionEditors.has(contribution.language)
156
+ ) {
157
+ throw new Error(
158
+ `an expression editor is already registered for language ${contribution.language}`,
159
+ );
160
+ }
161
+ stagedExpressionEditors.set(contribution.language, contribution);
162
+ },
120
163
  };
121
164
 
122
165
  try {
@@ -158,11 +201,16 @@ export async function loadPlugin(pluginId: string, entry: PluginEntry): Promise<
158
201
  for (const navEntry of stagedNavEntries) {
159
202
  navEntries.push(navEntry);
160
203
  }
204
+ if (stagedExpressionEditors.size > 0) {
205
+ // Reassign (see the declaration): identity turnover is what re-notifies
206
+ // mounted ExpressionFields when the load pass commits an editor.
207
+ expressionEditors = new Map([...expressionEditors, ...stagedExpressionEditors]);
208
+ }
161
209
  }
162
210
 
163
211
  /** The shell reads this after loading every installed plugin bundle. */
164
212
  export function getContributions(): PluginContributions {
165
- return { toolPanels, pages, settingsTabs, navEntries };
213
+ return { toolPanels, pages, settingsTabs, navEntries, expressionEditors };
166
214
  }
167
215
 
168
216
  /** Tests reset the module-level registry between cases. */
@@ -171,4 +219,5 @@ export function __resetContributions(): void {
171
219
  pages.length = 0;
172
220
  settingsTabs.length = 0;
173
221
  navEntries.length = 0;
222
+ expressionEditors = new Map();
174
223
  }
@@ -22,6 +22,8 @@
22
22
  */
23
23
  import type { ComponentType } from 'react';
24
24
 
25
+ import type { ExpressionEditorContribution } from '../expression/types';
26
+
25
27
  /**
26
28
  * A capability requirement a contribution may declare. `routes` is a list of
27
29
  * route-path prefixes (anyOf semantics, the same evaluator the shell nav uses):
@@ -193,6 +195,19 @@ export interface PluginContributions {
193
195
  readonly pages: readonly RegisteredPage[];
194
196
  readonly settingsTabs: readonly RegisteredSettingsTab[];
195
197
  readonly navEntries: readonly RegisteredNavEntry[];
198
+ /**
199
+ * The contributed expression editors, keyed by {@link ExpressionLanguage}. A
200
+ * language maps to at MOST one editor across the whole deployment — the registry
201
+ * rejects a second contribution for a language it already holds — so a field
202
+ * resolves its editor by language with no ambiguity. The shell reads this map and
203
+ * feeds it to `ExpressionEditorsProvider`; the plugin surface reaches it only
204
+ * through `useExpressionEditor`, never by enumerating this map.
205
+ *
206
+ * Optional in the TYPE (this host always provides it): a contributions object
207
+ * built against the pre-expression shape — an older host, a test double —
208
+ * remains a valid `PluginContributions`. Readers fall back to an empty map.
209
+ */
210
+ readonly expressionEditors?: ReadonlyMap<string, ExpressionEditorContribution>;
196
211
  }
197
212
 
198
213
  /**
@@ -218,6 +233,23 @@ export interface PluginContext {
218
233
  * registers (a nav entry with no page is a dead link and is rejected loudly).
219
234
  */
220
235
  registerNavEntry(contribution: NavEntryContribution): void;
236
+ /**
237
+ * Register the visual editor for one {@link ExpressionLanguage}, which every
238
+ * `ExpressionField` authoring that language then offers. A language is a GLOBAL
239
+ * key — at most one editor across the deployment — so a second contribution for a
240
+ * language already registered (by this plugin or another) is rejected loudly, as
241
+ * a duplicate tool panel is. A contribution whose `contractVersion` is not the
242
+ * host's {@link EXPRESSION_EDITOR_CONTRACT_VERSION} is likewise rejected loudly
243
+ * during registration — never silently skipped — so an editor built against an
244
+ * incompatible props contract is a visible per-plugin error, not a blank field.
245
+ *
246
+ * This method is OPTIONAL/ADDITIVE — optional in the TYPE too, so a plugin
247
+ * written against this SDK still typechecks against an older host context and
248
+ * a test double of the pre-expression context stays a valid `PluginContext`.
249
+ * Plugins feature-detect (`typeof context.registerExpressionEditor ===
250
+ * 'function'`) and no-op gracefully on hosts that predate the extension point.
251
+ */
252
+ registerExpressionEditor?(contribution: ExpressionEditorContribution): void;
221
253
  }
222
254
 
223
255
  /**
@@ -57,13 +57,17 @@ function seedText(value: Record<string, unknown> | null): string {
57
57
  }
58
58
 
59
59
  /**
60
- * Whether {@link SchemaForm} can render this schema's root — i.e. it classifies to a
61
- * concrete field kind rather than `unsupported`. A schema whose root the form cannot
62
- * render (or whose classification throws on a bad `$ref`) previews as a `JsonTree`.
60
+ * Whether {@link SchemaForm} can render this schema's root as a STRUCTURED form —
61
+ * i.e. it classifies to a concrete field kind rather than the free-form `json`
62
+ * fallback. A schema whose root has no structure to preview (a property-less
63
+ * object, an open/`allOf`/multi-type shape), or whose classification throws on a
64
+ * bad `$ref`, previews as a `JsonTree` of the schema dict instead. (Inside a live
65
+ * form such a node still renders the JSON editor; here, previewing a shapeless
66
+ * root as its own schema tree is the more legible affordance.)
63
67
  */
64
68
  function canRenderWithForm(schema: JsonSchema): boolean {
65
69
  try {
66
- return classifySchema(schema, schema).model.kind !== 'unsupported';
70
+ return classifySchema(schema, schema).model.kind !== 'json';
67
71
  } catch {
68
72
  return false;
69
73
  }
@@ -3,8 +3,9 @@
3
3
  * renderer and the validator classify through here so a construct is treated
4
4
  * identically everywhere. Classification resolves `$ref`, normalizes `anyOf`/
5
5
  * `oneOf` (null-union → nullable single field; multi-member → variant selector),
6
- * and lands every unrecognized construct on the explicit `unsupported` kind —
7
- * a field is never silently dropped.
6
+ * and lands every shape it has no structured editor for on the explicit `json`
7
+ * kind (a free-form JSON textarea) — a field is never silently dropped, and a
8
+ * shapeless one is edited as the JSON it is rather than dead-ending on a badge.
8
9
  */
9
10
  import {
10
11
  isNullSchema,
@@ -79,7 +80,18 @@ export type FieldModel =
79
80
  readonly variants: readonly UnionVariant[];
80
81
  readonly discriminator: string | undefined;
81
82
  }
82
- | { readonly kind: 'unsupported'; readonly reason: string };
83
+ | {
84
+ // A shape with no structured editor to build — a property-less object, a
85
+ // bare `additionalProperties`-open object, an items-less array, an empty/
86
+ // multi-type/`allOf` schema. It is NOT undroppable: every one of these still
87
+ // describes JSON, so the renderer edits it as free-form JSON in a mono
88
+ // textarea rather than dead-ending on a badge. `jsonType` is the container
89
+ // the schema commits to, so a valid buffer must parse to it (`'any'` = no
90
+ // constraint beyond "is JSON"); `reason` records which shape fell through.
91
+ readonly kind: 'json';
92
+ readonly jsonType: 'object' | 'array' | 'any';
93
+ readonly reason: string;
94
+ };
83
95
 
84
96
  /** A fully classified field: its model plus resolved metadata. */
85
97
  export interface ClassifiedField {
@@ -120,13 +132,22 @@ function mediaUpload(schema: JsonSchema): MediaUpload | undefined {
120
132
  return undefined;
121
133
  }
122
134
 
123
- function unsupported(
135
+ /**
136
+ * Land a shape the form has no structured editor for on the free-form JSON
137
+ * fallback (a mono JSON textarea) rather than a dead badge — a field is never
138
+ * silently dropped, and "any JSON" is the honest affordance for a shapeless one.
139
+ * `jsonType` is the container a valid buffer must parse to; `nullable` carries
140
+ * through so a nullable shape still accepts JSON `null`.
141
+ */
142
+ function jsonFallback(
124
143
  schema: JsonSchema,
144
+ jsonType: 'object' | 'array' | 'any',
125
145
  reason: string,
126
146
  title: string | undefined,
127
147
  description: string | undefined,
148
+ nullable = false,
128
149
  ): ClassifiedField {
129
- return { model: { kind: 'unsupported', reason }, nullable: false, schema, title, description };
150
+ return { model: { kind: 'json', jsonType, reason }, nullable, schema, title, description };
130
151
  }
131
152
 
132
153
  /**
@@ -139,11 +160,14 @@ export function classifySchema(raw: JsonSchema, root: JsonSchema): ClassifiedFie
139
160
  const title = firstString(raw.title, resolved.title);
140
161
  const description = firstString(raw.description, resolved.description);
141
162
 
142
- // const pins a single value.
163
+ // const pins a single value. Null-acceptance is fully decidable here: the one
164
+ // permitted value either IS null or it is not. Without this, a `const: null`
165
+ // field would seed to null and then fail the validator's upstream "must not be
166
+ // null" gate before the equality check ever ran — permanently invalid.
143
167
  if ('const' in resolved) {
144
168
  return {
145
169
  model: { kind: 'const', value: resolved.const },
146
- nullable: false,
170
+ nullable: resolved.const === null,
147
171
  schema: resolved,
148
172
  title,
149
173
  description,
@@ -167,7 +191,16 @@ export function classifySchema(raw: JsonSchema, root: JsonSchema): ClassifiedFie
167
191
  const nullable = nonNull.length !== members.length;
168
192
  const soleNonNull = nonNull[0];
169
193
  if (soleNonNull === undefined) {
170
- return unsupported(resolved, 'union with no non-null members', title, description);
194
+ // Every member is `null` — degenerate, but `null` is still valid, so edit
195
+ // it as JSON (nullable) rather than dead-ending on a badge.
196
+ return jsonFallback(
197
+ resolved,
198
+ 'any',
199
+ 'union with no non-null members',
200
+ title,
201
+ description,
202
+ true,
203
+ );
171
204
  }
172
205
  if (nonNull.length === 1) {
173
206
  const inner = classifySchema(soleNonNull, root);
@@ -216,7 +249,19 @@ export function classifySchema(raw: JsonSchema, root: JsonSchema): ClassifiedFie
216
249
  description: description ?? inner.description,
217
250
  };
218
251
  }
219
- return unsupported(resolved, 'allOf intersection of multiple schemas', title, description);
252
+ // Whether the intersection accepts `null` is not statically decidable here
253
+ // (it does iff EVERY member does), and the json escape hatch only enforces
254
+ // what it KNOWS — so stay permissive like `jsonType: 'any'` itself and let
255
+ // the server-side contract be the authority, rather than rejecting a null
256
+ // the schema may well permit (e.g. an intersection of open schemas).
257
+ return jsonFallback(
258
+ resolved,
259
+ 'any',
260
+ 'allOf intersection of multiple schemas',
261
+ title,
262
+ description,
263
+ true,
264
+ );
220
265
  }
221
266
 
222
267
  // Type-driven classification.
@@ -225,20 +270,41 @@ export function classifySchema(raw: JsonSchema, root: JsonSchema): ClassifiedFie
225
270
  const nullableByType = nonNullTypes.length !== types.length;
226
271
 
227
272
  if (nonNullTypes.length === 0) {
228
- return unsupported(resolved, 'schema declares no renderable type', title, description);
273
+ // No `type` at all (an open `{}` schema, or `type: "null"` alone) accepts any
274
+ // JSON — the classic "any" field. Edit it as free-form JSON. Nullable is
275
+ // always true here: `type: "null"` declares it, and an open schema with no
276
+ // type keyword permits null like any other value (`nullableByType` would say
277
+ // false for it only because there is no type list to find "null" in).
278
+ return jsonFallback(
279
+ resolved,
280
+ 'any',
281
+ 'schema declares no renderable type',
282
+ title,
283
+ description,
284
+ true,
285
+ );
229
286
  }
230
287
  if (nonNullTypes.length > 1) {
231
- return unsupported(
288
+ return jsonFallback(
232
289
  resolved,
290
+ 'any',
233
291
  `multiple JSON types (${nonNullTypes.join(', ')}) without a discriminator`,
234
292
  title,
235
293
  description,
294
+ nullableByType,
236
295
  );
237
296
  }
238
297
 
239
298
  const type = nonNullTypes[0];
240
299
  if (type === undefined) {
241
- return unsupported(resolved, 'schema declares no renderable type', title, description);
300
+ return jsonFallback(
301
+ resolved,
302
+ 'any',
303
+ 'schema declares no renderable type',
304
+ title,
305
+ description,
306
+ nullableByType,
307
+ );
242
308
  }
243
309
  switch (type) {
244
310
  case 'string':
@@ -275,7 +341,16 @@ export function classifySchema(raw: JsonSchema, root: JsonSchema): ClassifiedFie
275
341
  };
276
342
  case 'array': {
277
343
  if (resolved.items === undefined) {
278
- return unsupported(resolved, 'array without an items schema', title, description);
344
+ // A bare array with no item schema: no per-item editor to build, but the
345
+ // value is still a JSON array — edit it whole as free-form JSON.
346
+ return jsonFallback(
347
+ resolved,
348
+ 'array',
349
+ 'array without an items schema',
350
+ title,
351
+ description,
352
+ nullableByType,
353
+ );
279
354
  }
280
355
  return {
281
356
  model: { kind: 'array', items: resolved.items },
@@ -289,8 +364,9 @@ export function classifySchema(raw: JsonSchema, root: JsonSchema): ClassifiedFie
289
364
  if (resolved.properties === undefined) {
290
365
  // A free-form object typed by a VALUE SCHEMA (`additionalProperties` is a
291
366
  // schema, not `false`/`true`/absent) is a string→X map — renderable as
292
- // key/value rows. A boolean `additionalProperties` carries no value type
293
- // to build an entry editor from, so it stays unsupported.
367
+ // key/value rows. A boolean/absent `additionalProperties` carries no value
368
+ // type to build an entry editor from, so it falls through to the free-form
369
+ // JSON editor (constrained to a JSON object).
294
370
  // Read as `unknown`: the value is parsed JSON, where `additionalProperties:
295
371
  // null` is possible and `typeof null === 'object'` would otherwise classify
296
372
  // it as a record with a `null` value schema.
@@ -304,11 +380,13 @@ export function classifySchema(raw: JsonSchema, root: JsonSchema): ClassifiedFie
304
380
  description,
305
381
  };
306
382
  }
307
- return unsupported(
383
+ return jsonFallback(
308
384
  resolved,
309
- 'free-form object (additionalProperties map) is not supported',
385
+ 'object',
386
+ 'free-form object with no property schema',
310
387
  title,
311
388
  description,
389
+ nullableByType,
312
390
  );
313
391
  }
314
392
  const properties: (readonly [string, JsonSchema])[] = Object.entries(resolved.properties);
@@ -322,6 +400,13 @@ export function classifySchema(raw: JsonSchema, root: JsonSchema): ClassifiedFie
322
400
  };
323
401
  }
324
402
  default:
325
- return unsupported(resolved, `unrecognized type "${String(type)}"`, title, description);
403
+ return jsonFallback(
404
+ resolved,
405
+ 'any',
406
+ `unrecognized type "${String(type)}"`,
407
+ title,
408
+ description,
409
+ nullableByType,
410
+ );
326
411
  }
327
412
  }
@@ -61,7 +61,10 @@ export function defaultValueForSchema(schema: JsonSchema, root: JsonSchema = sch
61
61
  case 'number':
62
62
  case 'boolean':
63
63
  case 'union':
64
- case 'unsupported':
64
+ case 'json':
65
+ // A free-form JSON field starts ABSENT (its editor seeds an empty buffer),
66
+ // like the other value-less kinds: an optional one never invents a value,
67
+ // and a required one is left absent so validation flags it.
65
68
  return undefined;
66
69
  }
67
70
  }
@@ -13,12 +13,12 @@ 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 { JsonField } from './json-field';
16
17
  import { ObjectFields } from './object-fields';
17
18
  import { RecordField } from './record-field';
18
19
  import { scalarLabel } from './resolve';
19
20
  import { StringField } from './string-field';
20
21
  import { UnionField } from './union-field';
21
- import { UnsupportedNotice } from './unsupported-notice';
22
22
  import type { JsonSchema, SchemaFormErrors } from './types';
23
23
 
24
24
  interface FieldNodeProps {
@@ -48,13 +48,16 @@ export function FieldNode(props: FieldNodeProps): ReactNode {
48
48
  const model = classified.model;
49
49
 
50
50
  switch (model.kind) {
51
- case 'unsupported':
51
+ case 'json':
52
52
  return (
53
- <UnsupportedNotice
53
+ <JsonField
54
54
  heading={heading}
55
- reason={model.reason}
56
55
  description={description}
57
56
  error={error}
57
+ jsonType={model.jsonType}
58
+ nullable={classified.nullable}
59
+ value={value}
60
+ onChange={onChange}
58
61
  />
59
62
  );
60
63
  case 'const':
@@ -0,0 +1,143 @@
1
+ /**
2
+ * The free-form JSON field: the honest editor for a schema node the form has no
3
+ * STRUCTURED control for — a property-less object, a bare `additionalProperties`-
4
+ * open object, an items-less array, an empty/multi-type/`allOf` shape. Rather than
5
+ * dead-end on an "Unsupported" badge (a value the user can see but not set), it
6
+ * offers a mono textarea seeded with the current value pretty-printed and commits
7
+ * the PARSED value through the same `onChange` a structured field uses.
8
+ *
9
+ * Validation is LOUD and local: the buffer must parse as JSON, and — when the
10
+ * schema commits to a container (`jsonType` `'object'`/`'array'`) — the parsed
11
+ * value must be that container. An invalid buffer shows an inline error and does
12
+ * NOT commit, so a half-typed edit never writes a broken value through; the last
13
+ * committed value stands until the buffer is valid again.
14
+ *
15
+ * The buffer is LOCAL state (an invalid buffer has no committed value to derive
16
+ * from), resynced only when a DIFFERENT value arrives from the parent — the same
17
+ * `lastEmitted` guard `record-field` uses, so a parent echoing our own commit back
18
+ * does not clobber what is being typed.
19
+ */
20
+ import { useRef, useState } from 'react';
21
+ import type { ReactNode } from 'react';
22
+
23
+ import { Field } from '../components/field';
24
+ import { Textarea } from '../components/inputs';
25
+ import { errorMessage } from '../errors';
26
+
27
+ /** The JSON container a `json` field commits to (see `classify`). */
28
+ type JsonType = 'object' | 'array' | 'any';
29
+
30
+ function isPlainObject(value: unknown): value is Record<string, unknown> {
31
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
32
+ }
33
+
34
+ /** The current value as textarea text: pretty-printed JSON, or empty for absent. */
35
+ function serialize(value: unknown): string {
36
+ return value === undefined ? '' : JSON.stringify(value, null, 2);
37
+ }
38
+
39
+ /**
40
+ * The container constraint on a PARSED buffer, or `null` when it satisfies the
41
+ * schema. `null` (the JSON literal) is rejected only when the schema is not
42
+ * nullable — matching `validateAgainstSchema`, so the inline check and the
43
+ * pre-submit check agree.
44
+ */
45
+ function containerError(value: unknown, jsonType: JsonType, nullable: boolean): string | null {
46
+ if (value === null) return nullable ? null : 'Must not be null.';
47
+ if (jsonType === 'object' && !isPlainObject(value)) {
48
+ return 'Must be a JSON object (e.g. {"key": "value"}).';
49
+ }
50
+ if (jsonType === 'array' && !Array.isArray(value)) {
51
+ return 'Must be a JSON array (e.g. [1, 2, 3]).';
52
+ }
53
+ return null;
54
+ }
55
+
56
+ /** The muted hint's leading noun, so the user knows what a valid buffer must be. */
57
+ function hintNoun(jsonType: JsonType): string {
58
+ if (jsonType === 'object') return 'JSON object';
59
+ if (jsonType === 'array') return 'JSON array';
60
+ return 'JSON';
61
+ }
62
+
63
+ export function JsonField({
64
+ heading,
65
+ description,
66
+ error,
67
+ jsonType,
68
+ nullable,
69
+ value,
70
+ onChange,
71
+ }: {
72
+ heading: string;
73
+ description: string | undefined;
74
+ error: string | undefined;
75
+ jsonType: JsonType;
76
+ nullable: boolean;
77
+ value: unknown;
78
+ onChange: (value: unknown) => void;
79
+ }): ReactNode {
80
+ // The last value THIS field emitted. A parent echoing our own commit back
81
+ // passes it identity-equal, so the buffer (which may hold an invalid, un-
82
+ // committed edit) survives; a parent that swaps in a different value resyncs it.
83
+ const lastEmitted = useRef<unknown>(value);
84
+ const [buffer, setBuffer] = useState<string>(() => serialize(value));
85
+ const [parseError, setParseError] = useState<string | null>(null);
86
+ if (value !== lastEmitted.current) {
87
+ lastEmitted.current = value;
88
+ setBuffer(serialize(value));
89
+ setParseError(null);
90
+ }
91
+
92
+ const commit = (next: unknown): void => {
93
+ lastEmitted.current = next;
94
+ onChange(next);
95
+ };
96
+
97
+ const handleChange = (text: string): void => {
98
+ setBuffer(text);
99
+ // An empty buffer drops the value (emits `undefined`) — the same "absent
100
+ // optional" affordance a cleared text field gives; a required field's
101
+ // absence is then flagged by the pre-submit validator, not invented here.
102
+ if (text.trim() === '') {
103
+ setParseError(null);
104
+ commit(undefined);
105
+ return;
106
+ }
107
+ let parsed: unknown;
108
+ try {
109
+ parsed = JSON.parse(text);
110
+ } catch (thrown) {
111
+ // Invalid JSON: surface it, and DO NOT commit — the last valid value stands.
112
+ setParseError(`Invalid JSON: ${errorMessage(thrown)}`);
113
+ return;
114
+ }
115
+ const container = containerError(parsed, jsonType, nullable);
116
+ if (container !== null) {
117
+ setParseError(container);
118
+ return;
119
+ }
120
+ setParseError(null);
121
+ commit(parsed);
122
+ };
123
+
124
+ return (
125
+ <Field label={heading} description={description} error={parseError ?? error}>
126
+ <Textarea
127
+ value={buffer}
128
+ onChange={(event) => {
129
+ handleChange(event.target.value);
130
+ }}
131
+ rows={6}
132
+ aria-label={`${heading} JSON`}
133
+ className="tai-textarea-mono"
134
+ spellCheck={false}
135
+ />
136
+ {/* A muted hint, never an error: the field is USABLE, it just has no
137
+ structured schema to build a richer control from. */}
138
+ <p className="tai-field-hint" style={{ margin: 0 }}>
139
+ {`free-form ${hintNoun(jsonType)} — this field has no structured schema`}
140
+ </p>
141
+ </Field>
142
+ );
143
+ }
@@ -3,8 +3,8 @@
3
3
  * is deliberately PERMISSIVE — every field is optional and an open index
4
4
  * signature admits unknown keywords — because the schemas come from Pydantic v2
5
5
  * and may carry constructs the renderer does not consume. Unknown keys never
6
- * break typing; the renderer classifies a node and surfaces anything it cannot
7
- * render as a LOUD "unsupported field" notice rather than dropping it.
6
+ * break typing; the renderer classifies a node and, for a shape it has no
7
+ * structured control for, offers a free-form JSON editor rather than dropping it.
8
8
  */
9
9
 
10
10
  /** The JSON-Schema `type` keyword values Pydantic emits. */
@@ -1,8 +1,8 @@
1
1
  /**
2
2
  * `validateAgainstSchema` — a loud, structured pre-submit check. It walks the
3
3
  * schema against a value and returns a path-keyed `SchemaFormErrors` bag:
4
- * required-presence, type/format mismatches, enum/const violations, and any
5
- * `unsupported` construct (surfaced, never hidden). It never throws on a
4
+ * required-presence, type/format mismatches, enum/const violations, and the
5
+ * container constraint on a free-form `json` field. It never throws on a
6
6
  * malformed value — a malformed value IS the error it reports.
7
7
  */
8
8
  import { classifySchema } from './classify';
@@ -75,8 +75,14 @@ function walk(
75
75
 
76
76
  const { model } = classified;
77
77
  switch (model.kind) {
78
- case 'unsupported':
79
- errors[path] = `unsupported schema (${model.reason})`;
78
+ case 'json':
79
+ // A free-form JSON field: any JSON is accepted, subject only to the
80
+ // container the schema commits to (mirrors the renderer's inline check).
81
+ if (model.jsonType === 'object' && !isPlainObject(value)) {
82
+ errors[path] = 'must be a JSON object';
83
+ } else if (model.jsonType === 'array' && !Array.isArray(value)) {
84
+ errors[path] = 'must be a JSON array';
85
+ }
80
86
  return;
81
87
  case 'const':
82
88
  if (value !== model.value) errors[path] = `must equal ${scalarLabel(model.value)}`;
@@ -1,18 +0,0 @@
1
- /**
2
- * The notice rendered for a schema node the form cannot turn into an input: a
3
- * warning badge — a mark beside its label, so the meaning never rests on the
4
- * tint alone — followed by the reason, shown inside a labelled field.
5
- *
6
- * The three parts each say ONE thing once. The `Field` prints the heading, the
7
- * `Badge` prints the state, and the hint prints the REASON alone — repeating
8
- * either of the first two there would name the field twice and the state twice,
9
- * in a developer-log register on a surface a user reads.
10
- */
11
- import type { ReactNode } from 'react';
12
- export declare function UnsupportedNotice({ heading, reason, description, error, }: {
13
- heading: string;
14
- reason: string;
15
- description: string | undefined;
16
- error: string | undefined;
17
- }): ReactNode;
18
- //# sourceMappingURL=unsupported-notice.d.ts.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"unsupported-notice.d.ts","sourceRoot":"","sources":["../../src/schema-form/unsupported-notice.tsx"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AACH,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,OAAO,CAAC;AAMvC,wBAAgB,iBAAiB,CAAC,EAChC,OAAO,EACP,MAAM,EACN,WAAW,EACX,KAAK,GACN,EAAE;IACD,OAAO,EAAE,MAAM,CAAC;IAChB,MAAM,EAAE,MAAM,CAAC;IACf,WAAW,EAAE,MAAM,GAAG,SAAS,CAAC;IAChC,KAAK,EAAE,MAAM,GAAG,SAAS,CAAC;CAC3B,GAAG,SAAS,CAYZ"}
@@ -1,8 +0,0 @@
1
- import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { Badge } from '../components/badge';
3
- import { Field } from '../components/field';
4
- import { AlertTriangleIcon } from '../components/icons';
5
- export function UnsupportedNotice({ heading, reason, description, error, }) {
6
- return (_jsx(Field, { label: heading, description: description, error: error, group: true, children: _jsxs("div", { role: "alert", className: "tai-row", children: [_jsxs(Badge, { variant: "warning", children: [_jsx(AlertTriangleIcon, {}), "Unsupported"] }), _jsx("span", { className: "tai-field-hint", children: reason })] }) }));
7
- }
8
- //# sourceMappingURL=unsupported-notice.js.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"unsupported-notice.js","sourceRoot":"","sources":["../../src/schema-form/unsupported-notice.tsx"],"names":[],"mappings":";AAYA,OAAO,EAAE,KAAK,EAAE,MAAM,qBAAqB,CAAC;AAC5C,OAAO,EAAE,KAAK,EAAE,MAAM,qBAAqB,CAAC;AAC5C,OAAO,EAAE,iBAAiB,EAAE,MAAM,qBAAqB,CAAC;AAExD,MAAM,UAAU,iBAAiB,CAAC,EAChC,OAAO,EACP,MAAM,EACN,WAAW,EACX,KAAK,GAMN;IACC,OAAO,CACL,KAAC,KAAK,IAAC,KAAK,EAAE,OAAO,EAAE,WAAW,EAAE,WAAW,EAAE,KAAK,EAAE,KAAK,EAAE,KAAK,kBAClE,eAAK,IAAI,EAAC,OAAO,EAAC,SAAS,EAAC,SAAS,aACnC,MAAC,KAAK,IAAC,OAAO,EAAC,SAAS,aACtB,KAAC,iBAAiB,KAAG,mBAEf,EACR,eAAM,SAAS,EAAC,gBAAgB,YAAE,MAAM,GAAQ,IAC5C,GACA,CACT,CAAC;AACJ,CAAC"}