@assistant-ui/react-generative-ui 0.0.20 → 0.0.22

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 (189) 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 +369 -87
  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 +2 -0
  43. package/dist/constants.d.ts.map +1 -1
  44. package/dist/constants.js +3 -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 +58 -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 +20 -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 +187 -19
  73. package/dist/slack/toSlackBlocks.js.map +1 -1
  74. package/dist/slack/types.d.ts +4 -0
  75. package/dist/slack/types.d.ts.map +1 -1
  76. package/dist/teams/constants.d.ts +1 -1
  77. package/dist/teams/constants.d.ts.map +1 -1
  78. package/dist/teams/constants.js +1 -1
  79. package/dist/teams/constants.js.map +1 -1
  80. package/dist/teams/decodeSubmitData.d.ts +4 -0
  81. package/dist/teams/decodeSubmitData.d.ts.map +1 -1
  82. package/dist/teams/decodeSubmitData.js +6 -1
  83. package/dist/teams/decodeSubmitData.js.map +1 -1
  84. package/dist/teams/toAdaptiveCard.d.ts +5 -4
  85. package/dist/teams/toAdaptiveCard.d.ts.map +1 -1
  86. package/dist/teams/toAdaptiveCard.js +56 -17
  87. package/dist/teams/toAdaptiveCard.js.map +1 -1
  88. package/dist/teams/types.d.ts +4 -1
  89. package/dist/teams/types.d.ts.map +1 -1
  90. package/dist/vocabulary/alert.d.ts +9 -2
  91. package/dist/vocabulary/alert.d.ts.map +1 -1
  92. package/dist/vocabulary/alert.js +116 -19
  93. package/dist/vocabulary/alert.js.map +1 -1
  94. package/dist/vocabulary/collectFormValues.d.ts +2 -1
  95. package/dist/vocabulary/collectFormValues.d.ts.map +1 -1
  96. package/dist/vocabulary/collectFormValues.js +10 -3
  97. package/dist/vocabulary/collectFormValues.js.map +1 -1
  98. package/dist/vocabulary/data.d.ts +22 -5
  99. package/dist/vocabulary/data.d.ts.map +1 -1
  100. package/dist/vocabulary/data.js +278 -83
  101. package/dist/vocabulary/data.js.map +1 -1
  102. package/dist/vocabulary/dispatch.d.ts +3 -2
  103. package/dist/vocabulary/dispatch.d.ts.map +1 -1
  104. package/dist/vocabulary/dispatch.js +33 -4
  105. package/dist/vocabulary/dispatch.js.map +1 -1
  106. package/dist/vocabulary/fact.d.ts +8 -1
  107. package/dist/vocabulary/fact.d.ts.map +1 -1
  108. package/dist/vocabulary/fact.js +37 -11
  109. package/dist/vocabulary/fact.js.map +1 -1
  110. package/dist/vocabulary/form.d.ts.map +1 -1
  111. package/dist/vocabulary/form.js +3 -3
  112. package/dist/vocabulary/form.js.map +1 -1
  113. package/dist/vocabulary/formatValue.d.ts +7 -0
  114. package/dist/vocabulary/formatValue.d.ts.map +1 -0
  115. package/dist/vocabulary/formatValue.js +76 -0
  116. package/dist/vocabulary/formatValue.js.map +1 -0
  117. package/dist/vocabulary/icon.d.ts +1 -1
  118. package/dist/vocabulary/interactive.d.ts +137 -8
  119. package/dist/vocabulary/interactive.d.ts.map +1 -1
  120. package/dist/vocabulary/interactive.js +404 -103
  121. package/dist/vocabulary/interactive.js.map +1 -1
  122. package/dist/vocabulary/layout.d.ts.map +1 -1
  123. package/dist/vocabulary/layout.js +4 -4
  124. package/dist/vocabulary/layout.js.map +1 -1
  125. package/dist/vocabulary/list.js +2 -2
  126. package/dist/vocabulary/list.js.map +1 -1
  127. package/dist/vocabulary/text.d.ts +1 -1
  128. package/package.json +6 -3
  129. package/src/JSONGenerativeUI.client.tsx +84 -40
  130. package/src/JSONGenerativeUI.test.tsx +964 -68
  131. package/src/RadioGroupScope.server.test.tsx +24 -0
  132. package/src/RadioGroupScope.tsx +34 -0
  133. package/src/a2ui/BindingContext.ts +26 -0
  134. package/src/a2ui/PresentRenderer.tsx +83 -0
  135. package/src/a2ui/convert.test.ts +1982 -145
  136. package/src/a2ui/convert.ts +677 -137
  137. package/src/a2ui/dataModel.ts +69 -0
  138. package/src/a2ui/reducer.test.ts +35 -0
  139. package/src/a2ui/reducer.ts +5 -4
  140. package/src/a2ui/types.ts +9 -2
  141. package/src/a2ui/valueFunctions.test.ts +271 -0
  142. package/src/a2ui/valueFunctions.ts +523 -0
  143. package/src/actionRegistry.ts +1 -1
  144. package/src/answeredValues.test.ts +33 -0
  145. package/src/answeredValues.tsx +70 -0
  146. package/src/constants.ts +4 -0
  147. package/src/fieldReferences.test.ts +66 -0
  148. package/src/fieldReferences.ts +83 -0
  149. package/src/generativeUIToJSX.test.ts +18 -0
  150. package/src/generativeUIToJSX.ts +4 -6
  151. package/src/index.server.test.ts +106 -0
  152. package/src/ir.ts +1 -1
  153. package/src/renderGenerativeUI.test.tsx +14 -5
  154. package/src/renderGenerativeUI.tsx +30 -1
  155. package/src/slack/a2uiSlack.test.ts +198 -0
  156. package/src/slack/constants.ts +3 -0
  157. package/src/slack/decodeBlockAction.test.ts +98 -0
  158. package/src/slack/decodeBlockAction.ts +110 -17
  159. package/src/slack/fromSlackBlocks.test.ts +222 -3
  160. package/src/slack/fromSlackBlocks.ts +53 -2
  161. package/src/slack/toSlackBlocks.test.ts +682 -3
  162. package/src/slack/toSlackBlocks.ts +374 -23
  163. package/src/slack/types.ts +4 -0
  164. package/src/teams/constants.ts +1 -1
  165. package/src/teams/decodeSubmitData.test.ts +33 -0
  166. package/src/teams/decodeSubmitData.ts +9 -1
  167. package/src/teams/toAdaptiveCard.test.ts +219 -0
  168. package/src/teams/toAdaptiveCard.ts +93 -19
  169. package/src/teams/types.ts +4 -1
  170. package/src/vocabulary/alert.test.tsx +288 -7
  171. package/src/vocabulary/alert.tsx +168 -26
  172. package/src/vocabulary/collectFormValues.test.ts +28 -1
  173. package/src/vocabulary/collectFormValues.ts +25 -5
  174. package/src/vocabulary/data.test.tsx +248 -6
  175. package/src/vocabulary/data.tsx +398 -100
  176. package/src/vocabulary/dispatch.ts +63 -2
  177. package/src/vocabulary/fact.test.tsx +67 -0
  178. package/src/vocabulary/fact.tsx +40 -9
  179. package/src/vocabulary/form.tsx +7 -2
  180. package/src/vocabulary/formatValue.test.ts +72 -0
  181. package/src/vocabulary/formatValue.ts +127 -0
  182. package/src/vocabulary/index.test.ts +2 -0
  183. package/src/vocabulary/interactive.dispatch.test.tsx +167 -79
  184. package/src/vocabulary/interactive.dom.test.tsx +1699 -0
  185. package/src/vocabulary/interactive.test.tsx +60 -4
  186. package/src/vocabulary/interactive.tsx +770 -151
  187. package/src/vocabulary/layout.test.tsx +6 -4
  188. package/src/vocabulary/layout.tsx +13 -2
  189. package/src/vocabulary/list.tsx +2 -2
