@assistant-ui/react-generative-ui 0.0.19 → 0.0.21
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/JSONGenerativeUI.client.d.ts +14 -15
- package/dist/JSONGenerativeUI.client.d.ts.map +1 -1
- package/dist/JSONGenerativeUI.client.js +28 -8
- package/dist/JSONGenerativeUI.client.js.map +1 -1
- package/dist/JSONGenerativeUI.server.d.ts +5 -7
- package/dist/JSONGenerativeUI.server.d.ts.map +1 -1
- package/dist/JSONGenerativeUI.shared.d.ts +35 -36
- package/dist/JSONGenerativeUI.shared.d.ts.map +1 -1
- package/dist/a2ui/convert.d.ts +7 -7
- package/dist/a2ui/convert.d.ts.map +1 -1
- package/dist/a2ui/convert.js +148 -28
- package/dist/a2ui/convert.js.map +1 -1
- package/dist/a2ui/reducer.d.ts +1 -3
- package/dist/a2ui/reducer.d.ts.map +1 -1
- package/dist/a2ui/reducer.js +8 -0
- package/dist/a2ui/reducer.js.map +1 -1
- package/dist/a2ui/snapshot.d.ts +3 -0
- package/dist/a2ui/snapshot.d.ts.map +1 -0
- package/dist/a2ui/snapshot.js +33 -0
- package/dist/a2ui/snapshot.js.map +1 -0
- package/dist/a2ui/types.d.ts +50 -40
- package/dist/a2ui/types.d.ts.map +1 -1
- package/dist/a2ui/types.js.map +1 -1
- package/dist/a2ui.d.ts +5 -4
- package/dist/a2ui.d.ts.map +1 -0
- package/dist/a2ui.js +2 -1
- package/dist/actionRegistry.d.ts +12 -14
- package/dist/actionRegistry.d.ts.map +1 -1
- package/dist/actionRegistry.js.map +1 -1
- package/dist/buildPresentParameters.d.ts +2 -4
- package/dist/buildPresentParameters.d.ts.map +1 -1
- package/dist/buildPresentParameters.js +16 -4
- package/dist/buildPresentParameters.js.map +1 -1
- package/dist/constants.d.ts +0 -2
- package/dist/constants.d.ts.map +1 -1
- package/dist/convert/boundSpec.d.ts +0 -2
- package/dist/convert/boundSpec.d.ts.map +1 -1
- package/dist/convert/copyBounded.d.ts +2 -4
- package/dist/convert/copyBounded.d.ts.map +1 -1
- package/dist/convert/isElement.d.ts +1 -3
- package/dist/convert/isElement.d.ts.map +1 -1
- package/dist/convert/takeRun.d.ts +3 -5
- package/dist/convert/takeRun.d.ts.map +1 -1
- package/dist/defineGenerativeComponents.d.ts +1 -3
- package/dist/defineGenerativeComponents.d.ts.map +1 -1
- package/dist/generativeUIToJSX.d.ts +4 -6
- package/dist/generativeUIToJSX.d.ts.map +1 -1
- package/dist/index.d.ts +3 -13
- package/dist/index.d.ts.map +1 -0
- package/dist/index.server.d.ts +3 -13
- package/dist/index.server.d.ts.map +1 -0
- package/dist/index.shared.d.ts +15 -11
- package/dist/index.shared.d.ts.map +1 -0
- package/dist/ir.d.ts +17 -19
- package/dist/ir.d.ts.map +1 -1
- package/dist/renderGenerativeUI.d.ts +2 -4
- package/dist/renderGenerativeUI.d.ts.map +1 -1
- package/dist/scopeSchema.d.ts +6 -0
- package/dist/scopeSchema.d.ts.map +1 -0
- package/dist/scopeSchema.js +56 -0
- package/dist/scopeSchema.js.map +1 -0
- package/dist/slack/constants.d.ts +8 -9
- package/dist/slack/constants.d.ts.map +1 -1
- package/dist/slack/decodeBlockAction.d.ts +1 -3
- package/dist/slack/decodeBlockAction.d.ts.map +1 -1
- package/dist/slack/fromSlackBlocks.d.ts +1 -3
- package/dist/slack/fromSlackBlocks.d.ts.map +1 -1
- package/dist/slack/toSlackBlocks.d.ts +1 -3
- package/dist/slack/toSlackBlocks.d.ts.map +1 -1
- package/dist/slack/toSlackBlocks.js +1 -1
- package/dist/slack/types.d.ts +78 -80
- package/dist/slack/types.d.ts.map +1 -1
- package/dist/slack.d.ts +5 -5
- package/dist/slack.d.ts.map +1 -0
- package/dist/teams/constants.d.ts +1 -3
- package/dist/teams/constants.d.ts.map +1 -1
- package/dist/teams/decodeSubmitData.d.ts +1 -3
- package/dist/teams/decodeSubmitData.d.ts.map +1 -1
- package/dist/teams/toAdaptiveCard.d.ts +4 -6
- package/dist/teams/toAdaptiveCard.d.ts.map +1 -1
- package/dist/teams/toTeamsAttachments.d.ts +1 -3
- package/dist/teams/toTeamsAttachments.d.ts.map +1 -1
- package/dist/teams/types.d.ts +108 -109
- package/dist/teams/types.d.ts.map +1 -1
- package/dist/teams.d.ts +5 -5
- package/dist/teams.d.ts.map +1 -0
- package/dist/types.d.ts +36 -37
- package/dist/types.d.ts.map +1 -1
- package/dist/vocabulary/alert.d.ts +21 -23
- package/dist/vocabulary/alert.d.ts.map +1 -1
- package/dist/vocabulary/collectFormValues.d.ts +8 -10
- package/dist/vocabulary/collectFormValues.d.ts.map +1 -1
- package/dist/vocabulary/data.d.ts +45 -47
- package/dist/vocabulary/data.d.ts.map +1 -1
- package/dist/vocabulary/dispatch.d.ts +2 -4
- package/dist/vocabulary/dispatch.d.ts.map +1 -1
- package/dist/vocabulary/fact.d.ts +8 -10
- package/dist/vocabulary/fact.d.ts.map +1 -1
- package/dist/vocabulary/form.d.ts +7 -9
- package/dist/vocabulary/form.d.ts.map +1 -1
- package/dist/vocabulary/icon.d.ts +37 -39
- package/dist/vocabulary/icon.d.ts.map +1 -1
- package/dist/vocabulary/index.d.ts +1 -3
- package/dist/vocabulary/index.d.ts.map +1 -1
- package/dist/vocabulary/interactive.d.ts +87 -86
- package/dist/vocabulary/interactive.d.ts.map +1 -1
- package/dist/vocabulary/interactive.js +4 -4
- package/dist/vocabulary/interactive.js.map +1 -1
- package/dist/vocabulary/layout.d.ts +75 -77
- package/dist/vocabulary/layout.d.ts.map +1 -1
- package/dist/vocabulary/list.d.ts +10 -12
- package/dist/vocabulary/list.d.ts.map +1 -1
- package/dist/vocabulary/media.d.ts +21 -23
- package/dist/vocabulary/media.d.ts.map +1 -1
- package/dist/vocabulary/text.d.ts +53 -55
- package/dist/vocabulary/text.d.ts.map +1 -1
- package/dist/vocabulary/toTextContent.d.ts +0 -2
- package/dist/vocabulary/toTextContent.d.ts.map +1 -1
- package/package.json +4 -4
- package/src/JSONGenerativeUI.client.tsx +57 -15
- package/src/JSONGenerativeUI.test.tsx +298 -4
- package/src/a2ui/convert.test.ts +717 -0
- package/src/a2ui/convert.ts +229 -41
- package/src/a2ui/reducer.test.ts +18 -0
- package/src/a2ui/reducer.ts +16 -0
- package/src/a2ui/snapshot.test.ts +52 -0
- package/src/a2ui/snapshot.ts +52 -0
- package/src/a2ui/types.ts +16 -0
- package/src/a2ui.ts +2 -0
- package/src/actionRegistry.ts +5 -5
- package/src/buildPresentParameters.test.ts +246 -0
- package/src/buildPresentParameters.ts +18 -3
- package/src/scopeSchema.test.ts +72 -0
- package/src/scopeSchema.ts +71 -0
- package/src/vocabulary/interactive.dispatch.test.tsx +17 -3
- package/src/vocabulary/interactive.test.tsx +19 -0
- package/src/vocabulary/interactive.tsx +4 -1
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
import type { JSONSchema7, JSONSchema7Definition } from "json-schema";
|
|
2
|
+
import { describe, expect, it, vi } from "vitest";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
import { buildPresentParameters } from "./buildPresentParameters";
|
|
5
|
+
|
|
6
|
+
const component = (properties: z.ZodType) => ({
|
|
7
|
+
description: "A component",
|
|
8
|
+
properties,
|
|
9
|
+
render: () => null,
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
function resolve(root: JSONSchema7, value: JSONSchema7Definition) {
|
|
13
|
+
let schema = value as JSONSchema7;
|
|
14
|
+
if (schema.$ref === "#") return root;
|
|
15
|
+
if (schema.$ref) {
|
|
16
|
+
expect(schema.$ref).toMatch(/^#\//);
|
|
17
|
+
let target: unknown = root;
|
|
18
|
+
for (const segment of schema.$ref.slice(2).split("/")) {
|
|
19
|
+
const key = decodeURIComponent(segment)
|
|
20
|
+
.replace(/~1/g, "/")
|
|
21
|
+
.replace(/~0/g, "~");
|
|
22
|
+
target = (target as Record<string, unknown> | undefined)?.[key];
|
|
23
|
+
}
|
|
24
|
+
expect(target).toBeDefined();
|
|
25
|
+
schema = target as JSONSchema7;
|
|
26
|
+
}
|
|
27
|
+
return schema;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
describe("component schema references", () => {
|
|
31
|
+
it("retains recursive definitions used by a component prop", () => {
|
|
32
|
+
const Tree = z.object({
|
|
33
|
+
label: z.string(),
|
|
34
|
+
get descendants() {
|
|
35
|
+
return z.array(Tree);
|
|
36
|
+
},
|
|
37
|
+
});
|
|
38
|
+
const schema = buildPresentParameters({
|
|
39
|
+
Tree: component(z.object({ tree: Tree })),
|
|
40
|
+
});
|
|
41
|
+
const tree = resolve(schema, schema.properties!.tree!);
|
|
42
|
+
expect(tree.properties!.label).toEqual({ type: "string" });
|
|
43
|
+
const descendants = tree.properties!.descendants as JSONSchema7;
|
|
44
|
+
expect(resolve(schema, descendants.items as JSONSchema7Definition)).toBe(
|
|
45
|
+
tree,
|
|
46
|
+
);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("keeps definitions with the same generated name isolated across components", () => {
|
|
50
|
+
const TextTree = z.object({
|
|
51
|
+
value: z.string(),
|
|
52
|
+
get descendants() {
|
|
53
|
+
return z.array(TextTree);
|
|
54
|
+
},
|
|
55
|
+
});
|
|
56
|
+
const NumberTree = z.object({
|
|
57
|
+
value: z.number(),
|
|
58
|
+
get descendants() {
|
|
59
|
+
return z.array(NumberTree);
|
|
60
|
+
},
|
|
61
|
+
});
|
|
62
|
+
const schema = buildPresentParameters({
|
|
63
|
+
node: component(z.object({ textTree: TextTree })),
|
|
64
|
+
children: component(z.object({ numberTree: NumberTree })),
|
|
65
|
+
});
|
|
66
|
+
const textTree = resolve(schema, schema.properties!.textTree!);
|
|
67
|
+
const numberTree = resolve(schema, schema.properties!.numberTree!);
|
|
68
|
+
expect(textTree.properties!.value).toEqual({ type: "string" });
|
|
69
|
+
expect(numberTree.properties!.value).toEqual({ type: "number" });
|
|
70
|
+
expect(
|
|
71
|
+
resolve(
|
|
72
|
+
schema,
|
|
73
|
+
(textTree.properties!.descendants as JSONSchema7)
|
|
74
|
+
.items as JSONSchema7Definition,
|
|
75
|
+
),
|
|
76
|
+
).toBe(textTree);
|
|
77
|
+
expect(
|
|
78
|
+
resolve(
|
|
79
|
+
schema,
|
|
80
|
+
(numberTree.properties!.descendants as JSONSchema7)
|
|
81
|
+
.items as JSONSchema7Definition,
|
|
82
|
+
),
|
|
83
|
+
).toBe(numberTree);
|
|
84
|
+
expect(resolve(schema, schema.properties!.children!).anyOf).toBeDefined();
|
|
85
|
+
expect((schema.$defs!.node as JSONSchema7).properties!.$type).toBeDefined();
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("rebases root references to component props instead of the generated UI node", () => {
|
|
89
|
+
const Props = z.object({
|
|
90
|
+
label: z.string(),
|
|
91
|
+
get nested() {
|
|
92
|
+
return z.array(Props);
|
|
93
|
+
},
|
|
94
|
+
});
|
|
95
|
+
const schema = buildPresentParameters({ Recursive: component(Props) });
|
|
96
|
+
const nested = schema.properties!.nested as JSONSchema7;
|
|
97
|
+
const props = resolve(schema, nested.items as JSONSchema7Definition);
|
|
98
|
+
expect(props.properties!.label).toEqual({ type: "string" });
|
|
99
|
+
expect(props.properties!.$type).toBeUndefined();
|
|
100
|
+
expect(props.required).toEqual(["label", "nested"]);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it("rebases recursive references inside unions and records", () => {
|
|
104
|
+
const Tree = z.object({
|
|
105
|
+
get branches() {
|
|
106
|
+
return z.record(z.string(), z.union([z.string(), Tree]));
|
|
107
|
+
},
|
|
108
|
+
});
|
|
109
|
+
const schema = buildPresentParameters({
|
|
110
|
+
Tree: component(z.object({ tree: Tree })),
|
|
111
|
+
});
|
|
112
|
+
const tree = resolve(schema, schema.properties!.tree!);
|
|
113
|
+
const branches = tree.properties!.branches as JSONSchema7;
|
|
114
|
+
const variants = (branches.additionalProperties as JSONSchema7).anyOf!;
|
|
115
|
+
expect(resolve(schema, variants[1]!)).toBe(tree);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it("retains recursive definitions referenced from tuple items", () => {
|
|
119
|
+
const Tree = z.object({
|
|
120
|
+
label: z.string(),
|
|
121
|
+
get descendants() {
|
|
122
|
+
return z.array(Tree);
|
|
123
|
+
},
|
|
124
|
+
});
|
|
125
|
+
const schema = buildPresentParameters({
|
|
126
|
+
Pair: component(z.object({ pair: z.tuple([Tree, z.string()]) })),
|
|
127
|
+
});
|
|
128
|
+
const pair = schema.properties!.pair as JSONSchema7 & {
|
|
129
|
+
prefixItems?: JSONSchema7Definition[];
|
|
130
|
+
};
|
|
131
|
+
const first = (pair.prefixItems ??
|
|
132
|
+
(pair.items as JSONSchema7Definition[]))[0]!;
|
|
133
|
+
const tree = resolve(schema, first);
|
|
134
|
+
expect(tree.properties!.label).toEqual({ type: "string" });
|
|
135
|
+
expect(
|
|
136
|
+
resolve(
|
|
137
|
+
schema,
|
|
138
|
+
(tree.properties!.descendants as JSONSchema7)
|
|
139
|
+
.items as JSONSchema7Definition,
|
|
140
|
+
),
|
|
141
|
+
).toBe(tree);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it("rebases prefixItems in draft 2020-12 converter output", () => {
|
|
145
|
+
const raw = {
|
|
146
|
+
$schema: "https://json-schema.org/draft/2020-12/schema",
|
|
147
|
+
$id: "https://example.com/pair",
|
|
148
|
+
type: "object",
|
|
149
|
+
properties: {
|
|
150
|
+
pair: {
|
|
151
|
+
type: "array",
|
|
152
|
+
prefixItems: [{ $ref: "#/$defs/item" }, { type: "string" }],
|
|
153
|
+
},
|
|
154
|
+
},
|
|
155
|
+
$defs: {
|
|
156
|
+
item: {
|
|
157
|
+
type: "object",
|
|
158
|
+
properties: {
|
|
159
|
+
descendants: { type: "array", items: { $ref: "#/$defs/item" } },
|
|
160
|
+
},
|
|
161
|
+
},
|
|
162
|
+
},
|
|
163
|
+
};
|
|
164
|
+
const schema = buildPresentParameters({
|
|
165
|
+
Pair: component(raw as unknown as z.ZodType),
|
|
166
|
+
});
|
|
167
|
+
const pair = schema.properties!.pair as JSONSchema7 & {
|
|
168
|
+
prefixItems: JSONSchema7Definition[];
|
|
169
|
+
};
|
|
170
|
+
const item = resolve(schema, pair.prefixItems[0]!);
|
|
171
|
+
expect(item.properties!.descendants).toBeDefined();
|
|
172
|
+
expect(
|
|
173
|
+
resolve(
|
|
174
|
+
schema,
|
|
175
|
+
(item.properties!.descendants as JSONSchema7)
|
|
176
|
+
.items as JSONSchema7Definition,
|
|
177
|
+
),
|
|
178
|
+
).toBe(item);
|
|
179
|
+
const embedded = schema.$defs!.component0 as JSONSchema7;
|
|
180
|
+
expect(Object.keys(embedded)).not.toContain("$schema");
|
|
181
|
+
expect(Object.keys(embedded)).not.toContain("$id");
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
it("strips root resource metadata from embedded component schemas", () => {
|
|
185
|
+
const Tree = z.object({
|
|
186
|
+
get descendants() {
|
|
187
|
+
return z.array(Tree);
|
|
188
|
+
},
|
|
189
|
+
});
|
|
190
|
+
const schema = buildPresentParameters({
|
|
191
|
+
Tree: component(z.object({ tree: Tree })),
|
|
192
|
+
});
|
|
193
|
+
const embedded = schema.$defs!.component0 as JSONSchema7;
|
|
194
|
+
expect(Object.keys(embedded)).not.toContain("$schema");
|
|
195
|
+
expect(embedded.type).toBe("object");
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it("omits component definitions whose properties all lose the duplicate-name merge", () => {
|
|
199
|
+
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
200
|
+
try {
|
|
201
|
+
const StringTree = z.object({
|
|
202
|
+
get tree() {
|
|
203
|
+
return z.array(StringTree);
|
|
204
|
+
},
|
|
205
|
+
});
|
|
206
|
+
const NumberTree = z.object({
|
|
207
|
+
value: z.number(),
|
|
208
|
+
get tree() {
|
|
209
|
+
return z.array(NumberTree);
|
|
210
|
+
},
|
|
211
|
+
});
|
|
212
|
+
const schema = buildPresentParameters({
|
|
213
|
+
first: component(z.object({ tree: StringTree })),
|
|
214
|
+
second: component(z.object({ tree: NumberTree })),
|
|
215
|
+
});
|
|
216
|
+
expect(Object.keys(schema.$defs!)).toEqual([
|
|
217
|
+
"node",
|
|
218
|
+
"children",
|
|
219
|
+
"component0",
|
|
220
|
+
]);
|
|
221
|
+
expect(warn).toHaveBeenCalledOnce();
|
|
222
|
+
} finally {
|
|
223
|
+
warn.mockRestore();
|
|
224
|
+
}
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
it("leaves literal reference-shaped data and non-recursive schemas unchanged", () => {
|
|
228
|
+
const literal = {
|
|
229
|
+
$ref: "#/literal",
|
|
230
|
+
properties: { nested: { $ref: "#" } },
|
|
231
|
+
};
|
|
232
|
+
const schema = buildPresentParameters({
|
|
233
|
+
Card: component(
|
|
234
|
+
z.object({
|
|
235
|
+
label: z.string().meta({ examples: [literal], default: literal }),
|
|
236
|
+
}),
|
|
237
|
+
),
|
|
238
|
+
});
|
|
239
|
+
expect(schema.properties!.label).toEqual({
|
|
240
|
+
type: "string",
|
|
241
|
+
examples: [literal],
|
|
242
|
+
default: literal,
|
|
243
|
+
});
|
|
244
|
+
expect(Object.keys(schema.$defs!)).toEqual(["node", "children"]);
|
|
245
|
+
});
|
|
246
|
+
});
|
|
@@ -2,6 +2,7 @@ import { toJSONSchema } from "assistant-stream";
|
|
|
2
2
|
import type { JSONSchema7, JSONSchema7Definition } from "json-schema";
|
|
3
3
|
import { TYPE_KEY } from "./constants";
|
|
4
4
|
import type { GenerativeUILibrary } from "./types";
|
|
5
|
+
import { scopeSchema } from "./scopeSchema";
|
|
5
6
|
|
|
6
7
|
/**
|
|
7
8
|
* Builds the JSON schema for the `present` tool from a {@link GenerativeUILibrary}.
|
|
@@ -31,14 +32,20 @@ export function buildPresentParameters(
|
|
|
31
32
|
// advisory hint here, not a strict per-component contract.
|
|
32
33
|
const props = new Map<string, JSONSchema7Definition>();
|
|
33
34
|
const propOwners = new Map<string, string[]>();
|
|
34
|
-
|
|
35
|
-
|
|
35
|
+
const componentSchemas: Record<string, JSONSchema7> = {};
|
|
36
|
+
for (const [index, name] of names.entries()) {
|
|
37
|
+
const definition = `component${index}`;
|
|
38
|
+
const { schema: propsSchema, referenced } = scopeSchema(
|
|
39
|
+
toJSONSchema(library[name]!.properties),
|
|
40
|
+
`#/$defs/${definition}`,
|
|
41
|
+
);
|
|
36
42
|
if (propsSchema.type !== "object") {
|
|
37
43
|
throw new Error(
|
|
38
44
|
`[@assistant-ui/react-generative-ui] Component "${name}": ` +
|
|
39
45
|
"`properties` must be an object schema (e.g. `z.object({ ... })`).",
|
|
40
46
|
);
|
|
41
47
|
}
|
|
48
|
+
let merged = false;
|
|
42
49
|
for (const [key, schema] of Object.entries(propsSchema.properties ?? {})) {
|
|
43
50
|
if (key.startsWith("$") || key === "children") continue;
|
|
44
51
|
// secure-json-parse rejects the whole tool-argument payload on this key,
|
|
@@ -46,9 +53,17 @@ export function buildPresentParameters(
|
|
|
46
53
|
if (key === "__proto__") continue;
|
|
47
54
|
if (!props.has(key)) {
|
|
48
55
|
props.set(key, schema);
|
|
56
|
+
merged = true;
|
|
49
57
|
}
|
|
50
58
|
propOwners.set(key, [...(propOwners.get(key) ?? []), name]);
|
|
51
59
|
}
|
|
60
|
+
if (referenced && merged) {
|
|
61
|
+
// `$schema` is only valid at a schema-resource root, and an embedded
|
|
62
|
+
// `$id` would both collide across components and re-base the pointers
|
|
63
|
+
// scopeSchema just rewrote to be document-root relative.
|
|
64
|
+
const { $schema: _, $id: _id, ...embedded } = propsSchema;
|
|
65
|
+
componentSchemas[definition] = embedded;
|
|
66
|
+
}
|
|
52
67
|
}
|
|
53
68
|
|
|
54
69
|
if (process.env["NODE_ENV"] !== "production") {
|
|
@@ -101,7 +116,7 @@ export function buildPresentParameters(
|
|
|
101
116
|
|
|
102
117
|
return {
|
|
103
118
|
...node,
|
|
104
|
-
$defs: { node, children },
|
|
119
|
+
$defs: { node, children, ...componentSchemas },
|
|
105
120
|
};
|
|
106
121
|
}
|
|
107
122
|
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import type { JSONSchema7 } from "json-schema";
|
|
2
|
+
import { describe, expect, it } from "vitest";
|
|
3
|
+
import { scopeSchema } from "./scopeSchema";
|
|
4
|
+
|
|
5
|
+
const ref = { $ref: "#/definitions/a~1b~0c" };
|
|
6
|
+
const scopedRef = { $ref: "#/$defs/component0/definitions/a~1b~0c" };
|
|
7
|
+
const scope = (schema: JSONSchema7) =>
|
|
8
|
+
scopeSchema(schema, "#/$defs/component0");
|
|
9
|
+
|
|
10
|
+
describe("scopeSchema", () => {
|
|
11
|
+
it.each([
|
|
12
|
+
"properties",
|
|
13
|
+
"patternProperties",
|
|
14
|
+
"definitions",
|
|
15
|
+
"$defs",
|
|
16
|
+
"dependentSchemas",
|
|
17
|
+
])("rebases schemas inside %s without changing the input", (keyword) => {
|
|
18
|
+
const original = {
|
|
19
|
+
[keyword]: { value: ref, allowed: true, forbidden: false },
|
|
20
|
+
};
|
|
21
|
+
const result = scope(original);
|
|
22
|
+
expect(result.schema).toEqual({
|
|
23
|
+
[keyword]: { value: scopedRef, allowed: true, forbidden: false },
|
|
24
|
+
});
|
|
25
|
+
expect(result.referenced).toBe(true);
|
|
26
|
+
expect(original).toEqual({
|
|
27
|
+
[keyword]: { value: ref, allowed: true, forbidden: false },
|
|
28
|
+
});
|
|
29
|
+
});
|
|
30
|
+
it.each(["allOf", "anyOf", "oneOf", "items", "prefixItems"])(
|
|
31
|
+
"rebases schemas in %s arrays",
|
|
32
|
+
(keyword) => {
|
|
33
|
+
expect(scope({ [keyword]: [ref, true, false] }).schema).toEqual({
|
|
34
|
+
[keyword]: [scopedRef, true, false],
|
|
35
|
+
});
|
|
36
|
+
},
|
|
37
|
+
);
|
|
38
|
+
it.each([
|
|
39
|
+
"items",
|
|
40
|
+
"additionalProperties",
|
|
41
|
+
"additionalItems",
|
|
42
|
+
"unevaluatedProperties",
|
|
43
|
+
"unevaluatedItems",
|
|
44
|
+
"contentSchema",
|
|
45
|
+
"contains",
|
|
46
|
+
"propertyNames",
|
|
47
|
+
"not",
|
|
48
|
+
"if",
|
|
49
|
+
"then",
|
|
50
|
+
"else",
|
|
51
|
+
])("rebases the %s subschema", (keyword) => {
|
|
52
|
+
expect(scope({ [keyword]: ref }).schema).toEqual({ [keyword]: scopedRef });
|
|
53
|
+
});
|
|
54
|
+
it("preserves property dependencies while rebasing schema dependencies", () => {
|
|
55
|
+
expect(
|
|
56
|
+
scope({ dependencies: { first: ["second"], other: ref } }).schema,
|
|
57
|
+
).toEqual({ dependencies: { first: ["second"], other: scopedRef } });
|
|
58
|
+
});
|
|
59
|
+
it("leaves literal data, external references, and named anchors unchanged", () => {
|
|
60
|
+
const schema: JSONSchema7 = {
|
|
61
|
+
default: ref,
|
|
62
|
+
examples: [ref],
|
|
63
|
+
enum: [ref],
|
|
64
|
+
const: ref,
|
|
65
|
+
properties: {
|
|
66
|
+
remote: { $ref: "https://example.com/schema.json#/definitions/value" },
|
|
67
|
+
anchor: { $ref: "#value" },
|
|
68
|
+
},
|
|
69
|
+
};
|
|
70
|
+
expect(scope(schema)).toEqual({ schema, referenced: false });
|
|
71
|
+
});
|
|
72
|
+
});
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import type { JSONSchema7, JSONSchema7Definition } from "json-schema";
|
|
2
|
+
|
|
3
|
+
// The converter does not pin a JSON Schema draft, so the 2020-12 applicators
|
|
4
|
+
// missing from the JSONSchema7 typings must be traversed as well.
|
|
5
|
+
type TraversableSchema = JSONSchema7 & {
|
|
6
|
+
prefixItems?: JSONSchema7Definition[];
|
|
7
|
+
dependentSchemas?: { [key: string]: JSONSchema7Definition };
|
|
8
|
+
unevaluatedItems?: JSONSchema7Definition;
|
|
9
|
+
unevaluatedProperties?: JSONSchema7Definition;
|
|
10
|
+
contentSchema?: JSONSchema7Definition;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export function scopeSchema(schema: JSONSchema7, path: string) {
|
|
14
|
+
let referenced = false;
|
|
15
|
+
const visit = (definition: JSONSchema7Definition): JSONSchema7Definition => {
|
|
16
|
+
if (typeof definition === "boolean") return definition;
|
|
17
|
+
const schema = definition as TraversableSchema;
|
|
18
|
+
const result = { ...schema };
|
|
19
|
+
if (schema.$ref === "#" || schema.$ref?.startsWith("#/")) {
|
|
20
|
+
referenced = true;
|
|
21
|
+
result.$ref = path + schema.$ref.slice(1);
|
|
22
|
+
}
|
|
23
|
+
// Only schema-bearing keywords are traversed; defaults and examples are data.
|
|
24
|
+
for (const key of [
|
|
25
|
+
"properties",
|
|
26
|
+
"patternProperties",
|
|
27
|
+
"definitions",
|
|
28
|
+
"$defs",
|
|
29
|
+
"dependentSchemas",
|
|
30
|
+
] as const) {
|
|
31
|
+
const entries = schema[key];
|
|
32
|
+
if (entries)
|
|
33
|
+
result[key] = Object.fromEntries(
|
|
34
|
+
Object.entries(entries).map(([name, value]) => [name, visit(value)]),
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
for (const key of ["allOf", "anyOf", "oneOf", "prefixItems"] as const) {
|
|
38
|
+
if (schema[key]) result[key] = schema[key].map(visit);
|
|
39
|
+
}
|
|
40
|
+
for (const key of [
|
|
41
|
+
"additionalProperties",
|
|
42
|
+
"additionalItems",
|
|
43
|
+
"unevaluatedProperties",
|
|
44
|
+
"unevaluatedItems",
|
|
45
|
+
"contentSchema",
|
|
46
|
+
"contains",
|
|
47
|
+
"propertyNames",
|
|
48
|
+
"not",
|
|
49
|
+
"if",
|
|
50
|
+
"then",
|
|
51
|
+
"else",
|
|
52
|
+
] as const) {
|
|
53
|
+
const value = schema[key];
|
|
54
|
+
if (value !== undefined) result[key] = visit(value);
|
|
55
|
+
}
|
|
56
|
+
if (schema.items !== undefined)
|
|
57
|
+
result.items = Array.isArray(schema.items)
|
|
58
|
+
? schema.items.map(visit)
|
|
59
|
+
: visit(schema.items);
|
|
60
|
+
if (schema.dependencies)
|
|
61
|
+
result.dependencies = Object.fromEntries(
|
|
62
|
+
Object.entries(schema.dependencies).map(([name, value]) => [
|
|
63
|
+
name,
|
|
64
|
+
Array.isArray(value) ? value : visit(value),
|
|
65
|
+
]),
|
|
66
|
+
);
|
|
67
|
+
return result;
|
|
68
|
+
};
|
|
69
|
+
const scoped = visit(schema) as JSONSchema7;
|
|
70
|
+
return { schema: scoped, referenced };
|
|
71
|
+
}
|
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
import { describe, it, expect, vi } from "vitest";
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
Children,
|
|
4
|
+
isValidElement,
|
|
5
|
+
type ReactElement,
|
|
6
|
+
type ReactNode,
|
|
7
|
+
} from "react";
|
|
3
8
|
import { renderToStaticMarkup } from "react-dom/server";
|
|
4
9
|
import { interactiveVocabulary } from "./interactive";
|
|
5
10
|
import { createActionRegistry } from "../actionRegistry";
|
|
@@ -27,6 +32,15 @@ function renderWithHooks(render: () => ReactNode): ReactElement {
|
|
|
27
32
|
return captured!;
|
|
28
33
|
}
|
|
29
34
|
|
|
35
|
+
const getRadioOptions = (out: ReactElement) =>
|
|
36
|
+
Children.toArray((out.props as { children: ReactNode }).children)
|
|
37
|
+
.filter(isValidElement)
|
|
38
|
+
.filter(
|
|
39
|
+
(child) =>
|
|
40
|
+
(child.props as { "data-aui"?: string })["data-aui"] ===
|
|
41
|
+
"radiogroup-option",
|
|
42
|
+
) as ReactElement[];
|
|
43
|
+
|
|
30
44
|
describe("interactiveVocabulary $action dispatch", () => {
|
|
31
45
|
it("Button render attaches an onClick that fires $dispatch with the $action payload", () => {
|
|
32
46
|
const handler = vi.fn();
|
|
@@ -319,7 +333,7 @@ describe("interactiveVocabulary $action dispatch", () => {
|
|
|
319
333
|
],
|
|
320
334
|
}),
|
|
321
335
|
);
|
|
322
|
-
const options = (out
|
|
336
|
+
const options = getRadioOptions(out);
|
|
323
337
|
const secondRadio = (options[1]!.props as { children: ReactElement[] })
|
|
324
338
|
.children[0] as ReactElement;
|
|
325
339
|
const onChange = (secondRadio.props as { onChange: () => void }).onChange;
|
|
@@ -339,7 +353,7 @@ describe("interactiveVocabulary $action dispatch", () => {
|
|
|
339
353
|
],
|
|
340
354
|
}),
|
|
341
355
|
);
|
|
342
|
-
const options = (out
|
|
356
|
+
const options = getRadioOptions(out);
|
|
343
357
|
const firstRadio = (options[0]!.props as { children: ReactElement[] })
|
|
344
358
|
.children[0] as ReactElement;
|
|
345
359
|
const secondRadio = (options[1]!.props as { children: ReactElement[] })
|
|
@@ -3,6 +3,7 @@ import { renderToStaticMarkup } from "react-dom/server";
|
|
|
3
3
|
import { GENERATED_NAME_ATTR } from "../constants";
|
|
4
4
|
import { renderGenerativeUI } from "../renderGenerativeUI";
|
|
5
5
|
import { interactiveVocabulary } from "./interactive";
|
|
6
|
+
import { defaultGenerativeUILibrary } from "./index";
|
|
6
7
|
|
|
7
8
|
const render = (node: unknown) =>
|
|
8
9
|
renderToStaticMarkup(<>{renderGenerativeUI(node, interactiveVocabulary)}</>);
|
|
@@ -214,6 +215,24 @@ describe("interactiveVocabulary", () => {
|
|
|
214
215
|
expect(html).toContain("Large");
|
|
215
216
|
});
|
|
216
217
|
|
|
218
|
+
it("RadioGroup renders model-provided children after the options inside the fieldset", () => {
|
|
219
|
+
const html = renderToStaticMarkup(
|
|
220
|
+
<>
|
|
221
|
+
{renderGenerativeUI(
|
|
222
|
+
{
|
|
223
|
+
$type: "RadioGroup",
|
|
224
|
+
options: [{ label: "Small", value: "sm" }],
|
|
225
|
+
children: { $type: "Text", value: "Additional context" },
|
|
226
|
+
},
|
|
227
|
+
defaultGenerativeUILibrary,
|
|
228
|
+
)}
|
|
229
|
+
</>,
|
|
230
|
+
);
|
|
231
|
+
expect(html).toMatch(
|
|
232
|
+
/^<fieldset data-aui="radiogroup">.*data-aui="radiogroup-option".*Small<\/label>.*Additional context.*<\/fieldset>$/,
|
|
233
|
+
);
|
|
234
|
+
});
|
|
235
|
+
|
|
217
236
|
it("RadioGroup renders an aria-label from the label prop", () => {
|
|
218
237
|
const html = render({
|
|
219
238
|
$type: "RadioGroup",
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { useId } from "react";
|
|
1
|
+
import { useId, type ReactNode } from "react";
|
|
2
2
|
import { z } from "zod";
|
|
3
3
|
import { GENERATED_NAME_ATTR } from "../constants";
|
|
4
4
|
import type { Action } from "../ir";
|
|
@@ -31,6 +31,7 @@ type RadioGroupRenderProps = {
|
|
|
31
31
|
name?: string;
|
|
32
32
|
label?: string;
|
|
33
33
|
defaultValue?: string;
|
|
34
|
+
children?: ReactNode;
|
|
34
35
|
$status: GenerativeUIStatus;
|
|
35
36
|
$action?: Action;
|
|
36
37
|
$dispatch?: GenerativeUIDispatch;
|
|
@@ -41,6 +42,7 @@ function RadioGroupRender({
|
|
|
41
42
|
name,
|
|
42
43
|
label,
|
|
43
44
|
defaultValue,
|
|
45
|
+
children,
|
|
44
46
|
$action,
|
|
45
47
|
$dispatch,
|
|
46
48
|
}: RadioGroupRenderProps) {
|
|
@@ -68,6 +70,7 @@ function RadioGroupRender({
|
|
|
68
70
|
</label>
|
|
69
71
|
) : null,
|
|
70
72
|
)}
|
|
73
|
+
{children}
|
|
71
74
|
</fieldset>
|
|
72
75
|
);
|
|
73
76
|
}
|