@tai42/studio-sdk 6.2.0 → 7.1.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/field.d.ts +14 -1
- package/dist/components/field.d.ts.map +1 -1
- package/dist/components/field.js +5 -2
- package/dist/components/field.js.map +1 -1
- package/dist/expression/ExpressionField.d.ts +140 -0
- package/dist/expression/ExpressionField.d.ts.map +1 -0
- package/dist/expression/ExpressionField.js +88 -0
- package/dist/expression/ExpressionField.js.map +1 -0
- package/dist/expression/context.d.ts +50 -0
- package/dist/expression/context.d.ts.map +1 -0
- package/dist/expression/context.js +53 -0
- package/dist/expression/context.js.map +1 -0
- package/dist/expression/types.d.ts +157 -0
- package/dist/expression/types.d.ts.map +1 -0
- package/dist/expression/types.js +11 -0
- package/dist/expression/types.js.map +1 -0
- package/dist/hooks/useApi.d.ts +8 -0
- package/dist/hooks/useApi.d.ts.map +1 -1
- package/dist/index.d.ts +6 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -0
- package/dist/index.js.map +1 -1
- package/dist/plugin/registry.d.ts.map +1 -1
- package/dist/plugin/registry.js +39 -1
- package/dist/plugin/registry.js.map +1 -1
- package/dist/plugin/types.d.ts +31 -0
- package/dist/plugin/types.d.ts.map +1 -1
- package/dist/schema-editor/SchemaEditor.d.ts.map +1 -1
- package/dist/schema-editor/SchemaEditor.js +8 -4
- package/dist/schema-editor/SchemaEditor.js.map +1 -1
- package/dist/schema-form/classify.d.ts +5 -3
- package/dist/schema-form/classify.d.ts.map +1 -1
- package/dist/schema-form/classify.js +42 -16
- package/dist/schema-form/classify.js.map +1 -1
- package/dist/schema-form/default-value.d.ts.map +1 -1
- package/dist/schema-form/default-value.js +4 -1
- package/dist/schema-form/default-value.js.map +1 -1
- package/dist/schema-form/field-node.d.ts.map +1 -1
- package/dist/schema-form/field-node.js +3 -3
- package/dist/schema-form/field-node.js.map +1 -1
- package/dist/schema-form/json-field.d.ts +14 -0
- package/dist/schema-form/json-field.d.ts.map +1 -0
- package/dist/schema-form/json-field.js +104 -0
- package/dist/schema-form/json-field.js.map +1 -0
- package/dist/schema-form/types.d.ts +2 -2
- package/dist/schema-form/types.js +2 -2
- package/dist/schema-form/validate.d.ts.map +1 -1
- package/dist/schema-form/validate.js +11 -4
- package/dist/schema-form/validate.js.map +1 -1
- package/package.json +2 -2
- package/src/components/field.tsx +20 -2
- package/src/expression/ExpressionField.tsx +330 -0
- package/src/expression/context.tsx +83 -0
- package/src/expression/types.ts +164 -0
- package/src/index.ts +22 -0
- package/src/plugin/registry.ts +50 -1
- package/src/plugin/types.ts +32 -0
- package/src/schema-editor/SchemaEditor.tsx +8 -4
- package/src/schema-form/classify.ts +103 -18
- package/src/schema-form/default-value.ts +4 -1
- package/src/schema-form/field-node.tsx +7 -4
- package/src/schema-form/json-field.tsx +143 -0
- package/src/schema-form/types.ts +2 -2
- package/src/schema-form/validate.ts +10 -4
- package/dist/schema-form/unsupported-notice.d.ts +0 -18
- package/dist/schema-form/unsupported-notice.d.ts.map +0 -1
- package/dist/schema-form/unsupported-notice.js +0 -8
- package/dist/schema-form/unsupported-notice.js.map +0 -1
- package/src/schema-form/unsupported-notice.tsx +0 -39
package/src/plugin/registry.ts
CHANGED
|
@@ -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
|
}
|
package/src/plugin/types.ts
CHANGED
|
@@ -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
|
|
61
|
-
* concrete field kind rather than
|
|
62
|
-
*
|
|
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 !== '
|
|
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
|
|
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
|
-
| {
|
|
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
|
-
|
|
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: '
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
293
|
-
// to build an entry editor from, so it
|
|
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
|
|
383
|
+
return jsonFallback(
|
|
308
384
|
resolved,
|
|
309
|
-
'
|
|
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
|
|
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 '
|
|
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 '
|
|
51
|
+
case 'json':
|
|
52
52
|
return (
|
|
53
|
-
<
|
|
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
|
+
}
|
package/src/schema-form/types.ts
CHANGED
|
@@ -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
|
|
7
|
-
*
|
|
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
|
|
5
|
-
*
|
|
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 '
|
|
79
|
-
|
|
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"}
|