@@ -11,6 +11,11 @@ import {
11
11
  type NormalizedUIElement,
12
12
  type NormalizedUINode,
13
13
  } from "../ir";
14
+ import {
15
+ factTrend,
16
+ formatFactDelta,
17
+ formatValue,
18
+ } from "../vocabulary/formatValue";
14
19
  import {
15
20
  ACTION_ID_CAP,
16
21
  ACTIONS_ELEMENT_CAP,
@@ -22,6 +27,7 @@ import {
22
27
  CARD_TITLE_CAP,
23
28
  CAROUSEL_CARD_CAP,
24
29
  CAROUSEL_CARD_MIN,
30
+ CHECKBOX_OPTION_CAP,
25
31
  CONTEXT_ELEMENT_CAP,
26
32
  CONTEXT_TEXT_CAP,
27
33
  DATA_TABLE_CHAR_BUDGET,
@@ -65,16 +71,27 @@ type ConversionContext = {
65
71
  readonly blockCap: number;
66
72
  readonly surface: "message" | "modal";
67
73
  readonly warnings: SlackConversionWarning[];
74
+ readonly omittedPasswordNames: Set<string>;
75
+ fieldBlockIdSequence: number;
68
76
  markdownCharacters: number;
69
77
  markdownExhausted: boolean;
70
78
  dataTableCharacters: number;
71
79
  };
72
80
 
81
+ type FieldMapping = {
82
+ readonly actionId: string;
83
+ readonly name: string;
84
+ readonly component: string;
85
+ };
86
+
87
+ const SLACK_BLOCK_ID_CAP = 255;
88
+
73
89
  const INTERACTIVE_TYPES = new Set([
74
90
  "Button",
75
91
  "Select",
76
92
  "DatePicker",
77
93
  "Checkbox",
94
+ "CheckboxGroup",
78
95
  "RadioGroup",
79
96
  ]);
80
97
 
@@ -84,7 +101,12 @@ const INTERACTIVE_TYPES = new Set([
84
101
  * block rather than an actions element, but a reshape loses them the same way,
85
102
  * and a `Form` gets a Submit button whether or not it carries an action.
86
103
  */
87
- const CONTROL_TYPES = new Set([...INTERACTIVE_TYPES, "Input", "Form"]);
104
+ const CONTROL_TYPES = new Set([
105
+ ...INTERACTIVE_TYPES,
106
+ "Input",
107
+ "Slider",
108
+ "Form",
109
+ ]);
88
110
 
89
111
  const DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/;
90
112
 
@@ -94,6 +116,9 @@ const isRecord = (value: unknown): value is Record<string, unknown> =>
94
116
  const asString = (value: unknown): string =>
95
117
  typeof value === "string" ? value : "";
96
118
 
119
+ const asFiniteNumber = (value: unknown): number | undefined =>
120
+ typeof value === "number" && Number.isFinite(value) ? value : undefined;
121
+
97
122
  const warn = (
98
123
  context: ConversionContext,
99
124
  code: SlackConversionWarning["code"],
@@ -140,16 +165,103 @@ const plainText = (text: string): SlackPlainText => ({
140
165
  text,
141
166
  });
142
167
 
143
- const actionValue = (action: unknown): string | undefined => {
168
+ const serializeFieldBlockId = (
169
+ sequence: number,
170
+ fields: readonly FieldMapping[],
171
+ ): string =>
172
+ `aui:${sequence}:${JSON.stringify(
173
+ fields.map(({ actionId, name, component }) =>
174
+ component === "Checkbox"
175
+ ? [actionId, name, "Checkbox"]
176
+ : [actionId, name],
177
+ ),
178
+ )}`;
179
+
180
+ const fieldBlockId = (
181
+ fields: readonly FieldMapping[],
182
+ context: ConversionContext,
183
+ ): string | undefined => {
184
+ if (fields.length === 0) return undefined;
185
+ const sequence = context.fieldBlockIdSequence++;
186
+ const blockId = serializeFieldBlockId(sequence, fields);
187
+ if (blockId.length <= SLACK_BLOCK_ID_CAP) return blockId;
188
+ for (const field of fields) {
189
+ warn(
190
+ context,
191
+ "dropped",
192
+ field.component,
193
+ "name could not be mapped because its Slack block_id exceeded 255 characters.",
194
+ );
195
+ }
196
+ return undefined;
197
+ };
198
+
199
+ const collectOmittedPasswordNames = (
200
+ node: NormalizedUINode,
201
+ names: Set<string>,
202
+ depth = 0,
203
+ ): void => {
204
+ if (depth > MAX_TRAVERSAL_DEPTH) return;
205
+ if (Array.isArray(node)) {
206
+ for (const child of node) {
207
+ collectOmittedPasswordNames(child, names, depth + 1);
208
+ }
209
+ } else if (isElement(node)) {
210
+ if (
211
+ node.type === "Input" &&
212
+ node.props["inputType"] === "password" &&
213
+ typeof node.props["name"] === "string"
214
+ ) {
215
+ names.add(node.props["name"]);
216
+ }
217
+ if (node.children !== undefined) {
218
+ collectOmittedPasswordNames(node.children, names, depth + 1);
219
+ }
220
+ }
221
+ };
222
+
223
+ const withoutOmittedPasswordFallbacks = (
224
+ value: unknown,
225
+ names: ReadonlySet<string>,
226
+ depth = 0,
227
+ ): unknown => {
228
+ if (depth > MAX_TRAVERSAL_DEPTH) {
229
+ throw new RangeError("Action payload nests too deeply to sanitize.");
230
+ }
231
+ if (Array.isArray(value)) {
232
+ return value.map((item) =>
233
+ withoutOmittedPasswordFallbacks(item, names, depth + 1),
234
+ );
235
+ }
236
+ if (!isRecord(value)) return value;
237
+ if (
238
+ typeof value["$field"] === "string" &&
239
+ names.has(value["$field"]) &&
240
+ Object.keys(value).every((key) => key === "$field" || key === "fallback")
241
+ ) {
242
+ return { $field: value["$field"] };
243
+ }
244
+ return Object.fromEntries(
245
+ Object.entries(value).map(([key, item]) => [
246
+ key,
247
+ withoutOmittedPasswordFallbacks(item, names, depth + 1),
248
+ ]),
249
+ );
250
+ };
251
+
252
+ const actionValue = (
253
+ action: unknown,
254
+ context: ConversionContext,
255
+ ): string | undefined => {
144
256
  if (!isRecord(action)) return undefined;
145
257
  const { type: _type, ...payload } = action;
146
258
  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
- }
259
+ const serialized = JSON.stringify(
260
+ context.omittedPasswordNames.size === 0
261
+ ? payload
262
+ : withoutOmittedPasswordFallbacks(payload, context.omittedPasswordNames),
263
+ );
264
+ return serialized === "{}" ? undefined : serialized;
153
265
  };
154
266
 
155
267
  const buttonElement = (
@@ -159,8 +271,17 @@ const buttonElement = (
159
271
  component: string,
160
272
  context: ConversionContext,
161
273
  ): SlackButtonElement => {
162
- const serializedValue = actionValue(action);
163
- let value = serializedValue;
274
+ let value: string | undefined;
275
+ try {
276
+ value = actionValue(action, context);
277
+ } catch {
278
+ warn(
279
+ context,
280
+ "dropped",
281
+ component,
282
+ "value was dropped because the action payload could not be serialized.",
283
+ );
284
+ }
164
285
  if (value !== undefined && value.length > BUTTON_VALUE_CAP) {
165
286
  warn(
166
287
  context,
@@ -197,6 +318,7 @@ const optionFrom = (
197
318
  ) {
198
319
  return undefined;
199
320
  }
321
+ const description = asString(value["description"]);
200
322
  return {
201
323
  text: plainText(
202
324
  clampText(
@@ -208,7 +330,20 @@ const optionFrom = (
208
330
  ),
209
331
  ),
210
332
  value: value["value"],
211
- };
333
+ ...(description
334
+ ? {
335
+ description: plainText(
336
+ clampText(
337
+ description,
338
+ INTERACTIVE_TEXT_CAP,
339
+ component,
340
+ "option description",
341
+ context,
342
+ ),
343
+ ),
344
+ }
345
+ : {}),
346
+ } as SlackOption;
212
347
  };
213
348
 
214
349
  const warnDroppedOptions = (
@@ -273,10 +408,18 @@ const toActionElement = (
273
408
  "placeholder",
274
409
  context,
275
410
  );
411
+ const defaultValue = props["defaultValue"];
412
+ const initialOption =
413
+ typeof defaultValue === "string"
414
+ ? options.find((option) => option.value === defaultValue)
415
+ : undefined;
276
416
  return {
277
417
  type: "static_select",
278
418
  action_id: asActionId(action, "Select", context),
279
419
  options,
420
+ ...(initialOption !== undefined
421
+ ? { initial_option: initialOption }
422
+ : {}),
280
423
  ...(placeholder ? { placeholder: plainText(placeholder) } : {}),
281
424
  };
282
425
  }
@@ -319,6 +462,46 @@ const toActionElement = (
319
462
  : {}),
320
463
  };
321
464
  }
465
+ case "CheckboxGroup": {
466
+ const rawOptions = Array.isArray(props["options"])
467
+ ? props["options"]
468
+ : [];
469
+ const { items: takenOptions, truncated } = copyBounded(
470
+ rawOptions,
471
+ CHECKBOX_OPTION_CAP,
472
+ );
473
+ if (truncated) {
474
+ warn(
475
+ context,
476
+ "clamped",
477
+ "CheckboxGroup",
478
+ `options were clamped to ${CHECKBOX_OPTION_CAP} entries.`,
479
+ );
480
+ }
481
+ const options = takenOptions
482
+ .map((option) => optionFrom(option, "CheckboxGroup", context))
483
+ .filter((option): option is SlackOption => option !== undefined);
484
+ warnDroppedOptions(
485
+ options.length,
486
+ takenOptions.length,
487
+ "CheckboxGroup",
488
+ context,
489
+ );
490
+ const defaultValue = Array.isArray(props["defaultValue"])
491
+ ? props["defaultValue"]
492
+ : [];
493
+ const initialOptions = options.filter((option) =>
494
+ defaultValue.includes(option.value),
495
+ );
496
+ return {
497
+ type: "checkboxes",
498
+ action_id: asActionId(action, "CheckboxGroup", context),
499
+ options,
500
+ ...(initialOptions.length > 0
501
+ ? { initial_options: initialOptions }
502
+ : {}),
503
+ };
504
+ }
322
505
  case "RadioGroup": {
323
506
  const rawOptions = Array.isArray(props["options"])
324
507
  ? props["options"]
@@ -380,10 +563,15 @@ const convertFacts = (
380
563
  const fields = facts.map((fact) => {
381
564
  const label = asString(fact.props["label"]);
382
565
  const value = asString(fact.props["value"]);
566
+ const delta = fact.props["delta"];
567
+ const deltaText =
568
+ typeof delta === "string"
569
+ ? formatFactDelta(delta, factTrend(delta, fact.props["trend"]))
570
+ : undefined;
383
571
  return {
384
572
  type: "mrkdwn" as const,
385
573
  text: clampText(
386
- `*${label}*\n${value}`,
574
+ `*${label}*\n${deltaText === undefined ? value : `${value} (${deltaText})`}`,
387
575
  FACT_FIELD_TEXT_CAP,
388
576
  "Fact",
389
577
  "field",
@@ -406,13 +594,87 @@ const convertActions = (
406
594
  context: ConversionContext,
407
595
  ): SlackBlock[] => {
408
596
  const converted = elements
409
- .map((element) => toActionElement(element, context))
410
- .filter((element): element is SlackActionElement => element !== undefined);
597
+ .map((source) => ({
598
+ source,
599
+ element: toActionElement(source, context),
600
+ }))
601
+ .filter(
602
+ (
603
+ entry,
604
+ ): entry is {
605
+ readonly source: NormalizedUIElement;
606
+ readonly element: SlackActionElement;
607
+ } => entry.element !== undefined,
608
+ );
411
609
  const blocks: SlackBlock[] = [];
412
- for (let index = 0; index < converted.length; index += ACTIONS_ELEMENT_CAP) {
610
+ const groups: (typeof converted)[] = [];
611
+ let group: typeof converted = [];
612
+ let actionIds = new Set<string>();
613
+ const hasFieldName = (entry: (typeof converted)[number]): boolean => {
614
+ const name = entry.source.props["name"];
615
+ return typeof name === "string" && name.length > 0;
616
+ };
617
+ const fieldsFor = (entries: typeof converted): FieldMapping[] =>
618
+ entries.flatMap(({ source, element }) => {
619
+ const name = source.props["name"];
620
+ return typeof name === "string" && name.length > 0
621
+ ? [{ actionId: element.action_id, name, component: source.type }]
622
+ : [];
623
+ });
624
+ for (const entry of converted) {
625
+ const repeatedMappedActionId =
626
+ actionIds.has(entry.element.action_id) &&
627
+ (hasFieldName(entry) ||
628
+ group.some(
629
+ (candidate) =>
630
+ candidate.element.action_id === entry.element.action_id &&
631
+ hasFieldName(candidate),
632
+ ));
633
+ if (group.length === ACTIONS_ELEMENT_CAP || repeatedMappedActionId) {
634
+ groups.push(group);
635
+ group = [];
636
+ actionIds = new Set();
637
+ }
638
+ group.push(entry);
639
+ actionIds.add(entry.element.action_id);
640
+ }
641
+ if (group.length > 0) groups.push(group);
642
+ const mappedGroups: (typeof converted)[] = [];
643
+ let nextSequence = context.fieldBlockIdSequence;
644
+ for (const entries of groups) {
645
+ let current: typeof converted = [];
646
+ for (const entry of entries) {
647
+ const candidate = [...current, entry];
648
+ const fields = fieldsFor(candidate);
649
+ const blockId =
650
+ fields.length > 0
651
+ ? serializeFieldBlockId(nextSequence, fields)
652
+ : undefined;
653
+ if (
654
+ current.length > 0 &&
655
+ fields.length > fieldsFor(current).length &&
656
+ blockId !== undefined &&
657
+ blockId.length > SLACK_BLOCK_ID_CAP
658
+ ) {
659
+ mappedGroups.push(current);
660
+ if (fieldsFor(current).length > 0) nextSequence += 1;
661
+ current = [entry];
662
+ } else {
663
+ current = candidate;
664
+ }
665
+ }
666
+ if (current.length > 0) {
667
+ mappedGroups.push(current);
668
+ if (fieldsFor(current).length > 0) nextSequence += 1;
669
+ }
670
+ }
671
+ for (const group of mappedGroups) {
672
+ const fields = fieldsFor(group);
673
+ const blockId = fieldBlockId(fields, context);
413
674
  blocks.push({
414
675
  type: "actions",
415
- elements: converted.slice(index, index + ACTIONS_ELEMENT_CAP),
676
+ ...(blockId !== undefined ? { block_id: blockId } : {}),
677
+ elements: group.map(({ element }) => element),
416
678
  });
417
679
  }
418
680
  return blocks;
@@ -914,12 +1176,18 @@ const convertCarousel = (
914
1176
  return [buildCarouselBlock(cards)];
915
1177
  };
916
1178
 
917
- const toDataTableCell = (value: unknown): SlackDataTableCell | undefined => {
918
- if (typeof value === "number") {
1179
+ const toDataTableCell = (
1180
+ value: unknown,
1181
+ format: unknown,
1182
+ ): SlackDataTableCell | undefined => {
1183
+ if (typeof value === "number" && format === undefined) {
919
1184
  return { type: "raw_number", text: String(value) };
920
1185
  }
921
1186
  if (typeof value === "string" || typeof value === "boolean") {
922
- return { type: "raw_text", text: String(value) };
1187
+ return { type: "raw_text", text: formatValue(value, format) };
1188
+ }
1189
+ if (typeof value === "number") {
1190
+ return { type: "raw_text", text: formatValue(value, format) };
923
1191
  }
924
1192
  return undefined;
925
1193
  };
@@ -982,8 +1250,13 @@ const convertTable = (
982
1250
  ? copyBounded(row, DATA_TABLE_COLUMN_CAP).items
983
1251
  : []
984
1252
  ).map(
985
- (value) =>
986
- toDataTableCell(value) ?? { type: "raw_text" as const, text: "" },
1253
+ (value, index) =>
1254
+ toDataTableCell(
1255
+ value,
1256
+ isRecord(takenColumns[index])
1257
+ ? takenColumns[index]["format"]
1258
+ : undefined,
1259
+ ) ?? { type: "raw_text" as const, text: "" },
987
1260
  ),
988
1261
  );
989
1262
  const width = Math.max(
@@ -1115,9 +1388,68 @@ const convertElement = (
1115
1388
  case "Select":
1116
1389
  case "DatePicker":
1117
1390
  case "Checkbox":
1391
+ case "CheckboxGroup":
1118
1392
  case "RadioGroup":
1119
1393
  return convertActions([element], context);
1394
+ case "Slider": {
1395
+ const label = clampText(
1396
+ asString(props["label"]),
1397
+ INPUT_LABEL_CAP,
1398
+ "Slider",
1399
+ "label",
1400
+ context,
1401
+ );
1402
+ const min = asFiniteNumber(props["min"]);
1403
+ const max = asFiniteNumber(props["max"]);
1404
+ const step = asFiniteNumber(props["step"]);
1405
+ const defaultValue = asFiniteNumber(props["defaultValue"]);
1406
+ const actionId = asActionId(element.action, "Slider", context);
1407
+ const name = props["name"];
1408
+ const blockId = fieldBlockId(
1409
+ typeof name === "string" && name.length > 0
1410
+ ? [{ actionId, name, component: "Slider" }]
1411
+ : [],
1412
+ context,
1413
+ );
1414
+ return [
1415
+ {
1416
+ type: "input",
1417
+ ...(blockId !== undefined ? { block_id: blockId } : {}),
1418
+ label: plainText(label),
1419
+ element: {
1420
+ type: "number_input",
1421
+ action_id: actionId,
1422
+ ...(min !== undefined ? { min_value: min } : {}),
1423
+ ...(max !== undefined ? { max_value: max } : {}),
1424
+ ...(defaultValue !== undefined
1425
+ ? { initial_value: defaultValue }
1426
+ : {}),
1427
+ ...([step, min, max, defaultValue].some(
1428
+ (value) => value !== undefined && !Number.isInteger(value),
1429
+ )
1430
+ ? { is_decimal_allowed: true }
1431
+ : {}),
1432
+ },
1433
+ } as unknown as SlackBlock,
1434
+ ];
1435
+ }
1120
1436
  case "Input": {
1437
+ if (props["inputType"] === "password") {
1438
+ warn(
1439
+ context,
1440
+ "dropped",
1441
+ "Input",
1442
+ "Password input was replaced by a Slack omission note.",
1443
+ );
1444
+ return [
1445
+ {
1446
+ type: "context",
1447
+ elements: [
1448
+ { type: "mrkdwn", text: "Password input omitted on Slack." },
1449
+ ],
1450
+ },
1451
+ ];
1452
+ }
1121
1453
  const label = clampText(
1122
1454
  asString(props["label"]),
1123
1455
  INPUT_LABEL_CAP,
@@ -1132,14 +1464,30 @@ const convertElement = (
1132
1464
  "placeholder",
1133
1465
  context,
1134
1466
  );
1467
+ const actionId = asActionId(element.action, "Input", context);
1468
+ const name = props["name"];
1469
+ const blockId = fieldBlockId(
1470
+ typeof name === "string" && name.length > 0
1471
+ ? [{ actionId, name, component: "Input" }]
1472
+ : [],
1473
+ context,
1474
+ );
1475
+ const defaultValue = props["defaultValue"];
1135
1476
  return [
1136
1477
  {
1137
1478
  type: "input",
1479
+ ...(blockId !== undefined ? { block_id: blockId } : {}),
1138
1480
  label: plainText(label),
1139
1481
  element: {
1140
1482
  type: "plain_text_input",
1141
- action_id: asActionId(element.action, "Input", context),
1142
- ...(props["multiline"] === true ? { multiline: true } : {}),
1483
+ action_id: actionId,
1484
+ ...(props["multiline"] === true &&
1485
+ (props["inputType"] === undefined || props["inputType"] === "text")
1486
+ ? { multiline: true }
1487
+ : {}),
1488
+ ...(typeof defaultValue === "string" && defaultValue
1489
+ ? { initial_value: defaultValue }
1490
+ : {}),
1143
1491
  ...(placeholder ? { placeholder: plainText(placeholder) } : {}),
1144
1492
  },
1145
1493
  },
@@ -1340,6 +1688,8 @@ export function toSlackBlocks(
1340
1688
  blockCap: surface === "modal" ? MODAL_BLOCK_CAP : MESSAGE_BLOCK_CAP,
1341
1689
  surface,
1342
1690
  warnings: [],
1691
+ omittedPasswordNames: new Set(),
1692
+ fieldBlockIdSequence: 0,
1343
1693
  markdownCharacters: 0,
1344
1694
  markdownExhausted: false,
1345
1695
  dataTableCharacters: 0,
@@ -1349,6 +1699,7 @@ export function toSlackBlocks(
1349
1699
  warn(context, "clamped", "Root", clampReasonDetail(reason)),
1350
1700
  );
1351
1701
  const { root } = normalizeSpec(bounded as never);
1702
+ collectOmittedPasswordNames(root, context.omittedPasswordNames);
1352
1703
  const converted = convertSequence(root, context, 0);
1353
1704
  if (converted.length <= context.blockCap) {
1354
1705
  return { blocks: converted, warnings: context.warnings };
@@ -35,6 +35,7 @@ export interface SlackStaticSelectElement {
35
35
  readonly type: "static_select";
36
36
  readonly action_id: string;
37
37
  readonly options: readonly SlackOption[];
38
+ readonly initial_option?: SlackOption;
38
39
  readonly placeholder?: SlackPlainText;
39
40
  }
40
41
 
@@ -66,6 +67,7 @@ export interface SlackPlainTextInputElement {
66
67
  readonly type: "plain_text_input";
67
68
  readonly action_id: string;
68
69
  readonly multiline?: boolean;
70
+ readonly initial_value?: string;
69
71
  readonly placeholder?: SlackPlainText;
70
72
  }
71
73
 
@@ -112,12 +114,14 @@ export interface SlackDividerBlock {
112
114
  /** A Slack actions block. */
113
115
  export interface SlackActionsBlock {
114
116
  readonly type: "actions";
117
+ readonly block_id?: string;
115
118
  readonly elements: readonly SlackActionElement[];
116
119
  }
117
120
 
118
121
  /** A Slack input block. */
119
122
  export interface SlackInputBlock {
120
123
  readonly type: "input";
124
+ readonly block_id?: string;
121
125
  readonly label: SlackPlainText;
122
126
  readonly element: SlackPlainTextInputElement;
123
127
  }
@@ -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). */
@@ -23,6 +23,39 @@ describe("decodeSubmitData", () => {
23
23
  });
24
24
  });
25
25
 
26
+ it("keeps a number input as a number", () => {
27
+ const value = {
28
+ aui: { type: "set_quantity" },
29
+ quantity: 3.5,
30
+ };
31
+ expect(decodeSubmitData(value)).toEqual({
32
+ type: "set_quantity",
33
+ $input: { quantity: 3.5 },
34
+ });
35
+ });
36
+
37
+ it("resolves $field references in the payload from same-card inputs, else their fallback", () => {
38
+ const value = {
39
+ aui: {
40
+ type: "save",
41
+ payload: {
42
+ note: { $field: "note" },
43
+ form: { plan: [{ $field: "plan" }], missing: { $field: "gone" } },
44
+ renamed: { $field: "aui", fallback: "kept" },
45
+ },
46
+ },
47
+ note: "Ship it",
48
+ plan: "pro",
49
+ };
50
+ expect(decodeSubmitData(value)).toEqual({
51
+ type: "save",
52
+ note: "Ship it",
53
+ form: { plan: ["pro"] },
54
+ renamed: "kept",
55
+ $input: { note: "Ship it", plan: "pro" },
56
+ });
57
+ });
58
+
26
59
  it("omits $input when there are no other top-level keys", () => {
27
60
  expect(decodeSubmitData({ aui: { type: "approve" } })).toEqual({
28
61
  type: "approve",
@@ -1,3 +1,4 @@
1
+ import { resolveFieldReferences } from "../fieldReferences";
1
2
  import type { Action } from "../ir";
2
3
 
3
4
  const isRecord = (value: unknown): value is Record<string, unknown> =>
@@ -9,6 +10,10 @@ const isRecord = (value: unknown): value is Record<string, unknown> =>
9
10
  * the resume action under. Adaptive Cards fold every other same-card input's
10
11
  * value into the same submit object keyed by its `id`, so every top-level key
11
12
  * besides `aui` is collected into `$input` (omitted when there are none).
13
+ * Each `{ "$field": name }` reference inside the payload resolves to the
14
+ * same-card input value with that id, as the string Adaptive Cards submitted,
15
+ * and a reference with no such input resolves to its `fallback`, or is dropped
16
+ * without one.
12
17
  * `aui` is reserved for the envelope: `toAdaptiveCard` renames any input
13
18
  * whose id would collide to an unused id derived from it before encoding, so
14
19
  * a same-card input value can never land on this key. `$input` and `type`
@@ -47,7 +52,10 @@ export function decodeSubmitData(value: unknown): Action | undefined {
47
52
  inputEntries.length > 0 ? Object.fromEntries(inputEntries) : undefined;
48
53
 
49
54
  return {
50
- ...Object.fromEntries(payloadEntries),
55
+ ...(resolveFieldReferences(
56
+ Object.fromEntries(payloadEntries),
57
+ input ?? {},
58
+ ) as Record<string, unknown>),
51
59
  type,
52
60
  ...(input !== undefined ? { $input: input } : {}),
53
61
  };