@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
@@ -6,11 +6,17 @@ import {
6
6
  import { copyBounded } from "../convert/copyBounded";
7
7
  import { isElement } from "../convert/isElement";
8
8
  import { takeRun } from "../convert/takeRun";
9
+ import { classifyTemporal, splitTemporalMinutes } from "../temporal";
9
10
  import {
10
11
  normalizeSpec,
11
12
  type NormalizedUIElement,
12
13
  type NormalizedUINode,
13
14
  } from "../ir";
15
+ import {
16
+ factTrend,
17
+ formatFactDelta,
18
+ formatValue,
19
+ } from "../vocabulary/formatValue";
14
20
  import {
15
21
  ACTION_ID_CAP,
16
22
  ACTIONS_ELEMENT_CAP,
@@ -22,6 +28,7 @@ import {
22
28
  CARD_TITLE_CAP,
23
29
  CAROUSEL_CARD_CAP,
24
30
  CAROUSEL_CARD_MIN,
31
+ CHECKBOX_OPTION_CAP,
25
32
  CONTEXT_ELEMENT_CAP,
26
33
  CONTEXT_TEXT_CAP,
27
34
  DATA_TABLE_CHAR_BUDGET,
@@ -47,6 +54,7 @@ import {
47
54
  buildDataTableBlock,
48
55
  } from "./constants";
49
56
  import type {
57
+ FieldMapping,
50
58
  SlackActionElement,
51
59
  SlackBlock,
52
60
  SlackButtonElement,
@@ -65,16 +73,22 @@ type ConversionContext = {
65
73
  readonly blockCap: number;
66
74
  readonly surface: "message" | "modal";
67
75
  readonly warnings: SlackConversionWarning[];
76
+ readonly omittedPasswordNames: Set<string>;
77
+ fieldBlockIdSequence: number;
78
+ dateTimePairSequence: number;
68
79
  markdownCharacters: number;
69
80
  markdownExhausted: boolean;
70
81
  dataTableCharacters: number;
71
82
  };
72
83
 
84
+ const SLACK_BLOCK_ID_CAP = 255;
85
+
73
86
  const INTERACTIVE_TYPES = new Set([
74
87
  "Button",
75
88
  "Select",
76
89
  "DatePicker",
77
90
  "Checkbox",
91
+ "CheckboxGroup",
78
92
  "RadioGroup",
79
93
  ]);
80
94
 
@@ -84,7 +98,12 @@ const INTERACTIVE_TYPES = new Set([
84
98
  * block rather than an actions element, but a reshape loses them the same way,
85
99
  * and a `Form` gets a Submit button whether or not it carries an action.
86
100
  */
87
- const CONTROL_TYPES = new Set([...INTERACTIVE_TYPES, "Input", "Form"]);
101
+ const CONTROL_TYPES = new Set([
102
+ ...INTERACTIVE_TYPES,
103
+ "Input",
104
+ "Slider",
105
+ "Form",
106
+ ]);
88
107
 
89
108
  const DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/;
90
109
 
@@ -94,6 +113,9 @@ const isRecord = (value: unknown): value is Record<string, unknown> =>
94
113
  const asString = (value: unknown): string =>
95
114
  typeof value === "string" ? value : "";
96
115
 
116
+ const asFiniteNumber = (value: unknown): number | undefined =>
117
+ typeof value === "number" && Number.isFinite(value) ? value : undefined;
118
+
97
119
  const warn = (
98
120
  context: ConversionContext,
99
121
  code: SlackConversionWarning["code"],
@@ -140,16 +162,114 @@ const plainText = (text: string): SlackPlainText => ({
140
162
  text,
141
163
  });
142
164
 
143
- const actionValue = (action: unknown): string | undefined => {
165
+ const serializeFieldBlockId = (
166
+ sequence: number,
167
+ fields: readonly FieldMapping[],
168
+ ): string =>
169
+ `aui:${sequence}:${JSON.stringify(
170
+ fields.map(
171
+ ({ actionId, name, component, inputType, offset, part, pairId }) =>
172
+ inputType !== undefined
173
+ ? [
174
+ actionId,
175
+ name,
176
+ component,
177
+ inputType,
178
+ offset ?? null,
179
+ part ?? null,
180
+ pairId ?? null,
181
+ ]
182
+ : component === "Checkbox"
183
+ ? [actionId, name, "Checkbox"]
184
+ : [actionId, name],
185
+ ),
186
+ )}`;
187
+
188
+ const fieldBlockId = (
189
+ fields: readonly FieldMapping[],
190
+ context: ConversionContext,
191
+ ): string | undefined => {
192
+ if (fields.length === 0) return undefined;
193
+ const sequence = context.fieldBlockIdSequence++;
194
+ const blockId = serializeFieldBlockId(sequence, fields);
195
+ if (blockId.length <= SLACK_BLOCK_ID_CAP) return blockId;
196
+ for (const field of fields) {
197
+ warn(
198
+ context,
199
+ "dropped",
200
+ field.component,
201
+ "name could not be mapped because its Slack block_id exceeded 255 characters.",
202
+ );
203
+ }
204
+ return undefined;
205
+ };
206
+
207
+ const collectOmittedPasswordNames = (
208
+ node: NormalizedUINode,
209
+ names: Set<string>,
210
+ depth = 0,
211
+ ): void => {
212
+ if (depth > MAX_TRAVERSAL_DEPTH) return;
213
+ if (Array.isArray(node)) {
214
+ for (const child of node) {
215
+ collectOmittedPasswordNames(child, names, depth + 1);
216
+ }
217
+ } else if (isElement(node)) {
218
+ if (
219
+ node.type === "Input" &&
220
+ node.props["inputType"] === "password" &&
221
+ typeof node.props["name"] === "string"
222
+ ) {
223
+ names.add(node.props["name"]);
224
+ }
225
+ if (node.children !== undefined) {
226
+ collectOmittedPasswordNames(node.children, names, depth + 1);
227
+ }
228
+ }
229
+ };
230
+
231
+ const withoutOmittedPasswordFallbacks = (
232
+ value: unknown,
233
+ names: ReadonlySet<string>,
234
+ depth = 0,
235
+ ): unknown => {
236
+ if (depth > MAX_TRAVERSAL_DEPTH) {
237
+ throw new RangeError("Action payload nests too deeply to sanitize.");
238
+ }
239
+ if (Array.isArray(value)) {
240
+ return value.map((item) =>
241
+ withoutOmittedPasswordFallbacks(item, names, depth + 1),
242
+ );
243
+ }
244
+ if (!isRecord(value)) return value;
245
+ if (
246
+ typeof value["$field"] === "string" &&
247
+ names.has(value["$field"]) &&
248
+ Object.keys(value).every((key) => key === "$field" || key === "fallback")
249
+ ) {
250
+ return { $field: value["$field"] };
251
+ }
252
+ return Object.fromEntries(
253
+ Object.entries(value).map(([key, item]) => [
254
+ key,
255
+ withoutOmittedPasswordFallbacks(item, names, depth + 1),
256
+ ]),
257
+ );
258
+ };
259
+
260
+ const actionValue = (
261
+ action: unknown,
262
+ context: ConversionContext,
263
+ ): string | undefined => {
144
264
  if (!isRecord(action)) return undefined;
145
265
  const { type: _type, ...payload } = action;
146
266
  if (Object.keys(payload).length === 0) return undefined;
147
- try {
148
- const serialized = JSON.stringify(payload);
149
- return serialized === "{}" ? undefined : serialized;
150
- } catch {
151
- return undefined;
152
- }
267
+ const serialized = JSON.stringify(
268
+ context.omittedPasswordNames.size === 0
269
+ ? payload
270
+ : withoutOmittedPasswordFallbacks(payload, context.omittedPasswordNames),
271
+ );
272
+ return serialized === "{}" ? undefined : serialized;
153
273
  };
154
274
 
155
275
  const buttonElement = (
@@ -159,8 +279,17 @@ const buttonElement = (
159
279
  component: string,
160
280
  context: ConversionContext,
161
281
  ): SlackButtonElement => {
162
- const serializedValue = actionValue(action);
163
- let value = serializedValue;
282
+ let value: string | undefined;
283
+ try {
284
+ value = actionValue(action, context);
285
+ } catch {
286
+ warn(
287
+ context,
288
+ "dropped",
289
+ component,
290
+ "value was dropped because the action payload could not be serialized.",
291
+ );
292
+ }
164
293
  if (value !== undefined && value.length > BUTTON_VALUE_CAP) {
165
294
  warn(
166
295
  context,
@@ -197,6 +326,7 @@ const optionFrom = (
197
326
  ) {
198
327
  return undefined;
199
328
  }
329
+ const description = asString(value["description"]);
200
330
  return {
201
331
  text: plainText(
202
332
  clampText(
@@ -208,7 +338,20 @@ const optionFrom = (
208
338
  ),
209
339
  ),
210
340
  value: value["value"],
211
- };
341
+ ...(description
342
+ ? {
343
+ description: plainText(
344
+ clampText(
345
+ description,
346
+ INTERACTIVE_TEXT_CAP,
347
+ component,
348
+ "option description",
349
+ context,
350
+ ),
351
+ ),
352
+ }
353
+ : {}),
354
+ } as SlackOption;
212
355
  };
213
356
 
214
357
  const warnDroppedOptions = (
@@ -230,7 +373,7 @@ const warnDroppedOptions = (
230
373
  const toActionElement = (
231
374
  element: NormalizedUIElement,
232
375
  context: ConversionContext,
233
- ): SlackActionElement | undefined => {
376
+ ): SlackActionElement | SlackActionElement[] | undefined => {
234
377
  const { props, action } = element;
235
378
  switch (element.type) {
236
379
  case "Button":
@@ -273,15 +416,86 @@ const toActionElement = (
273
416
  "placeholder",
274
417
  context,
275
418
  );
419
+ const defaultValue = props["defaultValue"];
420
+ const initialOption =
421
+ typeof defaultValue === "string"
422
+ ? options.find((option) => option.value === defaultValue)
423
+ : undefined;
276
424
  return {
277
425
  type: "static_select",
278
426
  action_id: asActionId(action, "Select", context),
279
427
  options,
428
+ ...(initialOption !== undefined
429
+ ? { initial_option: initialOption }
430
+ : {}),
280
431
  ...(placeholder ? { placeholder: plainText(placeholder) } : {}),
281
432
  };
282
433
  }
283
434
  case "DatePicker": {
284
435
  const rawDate = asString(props["value"]);
436
+ const inputType = props["inputType"];
437
+ if (inputType === "time" || inputType === "datetime") {
438
+ const actionId = asActionId(action, "DatePicker", context);
439
+ const temporal = classifyTemporal(rawDate);
440
+ const parts = splitTemporalMinutes(rawDate);
441
+ if (
442
+ (inputType === "time" && temporal.kind === "time") ||
443
+ (inputType === "datetime" && temporal.kind === "floating")
444
+ ) {
445
+ if (parts?.droppedPrecision) {
446
+ warn(
447
+ context,
448
+ "dropped",
449
+ "DatePicker",
450
+ "Seconds were dropped because Slack timepickers only support minute precision.",
451
+ );
452
+ }
453
+ const timePicker: SlackActionElement = {
454
+ type: "timepicker",
455
+ action_id: actionId,
456
+ initial_time: parts!.time,
457
+ };
458
+ return inputType === "time"
459
+ ? timePicker
460
+ : [
461
+ {
462
+ type: "datepicker",
463
+ action_id: actionId,
464
+ initial_date: parts!.date!,
465
+ },
466
+ timePicker,
467
+ ];
468
+ }
469
+ if (inputType === "datetime" && temporal.kind === "instant") {
470
+ if (
471
+ temporal.epochMs % 1000 !== 0 ||
472
+ /[1-9]/.test(temporal.subMillisecondDigits ?? "")
473
+ ) {
474
+ warn(
475
+ context,
476
+ "dropped",
477
+ "DatePicker",
478
+ "Fractional seconds were dropped because Slack datetimepickers only support second precision.",
479
+ );
480
+ }
481
+ return {
482
+ type: "datetimepicker",
483
+ action_id: actionId,
484
+ initial_date_time: Math.floor(temporal.epochMs / 1000),
485
+ };
486
+ }
487
+ if (rawDate)
488
+ warn(
489
+ context,
490
+ "dropped",
491
+ "DatePicker",
492
+ `value did not match the ${inputType} format and was dropped.`,
493
+ );
494
+ return {
495
+ type: inputType === "time" ? "timepicker" : "datetimepicker",
496
+ action_id: actionId,
497
+ };
498
+ }
285
499
  const initialDate = DATE_PATTERN.test(rawDate) ? rawDate : undefined;
286
500
  if (rawDate && initialDate === undefined) {
287
501
  warn(
@@ -319,6 +533,46 @@ const toActionElement = (
319
533
  : {}),
320
534
  };
321
535
  }
536
+ case "CheckboxGroup": {
537
+ const rawOptions = Array.isArray(props["options"])
538
+ ? props["options"]
539
+ : [];
540
+ const { items: takenOptions, truncated } = copyBounded(
541
+ rawOptions,
542
+ CHECKBOX_OPTION_CAP,
543
+ );
544
+ if (truncated) {
545
+ warn(
546
+ context,
547
+ "clamped",
548
+ "CheckboxGroup",
549
+ `options were clamped to ${CHECKBOX_OPTION_CAP} entries.`,
550
+ );
551
+ }
552
+ const options = takenOptions
553
+ .map((option) => optionFrom(option, "CheckboxGroup", context))
554
+ .filter((option): option is SlackOption => option !== undefined);
555
+ warnDroppedOptions(
556
+ options.length,
557
+ takenOptions.length,
558
+ "CheckboxGroup",
559
+ context,
560
+ );
561
+ const defaultValue = Array.isArray(props["defaultValue"])
562
+ ? props["defaultValue"]
563
+ : [];
564
+ const initialOptions = options.filter((option) =>
565
+ defaultValue.includes(option.value),
566
+ );
567
+ return {
568
+ type: "checkboxes",
569
+ action_id: asActionId(action, "CheckboxGroup", context),
570
+ options,
571
+ ...(initialOptions.length > 0
572
+ ? { initial_options: initialOptions }
573
+ : {}),
574
+ };
575
+ }
322
576
  case "RadioGroup": {
323
577
  const rawOptions = Array.isArray(props["options"])
324
578
  ? props["options"]
@@ -380,10 +634,15 @@ const convertFacts = (
380
634
  const fields = facts.map((fact) => {
381
635
  const label = asString(fact.props["label"]);
382
636
  const value = asString(fact.props["value"]);
637
+ const delta = fact.props["delta"];
638
+ const deltaText =
639
+ typeof delta === "string"
640
+ ? formatFactDelta(delta, factTrend(delta, fact.props["trend"]))
641
+ : undefined;
383
642
  return {
384
643
  type: "mrkdwn" as const,
385
644
  text: clampText(
386
- `*${label}*\n${value}`,
645
+ `*${label}*\n${deltaText === undefined ? value : `${value} (${deltaText})`}`,
387
646
  FACT_FIELD_TEXT_CAP,
388
647
  "Fact",
389
648
  "field",
@@ -405,14 +664,103 @@ const convertActions = (
405
664
  elements: readonly NormalizedUIElement[],
406
665
  context: ConversionContext,
407
666
  ): SlackBlock[] => {
408
- const converted = elements
409
- .map((element) => toActionElement(element, context))
410
- .filter((element): element is SlackActionElement => element !== undefined);
667
+ const converted = elements.flatMap((source) => {
668
+ const result = toActionElement(source, context);
669
+ if (result === undefined) return [];
670
+ const controls = Array.isArray(result) ? result : [result];
671
+ const inputType =
672
+ source.type === "DatePicker" ? source.props["inputType"] : undefined;
673
+ const temporal =
674
+ inputType === "datetime"
675
+ ? classifyTemporal(asString(source.props["value"]))
676
+ : undefined;
677
+ const pairId =
678
+ controls.length > 1 ? context.dateTimePairSequence++ : undefined;
679
+ return controls.map((element) => {
680
+ const name = asString(source.props["name"]);
681
+ const field: FieldMapping | undefined =
682
+ inputType === "time" || inputType === "datetime"
683
+ ? {
684
+ actionId: element.action_id,
685
+ name,
686
+ component: source.type,
687
+ inputType,
688
+ ...(temporal?.kind === "instant"
689
+ ? { offset: temporal.offset }
690
+ : {}),
691
+ ...(pairId !== undefined
692
+ ? ({
693
+ part: element.type === "datepicker" ? "date" : "time",
694
+ pairId,
695
+ } as const)
696
+ : {}),
697
+ }
698
+ : name
699
+ ? { actionId: element.action_id, name, component: source.type }
700
+ : undefined;
701
+ return { element, field };
702
+ });
703
+ });
411
704
  const blocks: SlackBlock[] = [];
412
- for (let index = 0; index < converted.length; index += ACTIONS_ELEMENT_CAP) {
705
+ const groups: (typeof converted)[] = [];
706
+ let group: typeof converted = [];
707
+ let actionIds = new Set<string>();
708
+ const fieldsFor = (entries: typeof converted): FieldMapping[] =>
709
+ entries.flatMap(({ field }) => (field === undefined ? [] : [field]));
710
+ for (const entry of converted) {
711
+ const repeatedMappedActionId =
712
+ actionIds.has(entry.element.action_id) &&
713
+ (entry.field !== undefined ||
714
+ group.some(
715
+ (candidate) =>
716
+ candidate.element.action_id === entry.element.action_id &&
717
+ candidate.field !== undefined,
718
+ ));
719
+ if (group.length === ACTIONS_ELEMENT_CAP || repeatedMappedActionId) {
720
+ groups.push(group);
721
+ group = [];
722
+ actionIds = new Set();
723
+ }
724
+ group.push(entry);
725
+ actionIds.add(entry.element.action_id);
726
+ }
727
+ if (group.length > 0) groups.push(group);
728
+ const mappedGroups: (typeof converted)[] = [];
729
+ let nextSequence = context.fieldBlockIdSequence;
730
+ for (const entries of groups) {
731
+ let current: typeof converted = [];
732
+ for (const entry of entries) {
733
+ const candidate = [...current, entry];
734
+ const fields = fieldsFor(candidate);
735
+ const blockId =
736
+ fields.length > 0
737
+ ? serializeFieldBlockId(nextSequence, fields)
738
+ : undefined;
739
+ if (
740
+ current.length > 0 &&
741
+ fields.length > fieldsFor(current).length &&
742
+ blockId !== undefined &&
743
+ blockId.length > SLACK_BLOCK_ID_CAP
744
+ ) {
745
+ mappedGroups.push(current);
746
+ if (fieldsFor(current).length > 0) nextSequence += 1;
747
+ current = [entry];
748
+ } else {
749
+ current = candidate;
750
+ }
751
+ }
752
+ if (current.length > 0) {
753
+ mappedGroups.push(current);
754
+ if (fieldsFor(current).length > 0) nextSequence += 1;
755
+ }
756
+ }
757
+ for (const group of mappedGroups) {
758
+ const fields = fieldsFor(group);
759
+ const blockId = fieldBlockId(fields, context);
413
760
  blocks.push({
414
761
  type: "actions",
415
- elements: converted.slice(index, index + ACTIONS_ELEMENT_CAP),
762
+ ...(blockId !== undefined ? { block_id: blockId } : {}),
763
+ elements: group.map(({ element }) => element),
416
764
  });
417
765
  }
418
766
  return blocks;
@@ -914,12 +1262,18 @@ const convertCarousel = (
914
1262
  return [buildCarouselBlock(cards)];
915
1263
  };
916
1264
 
917
- const toDataTableCell = (value: unknown): SlackDataTableCell | undefined => {
918
- if (typeof value === "number") {
1265
+ const toDataTableCell = (
1266
+ value: unknown,
1267
+ format: unknown,
1268
+ ): SlackDataTableCell | undefined => {
1269
+ if (typeof value === "number" && format === undefined) {
919
1270
  return { type: "raw_number", text: String(value) };
920
1271
  }
921
1272
  if (typeof value === "string" || typeof value === "boolean") {
922
- return { type: "raw_text", text: String(value) };
1273
+ return { type: "raw_text", text: formatValue(value, format) };
1274
+ }
1275
+ if (typeof value === "number") {
1276
+ return { type: "raw_text", text: formatValue(value, format) };
923
1277
  }
924
1278
  return undefined;
925
1279
  };
@@ -982,8 +1336,13 @@ const convertTable = (
982
1336
  ? copyBounded(row, DATA_TABLE_COLUMN_CAP).items
983
1337
  : []
984
1338
  ).map(
985
- (value) =>
986
- toDataTableCell(value) ?? { type: "raw_text" as const, text: "" },
1339
+ (value, index) =>
1340
+ toDataTableCell(
1341
+ value,
1342
+ isRecord(takenColumns[index])
1343
+ ? takenColumns[index]["format"]
1344
+ : undefined,
1345
+ ) ?? { type: "raw_text" as const, text: "" },
987
1346
  ),
988
1347
  );
989
1348
  const width = Math.max(
@@ -1115,9 +1474,68 @@ const convertElement = (
1115
1474
  case "Select":
1116
1475
  case "DatePicker":
1117
1476
  case "Checkbox":
1477
+ case "CheckboxGroup":
1118
1478
  case "RadioGroup":
1119
1479
  return convertActions([element], context);
1480
+ case "Slider": {
1481
+ const label = clampText(
1482
+ asString(props["label"]),
1483
+ INPUT_LABEL_CAP,
1484
+ "Slider",
1485
+ "label",
1486
+ context,
1487
+ );
1488
+ const min = asFiniteNumber(props["min"]);
1489
+ const max = asFiniteNumber(props["max"]);
1490
+ const step = asFiniteNumber(props["step"]);
1491
+ const defaultValue = asFiniteNumber(props["defaultValue"]);
1492
+ const actionId = asActionId(element.action, "Slider", context);
1493
+ const name = props["name"];
1494
+ const blockId = fieldBlockId(
1495
+ typeof name === "string" && name.length > 0
1496
+ ? [{ actionId, name, component: "Slider" }]
1497
+ : [],
1498
+ context,
1499
+ );
1500
+ return [
1501
+ {
1502
+ type: "input",
1503
+ ...(blockId !== undefined ? { block_id: blockId } : {}),
1504
+ label: plainText(label),
1505
+ element: {
1506
+ type: "number_input",
1507
+ action_id: actionId,
1508
+ ...(min !== undefined ? { min_value: min } : {}),
1509
+ ...(max !== undefined ? { max_value: max } : {}),
1510
+ ...(defaultValue !== undefined
1511
+ ? { initial_value: defaultValue }
1512
+ : {}),
1513
+ ...([step, min, max, defaultValue].some(
1514
+ (value) => value !== undefined && !Number.isInteger(value),
1515
+ )
1516
+ ? { is_decimal_allowed: true }
1517
+ : {}),
1518
+ },
1519
+ } as unknown as SlackBlock,
1520
+ ];
1521
+ }
1120
1522
  case "Input": {
1523
+ if (props["inputType"] === "password") {
1524
+ warn(
1525
+ context,
1526
+ "dropped",
1527
+ "Input",
1528
+ "Password input was replaced by a Slack omission note.",
1529
+ );
1530
+ return [
1531
+ {
1532
+ type: "context",
1533
+ elements: [
1534
+ { type: "mrkdwn", text: "Password input omitted on Slack." },
1535
+ ],
1536
+ },
1537
+ ];
1538
+ }
1121
1539
  const label = clampText(
1122
1540
  asString(props["label"]),
1123
1541
  INPUT_LABEL_CAP,
@@ -1132,14 +1550,30 @@ const convertElement = (
1132
1550
  "placeholder",
1133
1551
  context,
1134
1552
  );
1553
+ const actionId = asActionId(element.action, "Input", context);
1554
+ const name = props["name"];
1555
+ const blockId = fieldBlockId(
1556
+ typeof name === "string" && name.length > 0
1557
+ ? [{ actionId, name, component: "Input" }]
1558
+ : [],
1559
+ context,
1560
+ );
1561
+ const defaultValue = props["defaultValue"];
1135
1562
  return [
1136
1563
  {
1137
1564
  type: "input",
1565
+ ...(blockId !== undefined ? { block_id: blockId } : {}),
1138
1566
  label: plainText(label),
1139
1567
  element: {
1140
1568
  type: "plain_text_input",
1141
- action_id: asActionId(element.action, "Input", context),
1142
- ...(props["multiline"] === true ? { multiline: true } : {}),
1569
+ action_id: actionId,
1570
+ ...(props["multiline"] === true &&
1571
+ (props["inputType"] === undefined || props["inputType"] === "text")
1572
+ ? { multiline: true }
1573
+ : {}),
1574
+ ...(typeof defaultValue === "string" && defaultValue
1575
+ ? { initial_value: defaultValue }
1576
+ : {}),
1143
1577
  ...(placeholder ? { placeholder: plainText(placeholder) } : {}),
1144
1578
  },
1145
1579
  },
@@ -1340,6 +1774,9 @@ export function toSlackBlocks(
1340
1774
  blockCap: surface === "modal" ? MODAL_BLOCK_CAP : MESSAGE_BLOCK_CAP,
1341
1775
  surface,
1342
1776
  warnings: [],
1777
+ omittedPasswordNames: new Set(),
1778
+ fieldBlockIdSequence: 0,
1779
+ dateTimePairSequence: 0,
1343
1780
  markdownCharacters: 0,
1344
1781
  markdownExhausted: false,
1345
1782
  dataTableCharacters: 0,
@@ -1349,6 +1786,7 @@ export function toSlackBlocks(
1349
1786
  warn(context, "clamped", "Root", clampReasonDetail(reason)),
1350
1787
  );
1351
1788
  const { root } = normalizeSpec(bounded as never);
1789
+ collectOmittedPasswordNames(root, context.omittedPasswordNames);
1352
1790
  const converted = convertSequence(root, context, 0);
1353
1791
  if (converted.length <= context.blockCap) {
1354
1792
  return { blocks: converted, warnings: context.warnings };