@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.
Files changed (212) hide show
  1. package/LICENSE +1 -1
  2. package/dist/JSONGenerativeUI.client.d.ts.map +1 -1
  3. package/dist/JSONGenerativeUI.client.js +30 -13
  4. package/dist/JSONGenerativeUI.client.js.map +1 -1
  5. package/dist/RadioGroupScope.d.ts +6 -0
  6. package/dist/RadioGroupScope.d.ts.map +1 -0
  7. package/dist/RadioGroupScope.js +25 -0
  8. package/dist/RadioGroupScope.js.map +1 -0
  9. package/dist/a2ui/BindingContext.d.ts +11 -0
  10. package/dist/a2ui/BindingContext.d.ts.map +1 -0
  11. package/dist/a2ui/BindingContext.js +12 -0
  12. package/dist/a2ui/BindingContext.js.map +1 -0
  13. package/dist/a2ui/PresentRenderer.d.ts +11 -0
  14. package/dist/a2ui/PresentRenderer.d.ts.map +1 -0
  15. package/dist/a2ui/PresentRenderer.js +39 -0
  16. package/dist/a2ui/PresentRenderer.js.map +1 -0
  17. package/dist/a2ui/convert.d.ts +6 -0
  18. package/dist/a2ui/convert.d.ts.map +1 -1
  19. package/dist/a2ui/convert.js +363 -85
  20. package/dist/a2ui/convert.js.map +1 -1
  21. package/dist/a2ui/dataModel.d.ts +9 -0
  22. package/dist/a2ui/dataModel.d.ts.map +1 -0
  23. package/dist/a2ui/dataModel.js +44 -0
  24. package/dist/a2ui/dataModel.js.map +1 -0
  25. package/dist/a2ui/reducer.d.ts +5 -0
  26. package/dist/a2ui/reducer.d.ts.map +1 -1
  27. package/dist/a2ui/reducer.js +3 -3
  28. package/dist/a2ui/reducer.js.map +1 -1
  29. package/dist/a2ui/types.d.ts +8 -2
  30. package/dist/a2ui/types.d.ts.map +1 -1
  31. package/dist/a2ui/types.js.map +1 -1
  32. package/dist/a2ui/valueFunctions.d.ts +16 -0
  33. package/dist/a2ui/valueFunctions.d.ts.map +1 -0
  34. package/dist/a2ui/valueFunctions.js +332 -0
  35. package/dist/a2ui/valueFunctions.js.map +1 -0
  36. package/dist/actionRegistry.d.ts +1 -1
  37. package/dist/actionRegistry.js.map +1 -1
  38. package/dist/answeredValues.d.ts +16 -0
  39. package/dist/answeredValues.d.ts.map +1 -0
  40. package/dist/answeredValues.js +37 -0
  41. package/dist/answeredValues.js.map +1 -0
  42. package/dist/constants.d.ts +3 -0
  43. package/dist/constants.d.ts.map +1 -1
  44. package/dist/constants.js +4 -1
  45. package/dist/constants.js.map +1 -1
  46. package/dist/fieldReferences.d.ts +9 -0
  47. package/dist/fieldReferences.d.ts.map +1 -0
  48. package/dist/fieldReferences.js +53 -0
  49. package/dist/fieldReferences.js.map +1 -0
  50. package/dist/generativeUIToJSX.js +3 -3
  51. package/dist/generativeUIToJSX.js.map +1 -1
  52. package/dist/index.js +2 -2
  53. package/dist/index.server.js +2 -2
  54. package/dist/index.shared.js +2 -2
  55. package/dist/ir.d.ts +1 -1
  56. package/dist/ir.js.map +1 -1
  57. package/dist/renderGenerativeUI.d.ts.map +1 -1
  58. package/dist/renderGenerativeUI.js +12 -1
  59. package/dist/renderGenerativeUI.js.map +1 -1
  60. package/dist/slack/constants.d.ts +2 -0
  61. package/dist/slack/constants.d.ts.map +1 -1
  62. package/dist/slack/constants.js +3 -1
  63. package/dist/slack/constants.js.map +1 -1
  64. package/dist/slack/decodeBlockAction.d.ts +3 -1
  65. package/dist/slack/decodeBlockAction.d.ts.map +1 -1
  66. package/dist/slack/decodeBlockAction.js +96 -7
  67. package/dist/slack/decodeBlockAction.js.map +1 -1
  68. package/dist/slack/fromSlackBlocks.d.ts.map +1 -1
  69. package/dist/slack/fromSlackBlocks.js +43 -3
  70. package/dist/slack/fromSlackBlocks.js.map +1 -1
  71. package/dist/slack/toSlackBlocks.d.ts.map +1 -1
  72. package/dist/slack/toSlackBlocks.js +244 -20
  73. package/dist/slack/toSlackBlocks.js.map +1 -1
  74. package/dist/slack/types.d.ts +24 -1
  75. package/dist/slack/types.d.ts.map +1 -1
  76. package/dist/slack.d.ts +1 -1
  77. package/dist/slack.d.ts.map +1 -1
  78. package/dist/teams/constants.d.ts +1 -1
  79. package/dist/teams/constants.d.ts.map +1 -1
  80. package/dist/teams/constants.js +1 -1
  81. package/dist/teams/constants.js.map +1 -1
  82. package/dist/teams/decodeSubmitData.d.ts +4 -0
  83. package/dist/teams/decodeSubmitData.d.ts.map +1 -1
  84. package/dist/teams/decodeSubmitData.js +29 -3
  85. package/dist/teams/decodeSubmitData.js.map +1 -1
  86. package/dist/teams/temporalId.d.ts +5 -0
  87. package/dist/teams/temporalId.d.ts.map +1 -0
  88. package/dist/teams/temporalId.js +28 -0
  89. package/dist/teams/temporalId.js.map +1 -0
  90. package/dist/teams/toAdaptiveCard.d.ts +5 -4
  91. package/dist/teams/toAdaptiveCard.d.ts.map +1 -1
  92. package/dist/teams/toAdaptiveCard.js +136 -18
  93. package/dist/teams/toAdaptiveCard.js.map +1 -1
  94. package/dist/teams/toTeamsAttachments.d.ts.map +1 -1
  95. package/dist/teams/toTeamsAttachments.js.map +1 -1
  96. package/dist/teams/types.d.ts +23 -2
  97. package/dist/teams/types.d.ts.map +1 -1
  98. package/dist/teams.d.ts +1 -1
  99. package/dist/teams.d.ts.map +1 -1
  100. package/dist/temporal.d.ts +33 -0
  101. package/dist/temporal.d.ts.map +1 -0
  102. package/dist/temporal.js +152 -0
  103. package/dist/temporal.js.map +1 -0
  104. package/dist/vocabulary/alert.d.ts +9 -2
  105. package/dist/vocabulary/alert.d.ts.map +1 -1
  106. package/dist/vocabulary/alert.js +116 -19
  107. package/dist/vocabulary/alert.js.map +1 -1
  108. package/dist/vocabulary/collectFormValues.d.ts +2 -1
  109. package/dist/vocabulary/collectFormValues.d.ts.map +1 -1
  110. package/dist/vocabulary/collectFormValues.js +15 -4
  111. package/dist/vocabulary/collectFormValues.js.map +1 -1
  112. package/dist/vocabulary/data.d.ts +22 -5
  113. package/dist/vocabulary/data.d.ts.map +1 -1
  114. package/dist/vocabulary/data.js +278 -83
  115. package/dist/vocabulary/data.js.map +1 -1
  116. package/dist/vocabulary/dispatch.d.ts +3 -2
  117. package/dist/vocabulary/dispatch.d.ts.map +1 -1
  118. package/dist/vocabulary/dispatch.js +33 -4
  119. package/dist/vocabulary/dispatch.js.map +1 -1
  120. package/dist/vocabulary/fact.d.ts +8 -1
  121. package/dist/vocabulary/fact.d.ts.map +1 -1
  122. package/dist/vocabulary/fact.js +37 -11
  123. package/dist/vocabulary/fact.js.map +1 -1
  124. package/dist/vocabulary/form.d.ts.map +1 -1
  125. package/dist/vocabulary/form.js +3 -3
  126. package/dist/vocabulary/form.js.map +1 -1
  127. package/dist/vocabulary/formatValue.d.ts +7 -0
  128. package/dist/vocabulary/formatValue.d.ts.map +1 -0
  129. package/dist/vocabulary/formatValue.js +76 -0
  130. package/dist/vocabulary/formatValue.js.map +1 -0
  131. package/dist/vocabulary/icon.d.ts +1 -1
  132. package/dist/vocabulary/interactive.d.ts +143 -8
  133. package/dist/vocabulary/interactive.d.ts.map +1 -1
  134. package/dist/vocabulary/interactive.js +458 -107
  135. package/dist/vocabulary/interactive.js.map +1 -1
  136. package/dist/vocabulary/layout.d.ts.map +1 -1
  137. package/dist/vocabulary/layout.js +4 -4
  138. package/dist/vocabulary/layout.js.map +1 -1
  139. package/dist/vocabulary/list.js +2 -2
  140. package/dist/vocabulary/list.js.map +1 -1
  141. package/dist/vocabulary/text.d.ts +1 -1
  142. package/package.json +8 -5
  143. package/src/JSONGenerativeUI.client.tsx +84 -40
  144. package/src/JSONGenerativeUI.test.tsx +964 -68
  145. package/src/RadioGroupScope.server.test.tsx +24 -0
  146. package/src/RadioGroupScope.tsx +34 -0
  147. package/src/a2ui/BindingContext.ts +26 -0
  148. package/src/a2ui/PresentRenderer.tsx +83 -0
  149. package/src/a2ui/convert.test.ts +2054 -229
  150. package/src/a2ui/convert.ts +665 -134
  151. package/src/a2ui/dataModel.ts +69 -0
  152. package/src/a2ui/reducer.test.ts +35 -0
  153. package/src/a2ui/reducer.ts +5 -4
  154. package/src/a2ui/types.ts +9 -2
  155. package/src/a2ui/valueFunctions.test.ts +271 -0
  156. package/src/a2ui/valueFunctions.ts +523 -0
  157. package/src/actionRegistry.ts +1 -1
  158. package/src/answeredValues.test.ts +33 -0
  159. package/src/answeredValues.tsx +70 -0
  160. package/src/constants.ts +5 -0
  161. package/src/fieldReferences.test.ts +66 -0
  162. package/src/fieldReferences.ts +83 -0
  163. package/src/generativeUIToJSX.test.ts +18 -0
  164. package/src/generativeUIToJSX.ts +4 -6
  165. package/src/index.server.test.ts +106 -0
  166. package/src/ir.ts +1 -1
  167. package/src/renderGenerativeUI.test.tsx +14 -5
  168. package/src/renderGenerativeUI.tsx +30 -1
  169. package/src/slack/a2uiSlack.test.ts +198 -0
  170. package/src/slack/constants.ts +3 -0
  171. package/src/slack/datePicker.test.ts +612 -0
  172. package/src/slack/decodeBlockAction.test.ts +98 -0
  173. package/src/slack/decodeBlockAction.ts +182 -16
  174. package/src/slack/fromSlackBlocks.test.ts +222 -3
  175. package/src/slack/fromSlackBlocks.ts +82 -2
  176. package/src/slack/toSlackBlocks.test.ts +682 -3
  177. package/src/slack/toSlackBlocks.ts +463 -25
  178. package/src/slack/types.ts +28 -0
  179. package/src/slack.ts +2 -0
  180. package/src/teams/constants.ts +1 -1
  181. package/src/teams/decodeSubmitData.test.ts +249 -0
  182. package/src/teams/decodeSubmitData.ts +57 -4
  183. package/src/teams/temporal.test.ts +766 -0
  184. package/src/teams/temporalId.ts +56 -0
  185. package/src/teams/toAdaptiveCard.test.ts +219 -0
  186. package/src/teams/toAdaptiveCard.ts +259 -21
  187. package/src/teams/toTeamsAttachments.ts +4 -1
  188. package/src/teams/types.ts +21 -1
  189. package/src/teams.ts +1 -0
  190. package/src/temporal.test.ts +319 -0
  191. package/src/temporal.ts +259 -0
  192. package/src/vocabulary/alert.test.tsx +288 -7
  193. package/src/vocabulary/alert.tsx +168 -26
  194. package/src/vocabulary/collectFormValues.test.ts +225 -1
  195. package/src/vocabulary/collectFormValues.ts +54 -5
  196. package/src/vocabulary/data.test.tsx +248 -6
  197. package/src/vocabulary/data.tsx +398 -100
  198. package/src/vocabulary/datepicker.dom.test.tsx +1148 -0
  199. package/src/vocabulary/dispatch.ts +63 -2
  200. package/src/vocabulary/fact.test.tsx +67 -0
  201. package/src/vocabulary/fact.tsx +40 -9
  202. package/src/vocabulary/form.tsx +7 -2
  203. package/src/vocabulary/formatValue.test.ts +72 -0
  204. package/src/vocabulary/formatValue.ts +127 -0
  205. package/src/vocabulary/index.test.ts +2 -0
  206. package/src/vocabulary/interactive.dispatch.test.tsx +167 -79
  207. package/src/vocabulary/interactive.dom.test.tsx +1747 -0
  208. package/src/vocabulary/interactive.test.tsx +60 -4
  209. package/src/vocabulary/interactive.tsx +910 -155
  210. package/src/vocabulary/layout.test.tsx +6 -4
  211. package/src/vocabulary/layout.tsx +13 -2
  212. package/src/vocabulary/list.tsx +2 -2
