@assistant-ui/react-generative-ui 0.0.18 → 0.0.20
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 +15 -17
- 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 +6 -9
- package/dist/JSONGenerativeUI.server.d.ts.map +1 -1
- package/dist/JSONGenerativeUI.shared.d.ts +42 -44
- package/dist/JSONGenerativeUI.shared.d.ts.map +1 -1
- package/dist/a2ui/convert.d.ts +7 -8
- package/dist/a2ui/convert.d.ts.map +1 -1
- package/dist/a2ui/convert.js +115 -16
- package/dist/a2ui/convert.js.map +1 -1
- package/dist/a2ui/reducer.d.ts +2 -5
- 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 +67 -58
- 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 +17 -20
- package/dist/actionRegistry.d.ts.map +1 -1
- package/dist/actionRegistry.js.map +1 -1
- package/dist/buildPresentParameters.d.ts +3 -6
- 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 +2 -4
- package/dist/constants.d.ts.map +1 -1
- package/dist/constants.js +2 -1
- package/dist/constants.js.map +1 -1
- package/dist/convert/boundSpec.d.ts +7 -10
- package/dist/convert/boundSpec.d.ts.map +1 -1
- package/dist/convert/copyBounded.d.ts +3 -6
- package/dist/convert/copyBounded.d.ts.map +1 -1
- package/dist/convert/isElement.d.ts +2 -5
- package/dist/convert/isElement.d.ts.map +1 -1
- package/dist/convert/takeRun.d.ts +4 -7
- package/dist/convert/takeRun.d.ts.map +1 -1
- package/dist/defineGenerativeComponents.d.ts +2 -5
- package/dist/defineGenerativeComponents.d.ts.map +1 -1
- package/dist/generativeUIToJSX.d.ts +6 -9
- 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 +45 -48
- package/dist/ir.d.ts.map +1 -1
- package/dist/renderGenerativeUI.d.ts +3 -6
- 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 +43 -45
- package/dist/slack/constants.d.ts.map +1 -1
- package/dist/slack/decodeBlockAction.d.ts +2 -5
- package/dist/slack/decodeBlockAction.d.ts.map +1 -1
- package/dist/slack/fromSlackBlocks.d.ts +2 -5
- package/dist/slack/fromSlackBlocks.d.ts.map +1 -1
- package/dist/slack/toSlackBlocks.d.ts +2 -5
- package/dist/slack/toSlackBlocks.d.ts.map +1 -1
- package/dist/slack/toSlackBlocks.js +1 -1
- package/dist/slack/types.d.ts +110 -113
- 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 +15 -18
- package/dist/teams/constants.d.ts.map +1 -1
- package/dist/teams/decodeSubmitData.d.ts +2 -5
- package/dist/teams/decodeSubmitData.d.ts.map +1 -1
- package/dist/teams/toAdaptiveCard.d.ts +10 -13
- package/dist/teams/toAdaptiveCard.d.ts.map +1 -1
- package/dist/teams/toTeamsAttachments.d.ts +2 -5
- package/dist/teams/toTeamsAttachments.d.ts.map +1 -1
- package/dist/teams/types.d.ts +136 -138
- 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 +45 -47
- package/dist/types.d.ts.map +1 -1
- package/dist/vocabulary/alert.d.ts +22 -25
- package/dist/vocabulary/alert.d.ts.map +1 -1
- package/dist/vocabulary/collectFormValues.d.ts +12 -13
- package/dist/vocabulary/collectFormValues.d.ts.map +1 -1
- package/dist/vocabulary/collectFormValues.js +3 -2
- package/dist/vocabulary/collectFormValues.js.map +1 -1
- package/dist/vocabulary/data.d.ts +46 -49
- package/dist/vocabulary/data.d.ts.map +1 -1
- package/dist/vocabulary/dispatch.d.ts +4 -7
- package/dist/vocabulary/dispatch.d.ts.map +1 -1
- package/dist/vocabulary/fact.d.ts +9 -12
- package/dist/vocabulary/fact.d.ts.map +1 -1
- package/dist/vocabulary/form.d.ts +8 -11
- package/dist/vocabulary/form.d.ts.map +1 -1
- package/dist/vocabulary/icon.d.ts +38 -41
- package/dist/vocabulary/icon.d.ts.map +1 -1
- package/dist/vocabulary/index.d.ts +2 -5
- package/dist/vocabulary/index.d.ts.map +1 -1
- package/dist/vocabulary/interactive.d.ts +88 -87
- package/dist/vocabulary/interactive.d.ts.map +1 -1
- package/dist/vocabulary/interactive.js +6 -4
- package/dist/vocabulary/interactive.js.map +1 -1
- package/dist/vocabulary/layout.d.ts +76 -79
- package/dist/vocabulary/layout.d.ts.map +1 -1
- package/dist/vocabulary/list.d.ts +11 -14
- package/dist/vocabulary/list.d.ts.map +1 -1
- package/dist/vocabulary/media.d.ts +22 -25
- package/dist/vocabulary/media.d.ts.map +1 -1
- package/dist/vocabulary/text.d.ts +54 -57
- package/dist/vocabulary/text.d.ts.map +1 -1
- package/dist/vocabulary/toTextContent.d.ts +1 -4
- package/dist/vocabulary/toTextContent.d.ts.map +1 -1
- package/package.json +12 -10
- package/src/JSONGenerativeUI.client.tsx +57 -15
- package/src/JSONGenerativeUI.test.tsx +298 -4
- package/src/a2ui/convert.test.ts +496 -0
- package/src/a2ui/convert.ts +170 -25
- 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/constants.ts +2 -0
- package/src/scopeSchema.test.ts +72 -0
- package/src/scopeSchema.ts +71 -0
- package/src/slack/fromSlackBlocks.test.ts +4 -1
- package/src/slack/toSlackBlocks.test.ts +8 -4
- package/src/teams/toAdaptiveCard.test.ts +5 -3
- package/src/vocabulary/collectFormValues.test.ts +38 -0
- package/src/vocabulary/collectFormValues.ts +11 -2
- package/src/vocabulary/form.test.tsx +1 -0
- package/src/vocabulary/interactive.dispatch.test.tsx +17 -3
- package/src/vocabulary/interactive.test.tsx +33 -1
- package/src/vocabulary/interactive.tsx +12 -2
- package/src/vocabulary/layout.test.tsx +1 -0
- package/src/vocabulary/list.test.tsx +5 -4
- package/dist/node_modules/.pnpm/@types_json-schema@7.0.15/node_modules/@types/json-schema/index.d.ts +0 -141
- package/dist/node_modules/.pnpm/@types_json-schema@7.0.15/node_modules/@types/json-schema/index.d.ts.map +0 -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
|
|
package/src/constants.ts
CHANGED
|
@@ -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
|
+
}
|
|
@@ -1107,7 +1107,10 @@ describe("fromSlackBlocks checkbox and radio caps", () => {
|
|
|
1107
1107
|
],
|
|
1108
1108
|
},
|
|
1109
1109
|
]);
|
|
1110
|
-
const radio = nodes[0]
|
|
1110
|
+
const radio = nodes[0];
|
|
1111
|
+
if (radio?.$type !== "RadioGroup" || !Array.isArray(radio.options)) {
|
|
1112
|
+
throw new Error("Expected a RadioGroup with options.");
|
|
1113
|
+
}
|
|
1111
1114
|
expect(radio.options).toHaveLength(10);
|
|
1112
1115
|
expect(
|
|
1113
1116
|
warnings.some(
|
|
@@ -2346,9 +2346,10 @@ describe("toSlackBlocks data_table integrity", () => {
|
|
|
2346
2346
|
columns: [{ label: "A" }, { label: "B" }],
|
|
2347
2347
|
rows: [[{ nested: true }, "kept"]],
|
|
2348
2348
|
});
|
|
2349
|
-
const table = blocks[0]
|
|
2350
|
-
|
|
2351
|
-
|
|
2349
|
+
const table = blocks[0];
|
|
2350
|
+
if (table?.type !== "data_table") {
|
|
2351
|
+
throw new Error("Expected a data table block.");
|
|
2352
|
+
}
|
|
2352
2353
|
expect(table.rows[1]).toEqual([
|
|
2353
2354
|
{ type: "raw_text", text: "" },
|
|
2354
2355
|
{ type: "raw_text", text: "kept" },
|
|
@@ -2361,7 +2362,10 @@ describe("toSlackBlocks data_table integrity", () => {
|
|
|
2361
2362
|
columns: [{ label: "A" }, { label: "B" }, { label: "C" }],
|
|
2362
2363
|
rows: [["x"], ["x", "y", "z"]],
|
|
2363
2364
|
});
|
|
2364
|
-
const table = blocks[0]
|
|
2365
|
+
const table = blocks[0];
|
|
2366
|
+
if (table?.type !== "data_table") {
|
|
2367
|
+
throw new Error("Expected a data table block.");
|
|
2368
|
+
}
|
|
2365
2369
|
expect(new Set(table.rows.map((row) => row.length))).toEqual(new Set([3]));
|
|
2366
2370
|
});
|
|
2367
2371
|
|
|
@@ -1038,9 +1038,11 @@ describe("toAdaptiveCard", () => {
|
|
|
1038
1038
|
name: "s",
|
|
1039
1039
|
options: [{ label: "ok", value: "a" }, { label: "bad" }, "nope"],
|
|
1040
1040
|
});
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1041
|
+
const select = card.body[0];
|
|
1042
|
+
if (select?.type !== "Input.ChoiceSet") {
|
|
1043
|
+
throw new Error("Expected an input choice set.");
|
|
1044
|
+
}
|
|
1045
|
+
expect(select.choices).toEqual([{ title: "ok", value: "a" }]);
|
|
1044
1046
|
expect(warnings).toContainEqual({
|
|
1045
1047
|
code: "dropped",
|
|
1046
1048
|
component: "Select",
|
|
@@ -1,8 +1,11 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
|
|
1
3
|
import { describe, it, expect } from "vitest";
|
|
2
4
|
import {
|
|
3
5
|
collectFormValues,
|
|
4
6
|
type FormControlElementLike,
|
|
5
7
|
} from "./collectFormValues";
|
|
8
|
+
import { GENERATED_NAME_ATTR } from "../constants";
|
|
6
9
|
|
|
7
10
|
const el = (
|
|
8
11
|
partial: Partial<FormControlElementLike>,
|
|
@@ -11,6 +14,7 @@ const el = (
|
|
|
11
14
|
type: "text",
|
|
12
15
|
value: "",
|
|
13
16
|
disabled: false,
|
|
17
|
+
hasAttribute: () => false,
|
|
14
18
|
...partial,
|
|
15
19
|
});
|
|
16
20
|
|
|
@@ -82,6 +86,20 @@ describe("collectFormValues", () => {
|
|
|
82
86
|
).toEqual({ kept: "yes" });
|
|
83
87
|
});
|
|
84
88
|
|
|
89
|
+
it("skips radio groups whose name is generated only for native grouping", () => {
|
|
90
|
+
expect(
|
|
91
|
+
collectFormValues([
|
|
92
|
+
el({
|
|
93
|
+
name: "_R_1_",
|
|
94
|
+
type: "radio",
|
|
95
|
+
value: "sm",
|
|
96
|
+
checked: true,
|
|
97
|
+
hasAttribute: (name) => name === GENERATED_NAME_ATTR,
|
|
98
|
+
}),
|
|
99
|
+
]),
|
|
100
|
+
).toEqual({});
|
|
101
|
+
});
|
|
102
|
+
|
|
85
103
|
it("skips disabled controls", () => {
|
|
86
104
|
expect(
|
|
87
105
|
collectFormValues([
|
|
@@ -106,6 +124,26 @@ describe("collectFormValues", () => {
|
|
|
106
124
|
).toEqual({ size: undefined });
|
|
107
125
|
});
|
|
108
126
|
|
|
127
|
+
it("skips controls disabled by a fieldset while preserving its first legend", () => {
|
|
128
|
+
const form = document.createElement("form");
|
|
129
|
+
form.innerHTML = `
|
|
130
|
+
<fieldset disabled>
|
|
131
|
+
<legend><input name="legend" value="kept" /></legend>
|
|
132
|
+
<input name="blocked" value="old" />
|
|
133
|
+
</fieldset>
|
|
134
|
+
<input name="enabled" value="yes" />
|
|
135
|
+
`;
|
|
136
|
+
const blocked = form.elements.namedItem("blocked") as HTMLInputElement;
|
|
137
|
+
|
|
138
|
+
expect(blocked.disabled).toBe(false);
|
|
139
|
+
|
|
140
|
+
expect(
|
|
141
|
+
collectFormValues(
|
|
142
|
+
form.elements as unknown as ArrayLike<FormControlElementLike>,
|
|
143
|
+
),
|
|
144
|
+
).toEqual({ legend: "kept", enabled: "yes" });
|
|
145
|
+
});
|
|
146
|
+
|
|
109
147
|
it("returns an empty object for no elements", () => {
|
|
110
148
|
expect(collectFormValues([])).toEqual({});
|
|
111
149
|
});
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { FormEvent } from "react";
|
|
2
|
+
import { GENERATED_NAME_ATTR } from "../constants";
|
|
2
3
|
|
|
3
4
|
/**
|
|
4
5
|
* The subset of `HTMLInputElement`/`HTMLSelectElement`/`HTMLTextAreaElement` that {@link collectFormValues} reads. A structural type rather than the DOM interfaces themselves, so a plain object can stand in for a form control in tests.
|
|
@@ -9,10 +10,12 @@ export type FormControlElementLike = {
|
|
|
9
10
|
readonly value: string;
|
|
10
11
|
readonly checked?: boolean;
|
|
11
12
|
readonly disabled: boolean;
|
|
13
|
+
readonly hasAttribute: (name: string) => boolean;
|
|
14
|
+
readonly matches?: ((selector: string) => boolean) | undefined;
|
|
12
15
|
};
|
|
13
16
|
|
|
14
17
|
/**
|
|
15
|
-
* Collects a submitted form's named control values into a plain object, keyed by `name`, in document order. Reads each control's live DOM state rather than `FormData`, so a checkbox resolves to its `checked` boolean instead of an on/off string. A radio group resolves to its checked option's `value`, or `undefined` if none is checked. Any other repeated `name` resolves to an array of its controls' values, in document order. Controls without a `name`, or that are
|
|
18
|
+
* Collects a submitted form's named control values into a plain object, keyed by `name`, in document order. Reads each control's live DOM state rather than `FormData`, so a checkbox resolves to its `checked` boolean instead of an on/off string. A radio group resolves to its checked option's `value`, or `undefined` if none is checked. Any other repeated `name` resolves to an array of its controls' values, in document order. Controls without a `name`, that carry `data-aui-generated-name`, or that are effectively disabled, including through an ancestor disabled fieldset outside its first legend, are skipped entirely.
|
|
16
19
|
*/
|
|
17
20
|
export function collectFormValues(
|
|
18
21
|
elements: ArrayLike<FormControlElementLike>,
|
|
@@ -21,7 +24,13 @@ export function collectFormValues(
|
|
|
21
24
|
|
|
22
25
|
for (const element of Array.from(elements)) {
|
|
23
26
|
const { name, disabled } = element;
|
|
24
|
-
if (
|
|
27
|
+
if (
|
|
28
|
+
!name ||
|
|
29
|
+
disabled ||
|
|
30
|
+
element.hasAttribute(GENERATED_NAME_ATTR) ||
|
|
31
|
+
element.matches?.(":disabled")
|
|
32
|
+
)
|
|
33
|
+
continue;
|
|
25
34
|
|
|
26
35
|
if (element.type === "radio") {
|
|
27
36
|
if (element.checked) values[name] = element.value;
|
|
@@ -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[] })
|