@assistant-ui/react-generative-ui 0.0.21 → 0.0.23
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/LICENSE +1 -1
- package/dist/JSONGenerativeUI.client.d.ts.map +1 -1
- package/dist/JSONGenerativeUI.client.js +30 -13
- package/dist/JSONGenerativeUI.client.js.map +1 -1
- package/dist/RadioGroupScope.d.ts +6 -0
- package/dist/RadioGroupScope.d.ts.map +1 -0
- package/dist/RadioGroupScope.js +25 -0
- package/dist/RadioGroupScope.js.map +1 -0
- package/dist/a2ui/BindingContext.d.ts +11 -0
- package/dist/a2ui/BindingContext.d.ts.map +1 -0
- package/dist/a2ui/BindingContext.js +12 -0
- package/dist/a2ui/BindingContext.js.map +1 -0
- package/dist/a2ui/PresentRenderer.d.ts +11 -0
- package/dist/a2ui/PresentRenderer.d.ts.map +1 -0
- package/dist/a2ui/PresentRenderer.js +39 -0
- package/dist/a2ui/PresentRenderer.js.map +1 -0
- package/dist/a2ui/convert.d.ts +6 -0
- package/dist/a2ui/convert.d.ts.map +1 -1
- package/dist/a2ui/convert.js +363 -85
- package/dist/a2ui/convert.js.map +1 -1
- package/dist/a2ui/dataModel.d.ts +9 -0
- package/dist/a2ui/dataModel.d.ts.map +1 -0
- package/dist/a2ui/dataModel.js +44 -0
- package/dist/a2ui/dataModel.js.map +1 -0
- package/dist/a2ui/reducer.d.ts +5 -0
- package/dist/a2ui/reducer.d.ts.map +1 -1
- package/dist/a2ui/reducer.js +3 -3
- package/dist/a2ui/reducer.js.map +1 -1
- package/dist/a2ui/types.d.ts +8 -2
- package/dist/a2ui/types.d.ts.map +1 -1
- package/dist/a2ui/types.js.map +1 -1
- package/dist/a2ui/valueFunctions.d.ts +16 -0
- package/dist/a2ui/valueFunctions.d.ts.map +1 -0
- package/dist/a2ui/valueFunctions.js +332 -0
- package/dist/a2ui/valueFunctions.js.map +1 -0
- package/dist/actionRegistry.d.ts +1 -1
- package/dist/actionRegistry.js.map +1 -1
- package/dist/answeredValues.d.ts +16 -0
- package/dist/answeredValues.d.ts.map +1 -0
- package/dist/answeredValues.js +37 -0
- package/dist/answeredValues.js.map +1 -0
- package/dist/constants.d.ts +3 -0
- package/dist/constants.d.ts.map +1 -1
- package/dist/constants.js +4 -1
- package/dist/constants.js.map +1 -1
- package/dist/fieldReferences.d.ts +9 -0
- package/dist/fieldReferences.d.ts.map +1 -0
- package/dist/fieldReferences.js +53 -0
- package/dist/fieldReferences.js.map +1 -0
- package/dist/generativeUIToJSX.js +3 -3
- package/dist/generativeUIToJSX.js.map +1 -1
- package/dist/index.js +2 -2
- package/dist/index.server.js +2 -2
- package/dist/index.shared.js +2 -2
- package/dist/ir.d.ts +1 -1
- package/dist/ir.js.map +1 -1
- package/dist/renderGenerativeUI.d.ts.map +1 -1
- package/dist/renderGenerativeUI.js +12 -1
- package/dist/renderGenerativeUI.js.map +1 -1
- package/dist/slack/constants.d.ts +2 -0
- package/dist/slack/constants.d.ts.map +1 -1
- package/dist/slack/constants.js +3 -1
- package/dist/slack/constants.js.map +1 -1
- package/dist/slack/decodeBlockAction.d.ts +3 -1
- package/dist/slack/decodeBlockAction.d.ts.map +1 -1
- package/dist/slack/decodeBlockAction.js +96 -7
- package/dist/slack/decodeBlockAction.js.map +1 -1
- package/dist/slack/fromSlackBlocks.d.ts.map +1 -1
- package/dist/slack/fromSlackBlocks.js +43 -3
- package/dist/slack/fromSlackBlocks.js.map +1 -1
- package/dist/slack/toSlackBlocks.d.ts.map +1 -1
- package/dist/slack/toSlackBlocks.js +244 -20
- package/dist/slack/toSlackBlocks.js.map +1 -1
- package/dist/slack/types.d.ts +24 -1
- package/dist/slack/types.d.ts.map +1 -1
- package/dist/slack.d.ts +1 -1
- package/dist/slack.d.ts.map +1 -1
- package/dist/teams/constants.d.ts +1 -1
- package/dist/teams/constants.d.ts.map +1 -1
- package/dist/teams/constants.js +1 -1
- package/dist/teams/constants.js.map +1 -1
- package/dist/teams/decodeSubmitData.d.ts +4 -0
- package/dist/teams/decodeSubmitData.d.ts.map +1 -1
- package/dist/teams/decodeSubmitData.js +29 -3
- package/dist/teams/decodeSubmitData.js.map +1 -1
- package/dist/teams/temporalId.d.ts +5 -0
- package/dist/teams/temporalId.d.ts.map +1 -0
- package/dist/teams/temporalId.js +28 -0
- package/dist/teams/temporalId.js.map +1 -0
- package/dist/teams/toAdaptiveCard.d.ts +5 -4
- package/dist/teams/toAdaptiveCard.d.ts.map +1 -1
- package/dist/teams/toAdaptiveCard.js +136 -18
- package/dist/teams/toAdaptiveCard.js.map +1 -1
- package/dist/teams/toTeamsAttachments.d.ts.map +1 -1
- package/dist/teams/toTeamsAttachments.js.map +1 -1
- package/dist/teams/types.d.ts +23 -2
- package/dist/teams/types.d.ts.map +1 -1
- package/dist/teams.d.ts +1 -1
- package/dist/teams.d.ts.map +1 -1
- package/dist/temporal.d.ts +33 -0
- package/dist/temporal.d.ts.map +1 -0
- package/dist/temporal.js +152 -0
- package/dist/temporal.js.map +1 -0
- package/dist/vocabulary/alert.d.ts +9 -2
- package/dist/vocabulary/alert.d.ts.map +1 -1
- package/dist/vocabulary/alert.js +116 -19
- package/dist/vocabulary/alert.js.map +1 -1
- package/dist/vocabulary/collectFormValues.d.ts +2 -1
- package/dist/vocabulary/collectFormValues.d.ts.map +1 -1
- package/dist/vocabulary/collectFormValues.js +15 -4
- package/dist/vocabulary/collectFormValues.js.map +1 -1
- package/dist/vocabulary/data.d.ts +22 -5
- package/dist/vocabulary/data.d.ts.map +1 -1
- package/dist/vocabulary/data.js +278 -83
- package/dist/vocabulary/data.js.map +1 -1
- package/dist/vocabulary/dispatch.d.ts +3 -2
- package/dist/vocabulary/dispatch.d.ts.map +1 -1
- package/dist/vocabulary/dispatch.js +33 -4
- package/dist/vocabulary/dispatch.js.map +1 -1
- package/dist/vocabulary/fact.d.ts +8 -1
- package/dist/vocabulary/fact.d.ts.map +1 -1
- package/dist/vocabulary/fact.js +37 -11
- package/dist/vocabulary/fact.js.map +1 -1
- package/dist/vocabulary/form.d.ts.map +1 -1
- package/dist/vocabulary/form.js +3 -3
- package/dist/vocabulary/form.js.map +1 -1
- package/dist/vocabulary/formatValue.d.ts +7 -0
- package/dist/vocabulary/formatValue.d.ts.map +1 -0
- package/dist/vocabulary/formatValue.js +76 -0
- package/dist/vocabulary/formatValue.js.map +1 -0
- package/dist/vocabulary/icon.d.ts +1 -1
- package/dist/vocabulary/interactive.d.ts +143 -8
- package/dist/vocabulary/interactive.d.ts.map +1 -1
- package/dist/vocabulary/interactive.js +458 -107
- package/dist/vocabulary/interactive.js.map +1 -1
- package/dist/vocabulary/layout.d.ts.map +1 -1
- package/dist/vocabulary/layout.js +4 -4
- package/dist/vocabulary/layout.js.map +1 -1
- package/dist/vocabulary/list.js +2 -2
- package/dist/vocabulary/list.js.map +1 -1
- package/dist/vocabulary/text.d.ts +1 -1
- package/package.json +8 -5
- package/src/JSONGenerativeUI.client.tsx +84 -40
- package/src/JSONGenerativeUI.test.tsx +964 -68
- package/src/RadioGroupScope.server.test.tsx +24 -0
- package/src/RadioGroupScope.tsx +34 -0
- package/src/a2ui/BindingContext.ts +26 -0
- package/src/a2ui/PresentRenderer.tsx +83 -0
- package/src/a2ui/convert.test.ts +2054 -229
- package/src/a2ui/convert.ts +665 -134
- package/src/a2ui/dataModel.ts +69 -0
- package/src/a2ui/reducer.test.ts +35 -0
- package/src/a2ui/reducer.ts +5 -4
- package/src/a2ui/types.ts +9 -2
- package/src/a2ui/valueFunctions.test.ts +271 -0
- package/src/a2ui/valueFunctions.ts +523 -0
- package/src/actionRegistry.ts +1 -1
- package/src/answeredValues.test.ts +33 -0
- package/src/answeredValues.tsx +70 -0
- package/src/constants.ts +5 -0
- package/src/fieldReferences.test.ts +66 -0
- package/src/fieldReferences.ts +83 -0
- package/src/generativeUIToJSX.test.ts +18 -0
- package/src/generativeUIToJSX.ts +4 -6
- package/src/index.server.test.ts +106 -0
- package/src/ir.ts +1 -1
- package/src/renderGenerativeUI.test.tsx +14 -5
- package/src/renderGenerativeUI.tsx +30 -1
- package/src/slack/a2uiSlack.test.ts +198 -0
- package/src/slack/constants.ts +3 -0
- package/src/slack/datePicker.test.ts +612 -0
- package/src/slack/decodeBlockAction.test.ts +98 -0
- package/src/slack/decodeBlockAction.ts +182 -16
- package/src/slack/fromSlackBlocks.test.ts +222 -3
- package/src/slack/fromSlackBlocks.ts +82 -2
- package/src/slack/toSlackBlocks.test.ts +682 -3
- package/src/slack/toSlackBlocks.ts +463 -25
- package/src/slack/types.ts +28 -0
- package/src/slack.ts +2 -0
- package/src/teams/constants.ts +1 -1
- package/src/teams/decodeSubmitData.test.ts +249 -0
- package/src/teams/decodeSubmitData.ts +57 -4
- package/src/teams/temporal.test.ts +766 -0
- package/src/teams/temporalId.ts +56 -0
- package/src/teams/toAdaptiveCard.test.ts +219 -0
- package/src/teams/toAdaptiveCard.ts +259 -21
- package/src/teams/toTeamsAttachments.ts +4 -1
- package/src/teams/types.ts +21 -1
- package/src/teams.ts +1 -0
- package/src/temporal.test.ts +319 -0
- package/src/temporal.ts +259 -0
- package/src/vocabulary/alert.test.tsx +288 -7
- package/src/vocabulary/alert.tsx +168 -26
- package/src/vocabulary/collectFormValues.test.ts +225 -1
- package/src/vocabulary/collectFormValues.ts +54 -5
- package/src/vocabulary/data.test.tsx +248 -6
- package/src/vocabulary/data.tsx +398 -100
- package/src/vocabulary/datepicker.dom.test.tsx +1148 -0
- package/src/vocabulary/dispatch.ts +63 -2
- package/src/vocabulary/fact.test.tsx +67 -0
- package/src/vocabulary/fact.tsx +40 -9
- package/src/vocabulary/form.tsx +7 -2
- package/src/vocabulary/formatValue.test.ts +72 -0
- package/src/vocabulary/formatValue.ts +127 -0
- package/src/vocabulary/index.test.ts +2 -0
- package/src/vocabulary/interactive.dispatch.test.tsx +167 -79
- package/src/vocabulary/interactive.dom.test.tsx +1747 -0
- package/src/vocabulary/interactive.test.tsx +60 -4
- package/src/vocabulary/interactive.tsx +910 -155
- package/src/vocabulary/layout.test.tsx +6 -4
- package/src/vocabulary/layout.tsx +13 -2
- package/src/vocabulary/list.tsx +2 -2
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { setAtPointer } from "./reducer";
|
|
2
|
+
|
|
3
|
+
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
|
4
|
+
typeof value === "object" && value !== null && !Array.isArray(value);
|
|
5
|
+
|
|
6
|
+
export const equalData = (left: unknown, right: unknown): boolean => {
|
|
7
|
+
if (Object.is(left, right)) return true;
|
|
8
|
+
if (Array.isArray(left) && Array.isArray(right)) {
|
|
9
|
+
return (
|
|
10
|
+
left.length === right.length &&
|
|
11
|
+
left.every((value, index) => equalData(value, right[index]))
|
|
12
|
+
);
|
|
13
|
+
}
|
|
14
|
+
if (!isRecord(left) || !isRecord(right)) return false;
|
|
15
|
+
const keys = Object.keys(left);
|
|
16
|
+
return (
|
|
17
|
+
keys.length === Object.keys(right).length &&
|
|
18
|
+
keys.every(
|
|
19
|
+
(key) => Object.hasOwn(right, key) && equalData(left[key], right[key]),
|
|
20
|
+
)
|
|
21
|
+
);
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export const decodePointer = (path: string): string[] => {
|
|
25
|
+
if (path === "" || path === "/") return [];
|
|
26
|
+
return (path.startsWith("/") ? path.slice(1) : path)
|
|
27
|
+
.split("/")
|
|
28
|
+
.map((segment) => segment.replaceAll("~1", "/").replaceAll("~0", "~"));
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
export const resolvePointer = (source: unknown, path: string): unknown => {
|
|
32
|
+
let current = source;
|
|
33
|
+
for (const segment of decodePointer(path)) {
|
|
34
|
+
if (Array.isArray(current)) {
|
|
35
|
+
if (!/^(0|[1-9]\d*)$/.test(segment)) return undefined;
|
|
36
|
+
current = current[Number(segment)];
|
|
37
|
+
continue;
|
|
38
|
+
}
|
|
39
|
+
if (!isRecord(current) || !Object.hasOwn(current, segment)) {
|
|
40
|
+
return undefined;
|
|
41
|
+
}
|
|
42
|
+
current = current[segment];
|
|
43
|
+
}
|
|
44
|
+
return current;
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
export const reconcileDataModel = (
|
|
48
|
+
previous: unknown,
|
|
49
|
+
incoming: unknown,
|
|
50
|
+
local: unknown,
|
|
51
|
+
editedPaths: ReadonlySet<string>,
|
|
52
|
+
) => {
|
|
53
|
+
let value = incoming;
|
|
54
|
+
const retained = new Set<string>();
|
|
55
|
+
for (const path of editedPaths) {
|
|
56
|
+
if (
|
|
57
|
+
equalData(resolvePointer(previous, path), resolvePointer(incoming, path))
|
|
58
|
+
) {
|
|
59
|
+
value = setAtPointer(
|
|
60
|
+
value,
|
|
61
|
+
path,
|
|
62
|
+
resolvePointer(local, path),
|
|
63
|
+
false,
|
|
64
|
+
).value;
|
|
65
|
+
retained.add(path);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return { incoming, value, editedPaths: retained };
|
|
69
|
+
};
|
package/src/a2ui/reducer.test.ts
CHANGED
|
@@ -100,6 +100,41 @@ describe("applyA2uiOperations", () => {
|
|
|
100
100
|
expect(result.state.get("properties")?.catalogId).toBe("basic");
|
|
101
101
|
});
|
|
102
102
|
|
|
103
|
+
it("applies v0.9.1 operations the way v0.9 does", () => {
|
|
104
|
+
const result = applyA2uiOperations(new Map(), [
|
|
105
|
+
{
|
|
106
|
+
version: "v0.9.1",
|
|
107
|
+
createSurface: { surfaceId: "main", catalogId: "basic" },
|
|
108
|
+
},
|
|
109
|
+
{
|
|
110
|
+
version: "v0.9.1",
|
|
111
|
+
updateComponents: {
|
|
112
|
+
surfaceId: "main",
|
|
113
|
+
components: [{ id: "root", component: "Text", text: "hello" }],
|
|
114
|
+
},
|
|
115
|
+
},
|
|
116
|
+
{
|
|
117
|
+
version: "v0.9.1",
|
|
118
|
+
updateDataModel: {
|
|
119
|
+
surfaceId: "main",
|
|
120
|
+
path: "/profile/name",
|
|
121
|
+
value: "Ada",
|
|
122
|
+
},
|
|
123
|
+
},
|
|
124
|
+
]);
|
|
125
|
+
|
|
126
|
+
expect(result.warnings).toEqual([]);
|
|
127
|
+
expect(result.state.get("main")).toMatchObject({
|
|
128
|
+
catalogId: "basic",
|
|
129
|
+
dataModel: { profile: { name: "Ada" } },
|
|
130
|
+
});
|
|
131
|
+
expect(result.state.get("main")?.components.get("root")).toEqual({
|
|
132
|
+
id: "root",
|
|
133
|
+
component: "Text",
|
|
134
|
+
text: "hello",
|
|
135
|
+
});
|
|
136
|
+
});
|
|
137
|
+
|
|
103
138
|
it.each(["10001", "4294967294"])(
|
|
104
139
|
"rejects array pointer index %s beyond the auto-vivification limit",
|
|
105
140
|
(index) => {
|
package/src/a2ui/reducer.ts
CHANGED
|
@@ -3,6 +3,7 @@ import {
|
|
|
3
3
|
type A2uiOperationResult,
|
|
4
4
|
type A2uiState,
|
|
5
5
|
type A2uiSurfaceState,
|
|
6
|
+
type A2uiVersion,
|
|
6
7
|
} from "./types";
|
|
7
8
|
|
|
8
9
|
const OPERATION_KEYS = new Set([
|
|
@@ -12,14 +13,14 @@ const OPERATION_KEYS = new Set([
|
|
|
12
13
|
"deleteSurface",
|
|
13
14
|
]);
|
|
14
15
|
// This defensive ceiling is well above the renderer's displayed-item limit.
|
|
15
|
-
const MAX_AUTO_VIVIFY_ARRAY_INDEX = 10_000;
|
|
16
|
+
export const MAX_AUTO_VIVIFY_ARRAY_INDEX = 10_000;
|
|
16
17
|
const INVALID_POINTER = Symbol("invalidPointer");
|
|
17
18
|
|
|
18
19
|
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
|
19
20
|
typeof value === "object" && value !== null && !Array.isArray(value);
|
|
20
21
|
|
|
21
|
-
const isVersion = (value: unknown): value is
|
|
22
|
-
value === "v0.9" || value === "v1.0";
|
|
22
|
+
const isVersion = (value: unknown): value is A2uiVersion =>
|
|
23
|
+
value === "v0.9" || value === "v0.9.1" || value === "v1.0";
|
|
23
24
|
|
|
24
25
|
const surfaceIdOf = (payload: Record<string, unknown>): string | undefined => {
|
|
25
26
|
const surfaceId = payload["surfaceId"];
|
|
@@ -117,7 +118,7 @@ const decodePointer = (path: string): string[] | undefined => {
|
|
|
117
118
|
const isArrayIndex = (segment: string): boolean =>
|
|
118
119
|
segment === "0" || /^[1-9]\d*$/.test(segment);
|
|
119
120
|
|
|
120
|
-
const setAtPointer = (
|
|
121
|
+
export const setAtPointer = (
|
|
121
122
|
model: unknown,
|
|
122
123
|
path: string,
|
|
123
124
|
value: unknown,
|
package/src/a2ui/types.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
export const A2UI_SURFACE_ID = Symbol("a2uiSurfaceId");
|
|
2
2
|
|
|
3
|
-
export type A2uiVersion = "v0.9" | "v1.0";
|
|
3
|
+
export type A2uiVersion = "v0.9" | "v0.9.1" | "v1.0";
|
|
4
4
|
|
|
5
5
|
export interface A2uiTemplateChildren {
|
|
6
6
|
readonly template: {
|
|
@@ -12,7 +12,10 @@ export interface A2uiTemplateChildren {
|
|
|
12
12
|
export interface ComponentNode extends Record<string, unknown> {
|
|
13
13
|
readonly id: string;
|
|
14
14
|
readonly component: string;
|
|
15
|
-
readonly children?:
|
|
15
|
+
readonly children?:
|
|
16
|
+
| readonly string[]
|
|
17
|
+
| { readonly componentId: string; readonly path: string }
|
|
18
|
+
| A2uiTemplateChildren;
|
|
16
19
|
}
|
|
17
20
|
|
|
18
21
|
export interface A2uiCreateSurfaceV09Payload {
|
|
@@ -53,6 +56,10 @@ export type A2uiCreateSurfaceOperation =
|
|
|
53
56
|
readonly version: "v0.9";
|
|
54
57
|
readonly createSurface: A2uiCreateSurfaceV09Payload;
|
|
55
58
|
}
|
|
59
|
+
| {
|
|
60
|
+
readonly version: "v0.9.1";
|
|
61
|
+
readonly createSurface: A2uiCreateSurfaceV09Payload;
|
|
62
|
+
}
|
|
56
63
|
| {
|
|
57
64
|
readonly version: "v1.0";
|
|
58
65
|
readonly createSurface: A2uiCreateSurfaceV10Payload;
|
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
ExpressionSyntaxError,
|
|
4
|
+
evaluateA2uiValueFunction,
|
|
5
|
+
parseExpressionTemplate,
|
|
6
|
+
type ExpressionPart,
|
|
7
|
+
} from "./valueFunctions";
|
|
8
|
+
|
|
9
|
+
const joinLiterals = (parts: ExpressionPart[]) =>
|
|
10
|
+
parts.reduce<ExpressionPart[]>((joined, part) => {
|
|
11
|
+
const last = joined.at(-1);
|
|
12
|
+
if (typeof part === "string" && typeof last === "string") {
|
|
13
|
+
joined[joined.length - 1] = last + part;
|
|
14
|
+
} else {
|
|
15
|
+
joined.push(part);
|
|
16
|
+
}
|
|
17
|
+
return joined;
|
|
18
|
+
}, []);
|
|
19
|
+
|
|
20
|
+
// The parse_expression_template cases of a2ui-project/a2ui conformance/core/expressions.yaml.
|
|
21
|
+
const PARSE_CASES: [string, ExpressionPart[]][] = [
|
|
22
|
+
["hello world", ["hello world"]],
|
|
23
|
+
["", []],
|
|
24
|
+
["${user}", [{ path: "user" }]],
|
|
25
|
+
["value is ${/user/name}", ["value is ", { path: "/user/name" }]],
|
|
26
|
+
["${my-path.with_underscores}", [{ path: "my-path.with_underscores" }]],
|
|
27
|
+
["a ${x} b ${y} c", ["a ", { path: "x" }, " b ", { path: "y" }, " c"]],
|
|
28
|
+
["${x}${y}", [{ path: "x" }, { path: "y" }]],
|
|
29
|
+
["${'hello world'}", ["hello world"]],
|
|
30
|
+
['${"hello world"}', ["hello world"]],
|
|
31
|
+
["${'line\\nbreak'}", ["line\nbreak"]],
|
|
32
|
+
["${'it\\'s'}", ["it's"]],
|
|
33
|
+
["${42}", [42]],
|
|
34
|
+
["${1.5}", [1.5]],
|
|
35
|
+
["${007}", [7]],
|
|
36
|
+
["${true} ${false}", [true, " ", false]],
|
|
37
|
+
["${trueish}", [{ path: "trueish" }]],
|
|
38
|
+
["${now()}", [{ call: "now", args: {} }]],
|
|
39
|
+
[
|
|
40
|
+
"sum is ${add(a: 10, b: 20)}",
|
|
41
|
+
["sum is ", { call: "add", args: { a: 10, b: 20 } }],
|
|
42
|
+
],
|
|
43
|
+
[
|
|
44
|
+
"${format(value: /amount, suffix: ' USD', fallback: zero())}",
|
|
45
|
+
[
|
|
46
|
+
{
|
|
47
|
+
call: "format",
|
|
48
|
+
args: {
|
|
49
|
+
value: { path: "/amount" },
|
|
50
|
+
suffix: " USD",
|
|
51
|
+
fallback: { call: "zero", args: {} },
|
|
52
|
+
},
|
|
53
|
+
},
|
|
54
|
+
],
|
|
55
|
+
],
|
|
56
|
+
[
|
|
57
|
+
"${outer(a: inner(b: 1))}",
|
|
58
|
+
[{ call: "outer", args: { a: { call: "inner", args: { b: 1 } } } }],
|
|
59
|
+
],
|
|
60
|
+
["${${'hello'}}", ["hello"]],
|
|
61
|
+
["${ /user/name }", [{ path: "/user/name" }]],
|
|
62
|
+
["\\${not_interpolated}", ["${not_interpolated}"]],
|
|
63
|
+
["before \\${x} after", ["before ${x} after"]],
|
|
64
|
+
["\\${literal} ${real}", ["${literal} ", { path: "real" }]],
|
|
65
|
+
["${1.}", [1]],
|
|
66
|
+
];
|
|
67
|
+
|
|
68
|
+
const MALFORMED_TEMPLATES = [
|
|
69
|
+
"hello ${world",
|
|
70
|
+
"${add(a: 1, b: 2}",
|
|
71
|
+
"${add(a 1)}",
|
|
72
|
+
"${true false}",
|
|
73
|
+
"${1.2.3}",
|
|
74
|
+
];
|
|
75
|
+
|
|
76
|
+
describe("parseExpressionTemplate", () => {
|
|
77
|
+
it.each(PARSE_CASES)("parses %j", (template, expected) => {
|
|
78
|
+
expect(joinLiterals(parseExpressionTemplate(template))).toEqual(expected);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it.each(MALFORMED_TEMPLATES)("rejects %j", (template) => {
|
|
82
|
+
expect(() => parseExpressionTemplate(template)).toThrow(
|
|
83
|
+
ExpressionSyntaxError,
|
|
84
|
+
);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("rejects templates past the length and nesting caps", () => {
|
|
88
|
+
expect(() => parseExpressionTemplate("x".repeat(10_001))).toThrow(
|
|
89
|
+
ExpressionSyntaxError,
|
|
90
|
+
);
|
|
91
|
+
expect(() =>
|
|
92
|
+
parseExpressionTemplate(`${"${".repeat(40)}1${"}".repeat(40)}`),
|
|
93
|
+
).toThrow(ExpressionSyntaxError);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it("accepts an empty interpolation, a trailing argument comma, and any whitespace", () => {
|
|
97
|
+
expect(joinLiterals(parseExpressionTemplate("a${}b"))).toEqual(["ab"]);
|
|
98
|
+
expect(parseExpressionTemplate("${add(a: 1, )}")).toEqual([
|
|
99
|
+
{ call: "add", args: { a: 1 } },
|
|
100
|
+
]);
|
|
101
|
+
expect(parseExpressionTemplate("${ /x }")).toEqual([{ path: "/x" }]);
|
|
102
|
+
expect(() => parseExpressionTemplate("${add(a: 1 x)}")).toThrow(
|
|
103
|
+
ExpressionSyntaxError,
|
|
104
|
+
);
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
const evaluate = (
|
|
109
|
+
name: string,
|
|
110
|
+
args: Record<string, unknown>,
|
|
111
|
+
data: Record<string, unknown> = {},
|
|
112
|
+
) => {
|
|
113
|
+
const warnings: string[] = [];
|
|
114
|
+
const value = evaluateA2uiValueFunction(name, args, {
|
|
115
|
+
resolve: (part) =>
|
|
116
|
+
typeof part === "object" && part !== null && "path" in part
|
|
117
|
+
? data[part.path]
|
|
118
|
+
: undefined,
|
|
119
|
+
warn: (message) => warnings.push(message),
|
|
120
|
+
templates: new Map(),
|
|
121
|
+
});
|
|
122
|
+
return { value, warnings };
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
const dateName = (
|
|
126
|
+
date: Date,
|
|
127
|
+
options: Intl.DateTimeFormatOptions,
|
|
128
|
+
type: Intl.DateTimeFormatPartTypes,
|
|
129
|
+
) =>
|
|
130
|
+
new Intl.DateTimeFormat(undefined, options)
|
|
131
|
+
.formatToParts(date)
|
|
132
|
+
.find((part) => part.type === type)!.value;
|
|
133
|
+
|
|
134
|
+
describe("evaluateA2uiValueFunction", () => {
|
|
135
|
+
it("interpolates formatString paths and coerces values to text", () => {
|
|
136
|
+
expect(
|
|
137
|
+
evaluate(
|
|
138
|
+
"formatString",
|
|
139
|
+
{ value: "Hi ${/name}, ${count} new ${/flag} ${/missing}|${/tags}" },
|
|
140
|
+
{ "/name": "Ada", count: 3, "/flag": true, "/tags": ["a", "b"] },
|
|
141
|
+
),
|
|
142
|
+
).toEqual({ value: 'Hi Ada, 3 new true |["a","b"]', warnings: [] });
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it("warns and skips a malformed formatString template", () => {
|
|
146
|
+
expect(evaluate("formatString", { value: "Hi ${name" })).toEqual({
|
|
147
|
+
value: undefined,
|
|
148
|
+
warnings: [
|
|
149
|
+
"A2UI formatString template is malformed: an interpolation is not closed.",
|
|
150
|
+
],
|
|
151
|
+
});
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it("formats numbers and currencies with the requested decimals and grouping", () => {
|
|
155
|
+
const number = (options: Intl.NumberFormatOptions, value: number) =>
|
|
156
|
+
new Intl.NumberFormat(undefined, options).format(value);
|
|
157
|
+
|
|
158
|
+
expect(evaluate("formatNumber", { value: 1234.5, decimals: 2 })).toEqual({
|
|
159
|
+
value: number(
|
|
160
|
+
{ minimumFractionDigits: 2, maximumFractionDigits: 2 },
|
|
161
|
+
1234.5,
|
|
162
|
+
),
|
|
163
|
+
warnings: [],
|
|
164
|
+
});
|
|
165
|
+
expect(
|
|
166
|
+
evaluate("formatNumber", { value: "1500", grouping: false }).value,
|
|
167
|
+
).toBe(number({ useGrouping: false }, 1500));
|
|
168
|
+
expect(
|
|
169
|
+
evaluate("formatCurrency", { value: 12.5, currency: "USD" }).value,
|
|
170
|
+
).toBe(number({ style: "currency", currency: "USD" }, 12.5));
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it("formats a missing amount as empty text and warns on invalid arguments", () => {
|
|
174
|
+
expect(evaluate("formatNumber", { value: undefined })).toEqual({
|
|
175
|
+
value: "",
|
|
176
|
+
warnings: [],
|
|
177
|
+
});
|
|
178
|
+
expect(evaluate("formatNumber", { value: "abc" })).toEqual({
|
|
179
|
+
value: undefined,
|
|
180
|
+
warnings: ['A2UI function "formatNumber" received invalid arguments.'],
|
|
181
|
+
});
|
|
182
|
+
expect(
|
|
183
|
+
evaluate("formatNumber", { value: 1, decimals: 1.5 }).warnings,
|
|
184
|
+
).toEqual(['A2UI function "formatNumber" received invalid arguments.']);
|
|
185
|
+
expect(evaluate("formatCurrency", { value: 1 }).warnings).toEqual([
|
|
186
|
+
'A2UI function "formatCurrency" received invalid arguments.',
|
|
187
|
+
]);
|
|
188
|
+
expect(
|
|
189
|
+
evaluate("formatCurrency", { value: 1, currency: "not a code" }).warnings,
|
|
190
|
+
).toEqual(['A2UI function "formatCurrency" received invalid arguments.']);
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
it("formats dates with TR35 patterns, quoted literals, and padded fields", () => {
|
|
194
|
+
const value = "2026-01-16T14:05:09";
|
|
195
|
+
const date = new Date(value);
|
|
196
|
+
const pm = dateName(date, { hour: "numeric", hour12: true }, "dayPeriod");
|
|
197
|
+
const format = (pattern: string) =>
|
|
198
|
+
evaluate("formatDate", { value, format: pattern }).value;
|
|
199
|
+
|
|
200
|
+
expect(format("h:mm a")).toBe(`2:05 ${pm}`);
|
|
201
|
+
expect(format("HH:mm:ss")).toBe("14:05:09");
|
|
202
|
+
expect(format("yy/M/d, yyyy-MM-dd")).toBe("26/1/16, 2026-01-16");
|
|
203
|
+
expect(format("EEEE, MMM d 'at' h:mm a")).toBe(
|
|
204
|
+
`${dateName(date, { weekday: "long" }, "weekday")}, ${dateName(date, { month: "short" }, "month")} 16 at 2:05 ${pm}`,
|
|
205
|
+
);
|
|
206
|
+
expect(format("MMMM d, E")).toBe(
|
|
207
|
+
`${dateName(date, { month: "long" }, "month")} 16, ${dateName(date, { weekday: "short" }, "weekday")}`,
|
|
208
|
+
);
|
|
209
|
+
expect(format("h 'o''clock'")).toBe("2 o'clock");
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
it("reads a date-only value as a local calendar date and rejects one out of range", () => {
|
|
213
|
+
expect(
|
|
214
|
+
evaluate("formatDate", {
|
|
215
|
+
value: "2026-01-16",
|
|
216
|
+
format: "yyyy-MM-dd HH:mm",
|
|
217
|
+
}).value,
|
|
218
|
+
).toBe("2026-01-16 00:00");
|
|
219
|
+
expect(
|
|
220
|
+
evaluate("formatDate", { value: "2026-02-30", format: "d" }).warnings,
|
|
221
|
+
).toEqual(['A2UI function "formatDate" received invalid arguments.']);
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
it("falls back to an ISO date for an unsupported pattern and warns on an invalid date", () => {
|
|
225
|
+
const value = "2026-01-16T14:05:09";
|
|
226
|
+
expect(evaluate("formatDate", { value, format: "YYYY" })).toEqual({
|
|
227
|
+
value: new Date(value).toISOString(),
|
|
228
|
+
warnings: [
|
|
229
|
+
'A2UI function "formatDate" does not support the pattern "YYYY", so it returned an ISO date.',
|
|
230
|
+
],
|
|
231
|
+
});
|
|
232
|
+
expect(
|
|
233
|
+
evaluate("formatDate", { value: "not a date", format: "d" }).warnings,
|
|
234
|
+
).toEqual(['A2UI function "formatDate" received invalid arguments.']);
|
|
235
|
+
expect(evaluate("formatDate", { value: undefined, format: "d" })).toEqual({
|
|
236
|
+
value: "",
|
|
237
|
+
warnings: [],
|
|
238
|
+
});
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
it("selects a plural category and falls back to other", () => {
|
|
242
|
+
const category = new Intl.PluralRules().select(1);
|
|
243
|
+
expect(
|
|
244
|
+
evaluate("pluralize", {
|
|
245
|
+
value: 1,
|
|
246
|
+
[category]: "1 review",
|
|
247
|
+
other: "reviews",
|
|
248
|
+
}).value,
|
|
249
|
+
).toBe("1 review");
|
|
250
|
+
expect(evaluate("pluralize", { value: 2, other: "reviews" }).value).toBe(
|
|
251
|
+
"reviews",
|
|
252
|
+
);
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
it("evaluates boolean functions by truthiness", () => {
|
|
256
|
+
expect(evaluate("and", { values: [true, 1] }).value).toBe(true);
|
|
257
|
+
expect(evaluate("and", { values: [true, ""] }).value).toBe(false);
|
|
258
|
+
expect(evaluate("or", { values: [false, "x"] }).value).toBe(true);
|
|
259
|
+
expect(evaluate("not", { value: 0 }).value).toBe(true);
|
|
260
|
+
expect(evaluate("not", { value: undefined }).value).toBe(true);
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
it("warns instead of evaluating a function it does not support", () => {
|
|
264
|
+
expect(evaluate("customValue", {})).toEqual({
|
|
265
|
+
value: undefined,
|
|
266
|
+
warnings: [
|
|
267
|
+
'A2UI function "customValue" is not supported and was skipped.',
|
|
268
|
+
],
|
|
269
|
+
});
|
|
270
|
+
});
|
|
271
|
+
});
|