@@ -1,5 +1,15 @@
1
1
  import type { UIElement } from "../ir";
2
2
 
3
+ export type FieldMapping = {
4
+ readonly actionId: string;
5
+ readonly name: string;
6
+ readonly component: string;
7
+ readonly inputType?: "time" | "datetime";
8
+ readonly offset?: string;
9
+ readonly part?: "date" | "time";
10
+ readonly pairId?: number;
11
+ };
12
+
3
13
  /** A Slack plain-text composition object. */
4
14
  export interface SlackPlainText {
5
15
  readonly type: "plain_text";
@@ -35,6 +45,7 @@ export interface SlackStaticSelectElement {
35
45
  readonly type: "static_select";
36
46
  readonly action_id: string;
37
47
  readonly options: readonly SlackOption[];
48
+ readonly initial_option?: SlackOption;
38
49
  readonly placeholder?: SlackPlainText;
39
50
  }
40
51
 
@@ -45,6 +56,18 @@ export interface SlackDatePickerElement {
45
56
  readonly initial_date?: string;
46
57
  }
47
58
 
59
+ export interface SlackTimePickerElement {
60
+ readonly type: "timepicker";
61
+ readonly action_id: string;
62
+ readonly initial_time?: string;
63
+ }
64
+
65
+ export interface SlackDateTimePickerElement {
66
+ readonly type: "datetimepicker";
67
+ readonly action_id: string;
68
+ readonly initial_date_time?: number;
69
+ }
70
+
48
71
  /** A Slack checkbox-group element. */
49
72
  export interface SlackCheckboxesElement {
50
73
  readonly type: "checkboxes";
@@ -66,6 +89,7 @@ export interface SlackPlainTextInputElement {
66
89
  readonly type: "plain_text_input";
67
90
  readonly action_id: string;
68
91
  readonly multiline?: boolean;
92
+ readonly initial_value?: string;
69
93
  readonly placeholder?: SlackPlainText;
70
94
  }
71
95
 
@@ -74,6 +98,8 @@ export type SlackActionElement =
74
98
  | SlackButtonElement
75
99
  | SlackStaticSelectElement
76
100
  | SlackDatePickerElement
101
+ | SlackTimePickerElement
102
+ | SlackDateTimePickerElement
77
103
  | SlackCheckboxesElement
78
104
  | SlackRadioButtonsElement;
79
105
 
@@ -112,12 +138,14 @@ export interface SlackDividerBlock {
112
138
  /** A Slack actions block. */
113
139
  export interface SlackActionsBlock {
114
140
  readonly type: "actions";
141
+ readonly block_id?: string;
115
142
  readonly elements: readonly SlackActionElement[];
116
143
  }
117
144
 
118
145
  /** A Slack input block. */
119
146
  export interface SlackInputBlock {
120
147
  readonly type: "input";
148
+ readonly block_id?: string;
121
149
  readonly label: SlackPlainText;
122
150
  readonly element: SlackPlainTextInputElement;
123
151
  }
package/src/slack.ts CHANGED
@@ -20,6 +20,7 @@ export type {
20
20
  SlackDataTableRawNumberCell,
21
21
  SlackDataTableRawTextCell,
22
22
  SlackDatePickerElement,
23
+ SlackDateTimePickerElement,
23
24
  SlackDividerBlock,
24
25
  SlackHeaderBlock,
25
26
  SlackImageBlock,
@@ -33,5 +34,6 @@ export type {
33
34
  SlackSectionBlock,
34
35
  SlackStaticSelectElement,
35
36
  SlackTextObject,
37
+ SlackTimePickerElement,
36
38
  ToSlackBlocksOptions,
37
39
  } from "./slack/types";
@@ -29,7 +29,7 @@ export const TABLE_ROW_CAP = 100;
29
29
  /** The maximum number of columns in a Table. */
30
30
  export const TABLE_COLUMN_CAP = 20;
31
31
 
32
- /** The maximum number of choices in a Select or RadioGroup. */
32
+ /** The maximum number of choices in a Select, RadioGroup, or CheckboxGroup. */
33
33
  export const CHOICE_OPTION_CAP = 100;
34
34
 
35
35
  /** The soft byte-count budget for a serialized card, past which a warning notes Teams' 100 KB bot message limit (the card is never truncated to fit it). */
@@ -1,7 +1,223 @@
1
1
  import { describe, expect, it } from "vitest";
2
2
  import { decodeSubmitData } from "./decodeSubmitData";
3
+ import { toAdaptiveCard } from "./toAdaptiveCard";
3
4
 
4
5
  describe("decodeSubmitData", () => {
6
+ describe.each(["2025-12-15T17:00:30.123456+02:00", "2025-12-15T17:00", ""])(
7
+ "partial datetime submission for %j",
8
+ (value) => {
9
+ it.each(["Input.Date", "Input.Time"])(
10
+ "clears the field when only %s is submitted",
11
+ (submittedType) => {
12
+ const { card } = toAdaptiveCard({
13
+ $type: "DatePicker",
14
+ name: "when",
15
+ inputType: "datetime",
16
+ value,
17
+ $action: {
18
+ type: "save",
19
+ selected: { $field: "when", fallback: "missing" },
20
+ },
21
+ });
22
+ const input = card.body.find(
23
+ (element) => element.type === submittedType,
24
+ );
25
+ const action = card.body.find(
26
+ (element) => element.type === "ActionSet",
27
+ );
28
+ if (!input || !("id" in input) || action?.type !== "ActionSet")
29
+ throw new Error("Missing picker input or action");
30
+ const submitted = {
31
+ ...action.actions[0]!.data,
32
+ [input.id]: submittedType === "Input.Date" ? "2025-12-16" : "18:20",
33
+ };
34
+ expect(Object.keys(submitted)).toHaveLength(2);
35
+ expect(decodeSubmitData(submitted)).toEqual({
36
+ type: "save",
37
+ selected: "",
38
+ $input: { when: "" },
39
+ });
40
+ },
41
+ );
42
+ },
43
+ );
44
+
45
+ it.each([
46
+ "aui:datetime:",
47
+ "aui:datetime:when",
48
+ "aui:datetime:time:when::extra",
49
+ "aui:datetime:time:when:%",
50
+ "aui:datetime:time:%E0%A4%A:",
51
+ "aui:datetime:time:%77hen:",
52
+ "aui:datetime:time:when:not-an-instant",
53
+ "aui:datetime:time:when:2025-02-30T17%3A00%3A00Z",
54
+ "aui:datetime:time:when:2025-12-15T17%3A00",
55
+ "aui:datetime:time:when:%7B%22dateId%22%3A%22other%22%7D",
56
+ "aui:datetime:time::",
57
+ "aui:datetime:time:aui:",
58
+ "aui:datetime:time:aui%3Adatetime%3Awhen%3A:",
59
+ "aui:datetime:missing:",
60
+ "aui:datetime:time:other:",
61
+ ])("rejects forged or malformed time input id %j", (id) => {
62
+ const value = {
63
+ aui: { type: "save", payload: { selected: { $field: "when" } } },
64
+ when: "2025-12-15",
65
+ other: "untouched",
66
+ [id]: "17:00",
67
+ };
68
+ expect(decodeSubmitData(value)).toBeUndefined();
69
+ expect(value.when).toBe("2025-12-15");
70
+ expect(value.other).toBe("untouched");
71
+ });
72
+
73
+ it.each([
74
+ "aui:datetime:date:",
75
+ "aui:datetime:date:when:",
76
+ "aui:datetime:date:when:2025-12-15T17%3A00Z",
77
+ "aui:datetime:date:%",
78
+ "aui:datetime:date:%E0%A4%A",
79
+ "aui:datetime:date:%77hen",
80
+ "aui:datetime:date:aui",
81
+ "aui:datetime:date:aui%3Adatetime%3Awhen",
82
+ "aui:datetime:time:when",
83
+ "aui:datetime:time:%77hen:",
84
+ "aui:datetime:time:when:not-an-instant",
85
+ "aui:datetime:time:when:2025-02-30T17%3A00%3A00Z",
86
+ "aui:datetime:time:when:2025-12-15T17%3a00%3a00Z",
87
+ "aui:datetime:other:when",
88
+ "aui:datetime:when:",
89
+ ])("rejects malformed temporal id %j even without another field", (id) => {
90
+ expect(
91
+ decodeSubmitData({ aui: { type: "save" }, [id]: "" }),
92
+ ).toBeUndefined();
93
+ });
94
+
95
+ it.each(["date", "time"])(
96
+ "rejects a forged %s id targeting an ordinary field",
97
+ (role) => {
98
+ const id = `aui:datetime:${role}:other${role === "time" ? ":" : ""}`;
99
+ const submitted = role === "date" ? "2025-12-16" : "18:20";
100
+ for (const other of ["2025-12-15", "17:00", "", "untouched"]) {
101
+ for (const entries of [
102
+ [
103
+ ["other", other],
104
+ [id, submitted],
105
+ ],
106
+ [
107
+ [id, submitted],
108
+ ["other", other],
109
+ ],
110
+ ]) {
111
+ const value = Object.freeze({
112
+ aui: { type: "save" },
113
+ ...Object.fromEntries(entries),
114
+ });
115
+ expect(decodeSubmitData(value)).toBeUndefined();
116
+ expect(value["other"]).toBe(other);
117
+ }
118
+ }
119
+ },
120
+ );
121
+
122
+ it.each([
123
+ ["date", "2025-02-30"],
124
+ ["date", "18:20"],
125
+ ["date", null],
126
+ ["date", undefined],
127
+ ["date", 20251215],
128
+ ["time", "24:00"],
129
+ ["time", "2025-12-15"],
130
+ ["time", {}],
131
+ ["time", []],
132
+ ["time", false],
133
+ ])("rejects invalid %s half %j", (role, submitted) => {
134
+ const id = `aui:datetime:${role}:when${role === "time" ? ":" : ""}`;
135
+ expect(
136
+ decodeSubmitData({ aui: { type: "save" }, [id]: submitted }),
137
+ ).toBeUndefined();
138
+ });
139
+
140
+ it.each([false, true])(
141
+ "rejects conflicting metadata regardless of submission order (%s)",
142
+ (reverse) => {
143
+ const entries = [
144
+ ["aui:datetime:time:when:", "17:00"],
145
+ ["aui:datetime:time:when:2025-12-15T17%3A00%3A00Z", "18:00"],
146
+ ];
147
+ expect(
148
+ decodeSubmitData({
149
+ aui: { type: "save" },
150
+ "aui:datetime:date:when": "2025-12-15",
151
+ ...Object.fromEntries(reverse ? entries.reverse() : entries),
152
+ }),
153
+ ).toBeUndefined();
154
+ },
155
+ );
156
+
157
+ it("never reads temporal metadata from the action envelope or its prototype", () => {
158
+ const temporal = {
159
+ get() {
160
+ throw new Error("Unexpected temporal read");
161
+ },
162
+ };
163
+ for (const aui of [
164
+ Object.defineProperty({ type: "save" }, "temporal", temporal),
165
+ Object.assign(
166
+ Object.create(Object.defineProperty({}, "temporal", temporal)),
167
+ { type: "save" },
168
+ ),
169
+ ]) {
170
+ expect(
171
+ decodeSubmitData({
172
+ aui,
173
+ "aui:datetime:date:when": "2025-12-15",
174
+ "aui:datetime:time:when:": "17:00",
175
+ }),
176
+ ).toEqual({
177
+ type: "save",
178
+ $input: { when: "2025-12-15T17:00" },
179
+ });
180
+ }
181
+ });
182
+
183
+ it("rejects a legacy time id with an inherited date half", () => {
184
+ const value = Object.assign(Object.create({ when: "2025-12-15" }), {
185
+ aui: { type: "save" },
186
+ "aui:datetime:when:": "17:00",
187
+ });
188
+ expect(decodeSubmitData(value)).toBeUndefined();
189
+ });
190
+
191
+ it.each([
192
+ [
193
+ "aui:datetime:date:when",
194
+ "2025-12-15",
195
+ "aui:datetime:time:when:",
196
+ "17:00",
197
+ ],
198
+ [
199
+ "aui:datetime:time:when:",
200
+ "17:00",
201
+ "aui:datetime:date:when",
202
+ "2025-12-15",
203
+ ],
204
+ ])(
205
+ "ignores an inherited half %s",
206
+ (inheritedId, inheritedValue, id, submitted) => {
207
+ const value = Object.assign(
208
+ Object.create({ [inheritedId]: inheritedValue }),
209
+ {
210
+ aui: { type: "save" },
211
+ [id]: submitted,
212
+ },
213
+ );
214
+ expect(decodeSubmitData(value)).toEqual({
215
+ type: "save",
216
+ $input: { when: "" },
217
+ });
218
+ },
219
+ );
220
+
5
221
  it("decodes the aui envelope, spreading its payload into the result", () => {
6
222
  const value = { aui: { type: "approve", payload: { requestId: "r1" } } };
7
223
  expect(decodeSubmitData(value)).toEqual({
@@ -23,6 +239,39 @@ describe("decodeSubmitData", () => {
23
239
  });
24
240
  });
25
241
 
242
+ it("keeps a number input as a number", () => {
243
+ const value = {
244
+ aui: { type: "set_quantity" },
245
+ quantity: 3.5,
246
+ };
247
+ expect(decodeSubmitData(value)).toEqual({
248
+ type: "set_quantity",
249
+ $input: { quantity: 3.5 },
250
+ });
251
+ });
252
+
253
+ it("resolves $field references in the payload from same-card inputs, else their fallback", () => {
254
+ const value = {
255
+ aui: {
256
+ type: "save",
257
+ payload: {
258
+ note: { $field: "note" },
259
+ form: { plan: [{ $field: "plan" }], missing: { $field: "gone" } },
260
+ renamed: { $field: "aui", fallback: "kept" },
261
+ },
262
+ },
263
+ note: "Ship it",
264
+ plan: "pro",
265
+ };
266
+ expect(decodeSubmitData(value)).toEqual({
267
+ type: "save",
268
+ note: "Ship it",
269
+ form: { plan: ["pro"] },
270
+ renamed: "kept",
271
+ $input: { note: "Ship it", plan: "pro" },
272
+ });
273
+ });
274
+
26
275
  it("omits $input when there are no other top-level keys", () => {
27
276
  expect(decodeSubmitData({ aui: { type: "approve" } })).toEqual({
28
277
  type: "approve",
@@ -1,4 +1,11 @@
1
+ import { resolveFieldReferences } from "../fieldReferences";
1
2
  import type { Action } from "../ir";
3
+ import {
4
+ classifyTemporal,
5
+ fromOffsetDateTime,
6
+ mergeTemporalMinutes,
7
+ } from "../temporal";
8
+ import { decodeTemporalInputId, TEMPORAL_INPUT_PREFIX } from "./temporalId";
2
9
 
3
10
  const isRecord = (value: unknown): value is Record<string, unknown> =>
4
11
  typeof value === "object" && value !== null && !Array.isArray(value);
@@ -9,6 +16,10 @@ const isRecord = (value: unknown): value is Record<string, unknown> =>
9
16
  * the resume action under. Adaptive Cards fold every other same-card input's
10
17
  * value into the same submit object keyed by its `id`, so every top-level key
11
18
  * besides `aui` is collected into `$input` (omitted when there are none).
19
+ * Each `{ "$field": name }` reference inside the payload resolves to the
20
+ * same-card input value with that id, as the string Adaptive Cards submitted,
21
+ * and a reference with no such input resolves to its `fallback`, or is dropped
22
+ * without one.
12
23
  * `aui` is reserved for the envelope: `toAdaptiveCard` renames any input
13
24
  * whose id would collide to an unused id derived from it before encoding, so
14
25
  * a same-card input value can never land on this key. `$input` and `type`
@@ -43,13 +54,55 @@ export function decodeSubmitData(value: unknown): Action | undefined {
43
54
  ([key]) => key !== "$input",
44
55
  );
45
56
  const inputEntries = Object.entries(value).filter(([key]) => key !== "aui");
46
- const input =
47
- inputEntries.length > 0 ? Object.fromEntries(inputEntries) : undefined;
57
+ const input = Object.fromEntries(inputEntries);
58
+ const temporalFields = new Map<
59
+ string,
60
+ { date?: string; time?: string; previousValue?: string }
61
+ >();
62
+ for (const [id, submitted] of inputEntries) {
63
+ if (!id.startsWith(TEMPORAL_INPUT_PREFIX)) continue;
64
+ const metadata = decodeTemporalInputId(id);
65
+ if (
66
+ !metadata ||
67
+ Object.hasOwn(input, metadata.fieldId) ||
68
+ typeof submitted !== "string" ||
69
+ (submitted !== "" && classifyTemporal(submitted).kind !== metadata.role)
70
+ )
71
+ return undefined;
72
+ const field = temporalFields.get(metadata.fieldId) ?? {};
73
+ if (field[metadata.role] !== undefined) return undefined;
74
+ field[metadata.role] = submitted;
75
+ if (metadata.role === "time" && metadata.previousValue !== undefined)
76
+ field.previousValue = metadata.previousValue;
77
+ temporalFields.set(metadata.fieldId, field);
78
+ delete input[id];
79
+ }
80
+ for (const [fieldId, field] of temporalFields) {
81
+ Object.defineProperty(input, fieldId, {
82
+ value: fromOffsetDateTime(
83
+ mergeTemporalMinutes(field.date ?? "", field.time ?? ""),
84
+ field.previousValue,
85
+ ),
86
+ enumerable: true,
87
+ configurable: true,
88
+ writable: true,
89
+ });
90
+ }
91
+ const hasInput = inputEntries.length > 0;
48
92
 
49
93
  return {
50
- ...Object.fromEntries(payloadEntries),
94
+ ...(resolveFieldReferences(
95
+ Object.fromEntries(payloadEntries),
96
+ Object.fromEntries(
97
+ Object.entries(input).flatMap(([id, submitted]) =>
98
+ /^_+aui:datetime:/.test(id)
99
+ ? [[id.slice(1), submitted]]
100
+ : [[id, submitted]],
101
+ ),
102
+ ),
103
+ ) as Record<string, unknown>),
51
104
  type,
52
- ...(input !== undefined ? { $input: input } : {}),
105
+ ...(hasInput ? { $input: input } : {}),
53
106
  };
54
107
  } catch {
55
108
  return undefined;