@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
@@ -0,0 +1,612 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { decodeBlockAction } from "./decodeBlockAction";
3
+ import { fromSlackBlocks } from "./fromSlackBlocks";
4
+ import { toSlackBlocks } from "./toSlackBlocks";
5
+ import type { SlackActionElement, SlackActionsBlock } from "./types";
6
+
7
+ const selection = (element: SlackActionElement): Record<string, unknown> => {
8
+ switch (element.type) {
9
+ case "datepicker":
10
+ return {
11
+ type: element.type,
12
+ selected_date: element.initial_date ?? null,
13
+ };
14
+ case "timepicker":
15
+ return {
16
+ type: element.type,
17
+ selected_time: element.initial_time ?? null,
18
+ };
19
+ case "datetimepicker":
20
+ return {
21
+ type: element.type,
22
+ selected_date_time: element.initial_date_time ?? 1765818000,
23
+ };
24
+ default:
25
+ throw new Error("Expected a picker");
26
+ }
27
+ };
28
+
29
+ const stateFrom = (blocks: readonly SlackActionsBlock[]) =>
30
+ Object.fromEntries(
31
+ blocks.map((block) => [
32
+ block.block_id!,
33
+ Object.fromEntries(
34
+ block.elements.map((element) => [
35
+ element.action_id,
36
+ selection(element),
37
+ ]),
38
+ ),
39
+ ]),
40
+ );
41
+
42
+ const submit = (state: unknown, name = "when") =>
43
+ decodeBlockAction(
44
+ {
45
+ action_id: "save",
46
+ value: JSON.stringify({ when: { $field: name, fallback: "stale" } }),
47
+ },
48
+ state,
49
+ );
50
+
51
+ const renderPicker = (inputType: string, value: string, name = "when") => {
52
+ const result = toSlackBlocks({
53
+ $type: "DatePicker",
54
+ inputType,
55
+ value,
56
+ name,
57
+ $action: { type: "pick" },
58
+ });
59
+ return { ...result, blocks: result.blocks as readonly SlackActionsBlock[] };
60
+ };
61
+
62
+ describe("Slack DatePicker temporal modes", () => {
63
+ it.each([
64
+ ["date", "2025-12-15", "2025-12-15", ["datepicker"]],
65
+ ["time", "17:00", "17:00", ["timepicker"]],
66
+ ["time", "17:00:30", "17:00", ["timepicker"]],
67
+ [
68
+ "datetime",
69
+ "2025-12-15T17:00:00Z",
70
+ "2025-12-15T17:00:00Z",
71
+ ["datetimepicker"],
72
+ ],
73
+ [
74
+ "datetime",
75
+ "2025-12-15T17:00:00+02:00",
76
+ "2025-12-15T17:00:00+02:00",
77
+ ["datetimepicker"],
78
+ ],
79
+ [
80
+ "datetime",
81
+ "2025-12-15T17:00",
82
+ "2025-12-15T17:00",
83
+ ["datepicker", "timepicker"],
84
+ ],
85
+ ["datetime", "", "2025-12-15T17:00:00Z", ["datetimepicker"]],
86
+ [
87
+ "datetime",
88
+ "2025-12-15T17:00+02:00",
89
+ "2025-12-15T17:00:00+02:00",
90
+ ["datetimepicker"],
91
+ ],
92
+ [
93
+ "datetime",
94
+ "2025-12-15T17:00:30.123",
95
+ "2025-12-15T17:00",
96
+ ["datepicker", "timepicker"],
97
+ ],
98
+ ] as const)(
99
+ "round trips %s %j through field state and picker actions",
100
+ (mode, value, expected, types) => {
101
+ const { blocks } = renderPicker(mode, value);
102
+ expect(
103
+ blocks.flatMap((block) =>
104
+ block.elements.map((element) => element.type),
105
+ ),
106
+ ).toEqual(types);
107
+ const state = stateFrom(blocks);
108
+ expect(submit(state)).toEqual({ type: "save", when: expected });
109
+ for (const block of blocks) {
110
+ for (const element of block.elements) {
111
+ expect(element).not.toHaveProperty("timezone");
112
+ expect(
113
+ decodeBlockAction(
114
+ {
115
+ ...selection(element),
116
+ action_id: element.action_id,
117
+ block_id: block.block_id,
118
+ },
119
+ state,
120
+ ),
121
+ ).toEqual({ type: "pick", $input: expected });
122
+ }
123
+ }
124
+ },
125
+ );
126
+
127
+ it("preserves the date mode block and metadata exactly", () => {
128
+ expect(renderPicker("date", "2025-12-15")).toEqual({
129
+ blocks: [
130
+ {
131
+ type: "actions",
132
+ block_id: 'aui:0:[["pick","when"]]',
133
+ elements: [
134
+ {
135
+ type: "datepicker",
136
+ action_id: "pick",
137
+ initial_date: "2025-12-15",
138
+ },
139
+ ],
140
+ },
141
+ ],
142
+ warnings: [],
143
+ });
144
+ });
145
+
146
+ it.each(["17:00", "17:00:00", ""])(
147
+ "renders a minute timepicker without a warning for %j",
148
+ (value) => {
149
+ const { blocks, warnings } = renderPicker("time", value);
150
+ expect(blocks[0]?.elements[0]).toEqual({
151
+ type: "timepicker",
152
+ action_id: "pick",
153
+ ...(value ? { initial_time: "17:00" } : {}),
154
+ });
155
+ expect(warnings).toEqual([]);
156
+ },
157
+ );
158
+
159
+ it("warns when Slack drops nonzero time seconds", () => {
160
+ const { warnings } = renderPicker("time", "17:00:30");
161
+ expect(warnings).toEqual([
162
+ {
163
+ code: "dropped",
164
+ component: "DatePicker",
165
+ detail:
166
+ "Seconds were dropped because Slack timepickers only support minute precision.",
167
+ },
168
+ ]);
169
+ });
170
+
171
+ it.each([
172
+ ["2025-12-15T17:00:00Z", 1765818000],
173
+ ["2025-12-15T17:00:00+02:00", 1765810800],
174
+ ["2025-12-15T17:00:00.999Z", 1765818000],
175
+ ])("renders %s as unix seconds", (value, expected) => {
176
+ expect(
177
+ renderPicker("datetime", value as string).blocks[0]?.elements[0],
178
+ ).toEqual({
179
+ type: "datetimepicker",
180
+ action_id: "pick",
181
+ initial_date_time: expected,
182
+ });
183
+ });
184
+
185
+ it.each([
186
+ ["2025-12-15T17:00:30.123Z", true],
187
+ ["2025-12-15T17:00:30.000001+02:00", true],
188
+ ["1969-12-31T23:59:59.999Z", true],
189
+ ["2025-12-15T17:00:30.000000Z", false],
190
+ ["2025-12-15T17:00:30Z", false],
191
+ ["2025-12-15T17:00Z", false],
192
+ ])("reports lost fractional seconds for %s", (value, dropped) => {
193
+ const { warnings } = renderPicker("datetime", value);
194
+ expect(warnings).toEqual(
195
+ dropped
196
+ ? [
197
+ {
198
+ code: "dropped",
199
+ component: "DatePicker",
200
+ detail:
201
+ "Fractional seconds were dropped because Slack datetimepickers only support second precision.",
202
+ },
203
+ ]
204
+ : [],
205
+ );
206
+ });
207
+
208
+ it.each(["Z", "+02:00", "-05:30", "+00:00", "-00:00"])(
209
+ "preserves %s offset spelling at seconds precision after an edit",
210
+ (offset) => {
211
+ const { blocks } = renderPicker(
212
+ "datetime",
213
+ `2025-12-15T17:00:45.123456${offset}`,
214
+ );
215
+ const block = blocks[0]!;
216
+ const element = block.elements[0]!;
217
+ if (element.type !== "datetimepicker")
218
+ throw new Error("Expected a datetimepicker");
219
+ const action = {
220
+ type: element.type,
221
+ action_id: element.action_id,
222
+ block_id: block.block_id,
223
+ selected_date_time: element.initial_date_time! + 3600,
224
+ };
225
+ expect(decodeBlockAction(action)).toEqual({
226
+ type: "pick",
227
+ $input: `2025-12-15T18:00:45${offset}`,
228
+ });
229
+ expect(submit({ [block.block_id!]: { pick: action } })).toEqual({
230
+ type: "save",
231
+ when: `2025-12-15T18:00:45${offset}`,
232
+ });
233
+ },
234
+ );
235
+
236
+ it("renders an empty datetime without an initial instant", () => {
237
+ expect(renderPicker("datetime", "").blocks[0]?.elements[0]).toEqual({
238
+ type: "datetimepicker",
239
+ action_id: "pick",
240
+ });
241
+ });
242
+
243
+ it.each(["time", "datetime"])(
244
+ "preserves a cleared %s in field state and picker input",
245
+ (inputType) => {
246
+ const { blocks } = renderPicker(inputType, "");
247
+ const block = blocks[0]!;
248
+ const action = {
249
+ type: `${inputType}picker`,
250
+ action_id: "pick",
251
+ block_id: block.block_id,
252
+ [inputType === "time" ? "selected_time" : "selected_date_time"]: null,
253
+ };
254
+ expect(decodeBlockAction(action)).toEqual({ type: "pick", $input: "" });
255
+ expect(submit({ [block.block_id!]: { pick: action } })).toEqual({
256
+ type: "save",
257
+ when: "",
258
+ });
259
+ },
260
+ );
261
+
262
+ it.each([
263
+ ["time", "24:00"],
264
+ ["time", "2025-12-15T17:00"],
265
+ ["datetime", "2025-02-29T17:00Z"],
266
+ ["datetime", "17:00"],
267
+ ])("drops an invalid %s value %j with a warning", (inputType, value) => {
268
+ const { blocks, warnings } = renderPicker(inputType!, value!);
269
+ expect(blocks[0]?.elements[0]).toEqual({
270
+ type: `${inputType}picker`,
271
+ action_id: "pick",
272
+ });
273
+ expect(warnings).toEqual([
274
+ {
275
+ code: "dropped",
276
+ component: "DatePicker",
277
+ detail: `value did not match the ${inputType} format and was dropped.`,
278
+ },
279
+ ]);
280
+ });
281
+
282
+ it.each([
283
+ ["timepicker", { selected_time: "24:00" }],
284
+ ["timepicker", { selected_time: "2025-12-15T17:00" }],
285
+ ["datetimepicker", { selected_date_time: "1765818000" }],
286
+ ["datetimepicker", { selected_date_time: Infinity }],
287
+ ["datetimepicker", { selected_date_time: 1e20 }],
288
+ ["datetimepicker", { selected_date_time: 0.5 }],
289
+ ])("rejects malformed %s selection %j", (type, selected) => {
290
+ expect(
291
+ decodeBlockAction({ type, action_id: "pick", ...selected }),
292
+ ).toBeUndefined();
293
+ });
294
+
295
+ it("decodes standalone native pickers and tolerates malformed metadata", () => {
296
+ expect(
297
+ decodeBlockAction({
298
+ type: "timepicker",
299
+ action_id: "pick",
300
+ selected_time: "17:00",
301
+ }),
302
+ ).toEqual({ type: "pick", $input: "17:00" });
303
+ for (const block_id of [
304
+ undefined,
305
+ "aui:0:{",
306
+ 'aui:0:[null,42,["pick"]]',
307
+ 'aui:0:[["pick","when","DatePicker","datetime",null,"date","invalid"]]',
308
+ ]) {
309
+ expect(
310
+ decodeBlockAction({
311
+ type: "datetimepicker",
312
+ action_id: "pick",
313
+ selected_date_time: 0,
314
+ block_id,
315
+ }),
316
+ ).toEqual({ type: "pick", $input: "1970-01-01T00:00:00Z" });
317
+ }
318
+ });
319
+
320
+ it("carries an unnamed instant's offset in its block ID", () => {
321
+ const { blocks } = toSlackBlocks({
322
+ $type: "DatePicker",
323
+ inputType: "datetime",
324
+ value: "2025-12-15T17:00:00+02:00",
325
+ $action: { type: "pick" },
326
+ });
327
+ const block = blocks[0] as SlackActionsBlock;
328
+ expect(
329
+ decodeBlockAction({
330
+ ...selection(block.elements[0]!),
331
+ action_id: "pick",
332
+ block_id: block.block_id,
333
+ }),
334
+ ).toEqual({ type: "pick", $input: "2025-12-15T17:00:00+02:00" });
335
+ });
336
+
337
+ it.each(["date", "time"])(
338
+ "clears the field and rejects a split action when the %s half is empty",
339
+ (part) => {
340
+ const { blocks } = renderPicker("datetime", "2025-12-15T17:00");
341
+ const state = stateFrom(blocks);
342
+ const clearedBlock = blocks.find(
343
+ (block) => block.elements[0]?.type === `${part}picker`,
344
+ )!;
345
+ const cleared = { type: `${part}picker`, [`selected_${part}`]: null };
346
+ state[clearedBlock.block_id!] = { pick: cleared };
347
+ expect(submit(state)).toEqual({ type: "save", when: "" });
348
+ for (const block of blocks) {
349
+ const current = state[block.block_id!]!.pick!;
350
+ expect(
351
+ decodeBlockAction(
352
+ { ...current, action_id: "pick", block_id: block.block_id },
353
+ state,
354
+ ),
355
+ ).toBeUndefined();
356
+ }
357
+ },
358
+ );
359
+
360
+ it("uses the fired split selection ahead of stale state and requires its companion", () => {
361
+ const { blocks } = renderPicker("datetime", "2025-12-15T17:00");
362
+ const block = blocks.find(
363
+ (entry) => entry.elements[0]?.type === "timepicker",
364
+ )!;
365
+ const action = {
366
+ type: "timepicker",
367
+ action_id: "pick",
368
+ block_id: block.block_id,
369
+ selected_time: "18:30",
370
+ };
371
+ expect(decodeBlockAction(action, stateFrom(blocks))).toEqual({
372
+ type: "pick",
373
+ $input: "2025-12-15T18:30",
374
+ });
375
+ expect(decodeBlockAction(action)).toBeUndefined();
376
+ expect(
377
+ submit({ [block.block_id!]: { pick: selection(block.elements[0]!) } }),
378
+ ).toEqual({ type: "save", when: "" });
379
+ });
380
+
381
+ it("keeps unnamed split pairs with repeated action IDs distinct across containers", () => {
382
+ const { blocks } = toSlackBlocks(
383
+ ["2025-12-15T17:00", "2026-01-02T09:30"].map((value) => ({
384
+ $type: "Col",
385
+ children: [
386
+ {
387
+ $type: "DatePicker",
388
+ inputType: "datetime",
389
+ value,
390
+ $action: { type: "pick" },
391
+ },
392
+ ],
393
+ })),
394
+ );
395
+ const actions = blocks as readonly SlackActionsBlock[];
396
+ const state = stateFrom(actions);
397
+ const inputs = actions.map(
398
+ (block) =>
399
+ decodeBlockAction(
400
+ {
401
+ ...selection(block.elements[0]!),
402
+ action_id: "pick",
403
+ block_id: block.block_id,
404
+ },
405
+ state,
406
+ )?.["$input"],
407
+ );
408
+ expect(inputs).toEqual([
409
+ "2025-12-15T17:00",
410
+ "2025-12-15T17:00",
411
+ "2026-01-02T09:30",
412
+ "2026-01-02T09:30",
413
+ ]);
414
+ });
415
+
416
+ it("keeps temporal metadata in bounded block IDs and warns when one mapping cannot fit", () => {
417
+ const { blocks, warnings } = toSlackBlocks(
418
+ Array.from({ length: 5 }, (_, i) => ({
419
+ $type: "DatePicker",
420
+ inputType: "datetime",
421
+ value: "2025-12-15T17:00:00+02:00",
422
+ name: `when${i}${"x".repeat(60)}`,
423
+ $action: { type: `pick${i}` },
424
+ })),
425
+ );
426
+ expect(warnings).toEqual([]);
427
+ expect(blocks.length).toBeGreaterThan(1);
428
+ for (const block of blocks as readonly SlackActionsBlock[]) {
429
+ expect(block.block_id!.length).toBeLessThanOrEqual(255);
430
+ expect(block.block_id).toContain("+02:00");
431
+ }
432
+ const oversized = renderPicker(
433
+ "datetime",
434
+ "2025-12-15T17:00:00+02:00",
435
+ "x".repeat(300),
436
+ );
437
+ expect(oversized.blocks[0]).not.toHaveProperty("block_id");
438
+ expect(oversized.warnings).toEqual([
439
+ {
440
+ code: "dropped",
441
+ component: "DatePicker",
442
+ detail:
443
+ "name could not be mapped because its Slack block_id exceeded 255 characters.",
444
+ },
445
+ ]);
446
+ });
447
+
448
+ it.each(["message", "modal"] as const)(
449
+ "decodes split pairs alongside other controls on a %s",
450
+ (surface) => {
451
+ const result = toSlackBlocks(
452
+ {
453
+ $type: "Form",
454
+ children: [
455
+ {
456
+ $type: "DatePicker",
457
+ inputType: "datetime",
458
+ value: "2025-12-15T17:00",
459
+ name: "when",
460
+ $action: { type: "pick" },
461
+ },
462
+ {
463
+ $type: "DatePicker",
464
+ inputType: "time",
465
+ value: "09:30",
466
+ name: "start",
467
+ $action: { type: "start" },
468
+ },
469
+ {
470
+ $type: "Select",
471
+ name: "plan",
472
+ options: [{ label: "Pro", value: "pro" }],
473
+ $action: { type: "plan" },
474
+ },
475
+ {
476
+ $type: "Button",
477
+ label: "Save",
478
+ $action: {
479
+ type: "save",
480
+ when: { $field: "when" },
481
+ start: { $field: "start" },
482
+ plan: { $field: "plan" },
483
+ },
484
+ },
485
+ ],
486
+ },
487
+ { surface },
488
+ );
489
+ const state: Record<string, Record<string, unknown>> = {};
490
+ let button: SlackActionElement | undefined;
491
+ for (const block of result.blocks) {
492
+ if (block.type !== "actions") continue;
493
+ const values: Record<string, unknown> = {};
494
+ for (const element of block.elements) {
495
+ if (element.type === "button") {
496
+ if (element.action_id === "save") button = element;
497
+ } else if (element.type === "static_select") {
498
+ values[element.action_id] = {
499
+ type: element.type,
500
+ selected_option: { value: "pro" },
501
+ };
502
+ } else values[element.action_id] = selection(element);
503
+ }
504
+ if (block.block_id) state[block.block_id] = values;
505
+ }
506
+ expect(result.warnings).toEqual([]);
507
+ expect(decodeBlockAction(button, state)).toEqual({
508
+ type: "save",
509
+ when: "2025-12-15T17:00",
510
+ start: "09:30",
511
+ plan: "pro",
512
+ });
513
+ },
514
+ );
515
+
516
+ it.each([
517
+ ["timepicker", { initial_time: "17:00" }, "time", "17:00"],
518
+ [
519
+ "datetimepicker",
520
+ { initial_date_time: 1765818000 },
521
+ "datetime",
522
+ "2025-12-15T17:00:00Z",
523
+ ],
524
+ [
525
+ "datetimepicker",
526
+ { initial_date_time: 0 },
527
+ "datetime",
528
+ "1970-01-01T00:00:00Z",
529
+ ],
530
+ ["timepicker", {}, "time", undefined],
531
+ ["datetimepicker", {}, "datetime", undefined],
532
+ ])(
533
+ "converts native %s %j back to the matching DatePicker mode",
534
+ (type, initial, inputType, value) => {
535
+ expect(
536
+ fromSlackBlocks([
537
+ {
538
+ type: "actions",
539
+ elements: [{ type, action_id: "pick", ...initial }],
540
+ },
541
+ ]),
542
+ ).toEqual({
543
+ nodes: [
544
+ {
545
+ $type: "DatePicker",
546
+ inputType,
547
+ ...(value === undefined ? {} : { value }),
548
+ $action: { type: "pick" },
549
+ },
550
+ ],
551
+ warnings: [],
552
+ });
553
+ },
554
+ );
555
+
556
+ it("omits invalid native timestamps without losing sibling blocks", () => {
557
+ for (const initial_date_time of [Infinity, NaN, 1e20, 0.5, "1765818000"]) {
558
+ expect(
559
+ fromSlackBlocks([
560
+ {
561
+ type: "actions",
562
+ elements: [
563
+ { type: "datetimepicker", action_id: "pick", initial_date_time },
564
+ ],
565
+ },
566
+ { type: "divider" },
567
+ ]).nodes,
568
+ ).toEqual([
569
+ {
570
+ $type: "DatePicker",
571
+ inputType: "datetime",
572
+ $action: { type: "pick" },
573
+ },
574
+ { $type: "Divider" },
575
+ ]);
576
+ }
577
+ });
578
+
579
+ it.each([
580
+ ["timepicker", { initial_time: "17:00" }, "time", "17:00"],
581
+ [
582
+ "datetimepicker",
583
+ { initial_date_time: 1765818000 },
584
+ "datetime",
585
+ "2025-12-15T17:00:00Z",
586
+ ],
587
+ ])(
588
+ "converts %s in an input block with its label",
589
+ (type, initial, inputType, value) => {
590
+ expect(
591
+ fromSlackBlocks([
592
+ {
593
+ type: "input",
594
+ label: { type: "plain_text", text: "When" },
595
+ element: { type, action_id: "pick", ...initial },
596
+ },
597
+ ]),
598
+ ).toEqual({
599
+ nodes: [
600
+ {
601
+ $type: "DatePicker",
602
+ inputType,
603
+ value,
604
+ label: "When",
605
+ $action: { type: "pick" },
606
+ },
607
+ ],
608
+ warnings: [],
609
+ });
610
+ },
611
+ );
612
+ });