@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
@@ -2,6 +2,30 @@ import { describe, expect, it } from "vitest";
2
2
  import { decodeBlockAction } from "./decodeBlockAction";
3
3
 
4
4
  describe("decodeBlockAction", () => {
5
+ it.each([
6
+ "{}",
7
+ '{"sku":"typed text","qty":2}',
8
+ '{"type":"other","$input":"typed text"}',
9
+ ])("preserves plain-text input containing JSON verbatim: %s", (value) => {
10
+ expect(
11
+ decodeBlockAction({
12
+ type: "plain_text_input",
13
+ action_id: "leave_note",
14
+ value,
15
+ }),
16
+ ).toEqual({ type: "leave_note", $input: value });
17
+ });
18
+
19
+ it("keeps typed text distinct from a button's serialized payload", () => {
20
+ const value = '{"sku":"abc123","$input":"ignored"}';
21
+ expect(
22
+ decodeBlockAction({ type: "button", action_id: "buy_item", value }),
23
+ ).toEqual({
24
+ type: "buy_item",
25
+ sku: "abc123",
26
+ });
27
+ });
28
+
5
29
  it("decodes a button action, spreading its JSON value into the payload", () => {
6
30
  const action = {
7
31
  action_id: "buy_item",
@@ -14,6 +38,70 @@ describe("decodeBlockAction", () => {
14
38
  });
15
39
  });
16
40
 
41
+ it("resolves nested field references from named plain-text input state", () => {
42
+ const blockId = `aui:0:${JSON.stringify([["note", "note"]])}`;
43
+ expect(
44
+ decodeBlockAction(
45
+ {
46
+ action_id: "save",
47
+ value: JSON.stringify({ context: { note: { $field: "note" } } }),
48
+ },
49
+ {
50
+ [blockId]: {
51
+ note: { type: "plain_text_input", value: "edited" },
52
+ },
53
+ },
54
+ ),
55
+ ).toEqual({ type: "save", context: { note: "edited" } });
56
+ });
57
+
58
+ it("maps each action in a shared block and preserves empty checkbox selections", () => {
59
+ const blockId = `aui:1:${JSON.stringify([
60
+ ["choose_plan", "plan"],
61
+ ["choose_tags", "tags"],
62
+ ["choose_date", "date"],
63
+ ["choose_size", "size"],
64
+ ])}`;
65
+ expect(
66
+ decodeBlockAction(
67
+ {
68
+ action_id: "submit",
69
+ value: JSON.stringify({
70
+ context: {
71
+ plan: { $field: "plan" },
72
+ tags: { $field: "tags" },
73
+ date: { $field: "date" },
74
+ size: { $field: "size" },
75
+ missing: { $field: "missing" },
76
+ },
77
+ }),
78
+ },
79
+ {
80
+ [blockId]: {
81
+ choose_plan: {
82
+ type: "static_select",
83
+ selected_option: { value: "pro" },
84
+ },
85
+ choose_tags: { type: "checkboxes", selected_options: [] },
86
+ choose_date: { type: "datepicker", selected_date: "2026-09-25" },
87
+ choose_size: {
88
+ type: "radio_buttons",
89
+ selected_option: { value: "small" },
90
+ },
91
+ },
92
+ },
93
+ ),
94
+ ).toEqual({
95
+ type: "submit",
96
+ context: {
97
+ plan: "pro",
98
+ tags: [],
99
+ date: "2026-09-25",
100
+ size: "small",
101
+ },
102
+ });
103
+ });
104
+
17
105
  it("decodes a static_select action, reading $input from selected_option.value", () => {
18
106
  const action = {
19
107
  action_id: "choose_color",
@@ -36,6 +124,16 @@ describe("decodeBlockAction", () => {
36
124
  });
37
125
  });
38
126
 
127
+ it("decodes a number input action as a number", () => {
128
+ expect(
129
+ decodeBlockAction({
130
+ type: "number_input",
131
+ action_id: "set_quantity",
132
+ value: "3.5",
133
+ }),
134
+ ).toEqual({ type: "set_quantity", $input: 3.5 });
135
+ });
136
+
39
137
  it("decodes a checkboxes action, reading $input from selected_options[].value", () => {
40
138
  const action = {
41
139
  action_id: "toggle_opts",
@@ -1,4 +1,5 @@
1
1
  import type { Action } from "../ir";
2
+ import { resolveFieldReferences } from "../fieldReferences";
2
3
 
3
4
  const isRecord = (value: unknown): value is Record<string, unknown> =>
4
5
  typeof value === "object" && value !== null && !Array.isArray(value);
@@ -8,12 +9,111 @@ const optionValue = (value: unknown): string | undefined =>
8
9
  ? value["value"]
9
10
  : undefined;
10
11
 
12
+ const selectedValue = (action: Record<string, unknown>): unknown => {
13
+ const selectedOption = optionValue(action["selected_option"]);
14
+ const selectedDate =
15
+ typeof action["selected_date"] === "string"
16
+ ? action["selected_date"]
17
+ : undefined;
18
+ const selectedOptions = Array.isArray(action["selected_options"])
19
+ ? action["selected_options"]
20
+ .map(optionValue)
21
+ .filter((value): value is string => value !== undefined)
22
+ : undefined;
23
+ const numberInput =
24
+ action["type"] === "number_input" && typeof action["value"] === "string"
25
+ ? Number(action["value"])
26
+ : undefined;
27
+ return (
28
+ selectedOption ??
29
+ selectedDate ??
30
+ (selectedOptions !== undefined
31
+ ? selectedOptions
32
+ : Number.isFinite(numberInput)
33
+ ? numberInput
34
+ : undefined)
35
+ );
36
+ };
37
+
38
+ const fieldMappingsFromBlockId = (
39
+ blockId: string,
40
+ ): readonly (readonly [string, string, boolean])[] => {
41
+ if (!/^aui:\d+:/.test(blockId)) return [];
42
+ const mappingStart = blockId.indexOf(":", 4);
43
+ if (mappingStart === -1) return [];
44
+ try {
45
+ const parsed: unknown = JSON.parse(blockId.slice(mappingStart + 1));
46
+ if (!Array.isArray(parsed)) return [];
47
+ return parsed.flatMap((entry): (readonly [string, string, boolean])[] =>
48
+ Array.isArray(entry) &&
49
+ typeof entry[0] === "string" &&
50
+ typeof entry[1] === "string"
51
+ ? [[entry[0], entry[1], entry[2] === "Checkbox"]]
52
+ : [],
53
+ );
54
+ } catch {
55
+ return [];
56
+ }
57
+ };
58
+
59
+ const checkboxValue = (
60
+ value: Record<string, unknown>,
61
+ name: string,
62
+ ): boolean | undefined =>
63
+ Array.isArray(value["selected_options"])
64
+ ? value["selected_options"].some((option) => optionValue(option) === name)
65
+ : undefined;
66
+
67
+ const stateFieldValue = (
68
+ value: unknown,
69
+ name: string,
70
+ isCheckbox: boolean,
71
+ ): unknown => {
72
+ if (!isRecord(value)) return undefined;
73
+ if (isCheckbox && value["type"] === "checkboxes") {
74
+ return checkboxValue(value, name);
75
+ }
76
+ if (value["type"] === "plain_text_input") {
77
+ return typeof value["value"] === "string" ? value["value"] : undefined;
78
+ }
79
+ return selectedValue(value);
80
+ };
81
+
82
+ const fieldValuesFromState = (
83
+ stateValues: unknown,
84
+ ): Record<string, unknown> => {
85
+ const fields: Record<string, unknown> = Object.create(null);
86
+ if (!isRecord(stateValues)) return fields;
87
+ for (const [blockId, rawBlockValues] of Object.entries(stateValues)) {
88
+ if (!isRecord(rawBlockValues)) continue;
89
+ for (const [actionId, name, isCheckbox] of fieldMappingsFromBlockId(
90
+ blockId,
91
+ )) {
92
+ if (!Object.hasOwn(rawBlockValues, actionId)) continue;
93
+ const value = stateFieldValue(rawBlockValues[actionId], name, isCheckbox);
94
+ if (value === undefined) continue;
95
+ Object.defineProperty(fields, name, {
96
+ value,
97
+ enumerable: true,
98
+ configurable: true,
99
+ writable: true,
100
+ });
101
+ }
102
+ }
103
+ return fields;
104
+ };
105
+
11
106
  /**
12
107
  * Decodes one structural entry from a Slack `block_actions` payload.
13
108
  * `$input` is reserved for the runtime selection: a `$input` key inside the
14
109
  * button's JSON `value` payload is dropped rather than spread into the result.
110
+ * `stateValues` is the companion `state.values` object used to resolve named
111
+ * control values referenced by `{ "$field": name }` in the action payload.
15
112
  */
16
- export function decodeBlockAction(action: unknown): Action | undefined {
113
+ export function decodeBlockAction(
114
+ action: unknown,
115
+ stateValues?: unknown,
116
+ ): Action | undefined {
17
117
  try {
18
118
  if (!isRecord(action) || typeof action["action_id"] !== "string") {
19
119
  return undefined;
@@ -25,7 +125,9 @@ export function decodeBlockAction(action: unknown): Action | undefined {
25
125
  const rawValue = action["value"];
26
126
  let payload: Record<string, unknown> = {};
27
127
  let plainValue: string | undefined;
28
- if (typeof rawValue === "string") {
128
+ if (action["type"] === "plain_text_input" && typeof rawValue === "string") {
129
+ plainValue = rawValue;
130
+ } else if (typeof rawValue === "string") {
29
131
  try {
30
132
  const parsed: unknown = JSON.parse(rawValue);
31
133
  if (isRecord(parsed)) {
@@ -38,28 +140,19 @@ export function decodeBlockAction(action: unknown): Action | undefined {
38
140
  }
39
141
  }
40
142
 
41
- const selectedOption = optionValue(action["selected_option"]);
42
- const selectedDate =
43
- typeof action["selected_date"] === "string"
44
- ? action["selected_date"]
45
- : undefined;
46
- const selectedOptions = Array.isArray(action["selected_options"])
47
- ? action["selected_options"]
48
- .map(optionValue)
49
- .filter((value): value is string => value !== undefined)
50
- : undefined;
51
- const input =
52
- selectedOption ??
53
- selectedDate ??
54
- (selectedOptions !== undefined ? selectedOptions : plainValue);
143
+ const input = selectedValue(action) ?? plainValue;
55
144
 
56
- return {
145
+ const decoded = {
57
146
  ...Object.fromEntries(
58
147
  Object.entries(payload).filter(([key]) => key !== "$input"),
59
148
  ),
60
149
  type: actionId,
61
150
  ...(input !== undefined ? { $input: input } : {}),
62
151
  };
152
+ return resolveFieldReferences(
153
+ decoded,
154
+ fieldValuesFromState(stateValues),
155
+ ) as Action;
63
156
  } catch {
64
157
  return undefined;
65
158
  }
@@ -1,6 +1,10 @@
1
1
  import { describe, expect, it } from "vitest";
2
2
  import type { UIElement } from "../ir";
3
- import { INBOUND_BLOCK_CAP, SELECT_OPTION_CAP } from "./constants";
3
+ import {
4
+ CHECKBOX_OPTION_CAP,
5
+ INBOUND_BLOCK_CAP,
6
+ SELECT_OPTION_CAP,
7
+ } from "./constants";
4
8
  import { fromSlackBlocks } from "./fromSlackBlocks";
5
9
  import { toSlackBlocks } from "./toSlackBlocks";
6
10
  import type { ToSlackBlocksOptions } from "./types";
@@ -319,6 +323,54 @@ describe("fromSlackBlocks", () => {
319
323
  ]);
320
324
  });
321
325
 
326
+ it("reads defaultValue from a matching static_select initial_option", () => {
327
+ const { nodes } = fromSlackBlocks([
328
+ {
329
+ type: "actions",
330
+ elements: [
331
+ {
332
+ type: "static_select",
333
+ action_id: "pick",
334
+ options: [
335
+ { text: { type: "plain_text", text: "Alpha" }, value: "a" },
336
+ { text: { type: "plain_text", text: "Beta" }, value: "b" },
337
+ ],
338
+ initial_option: {
339
+ text: { type: "plain_text", text: "Beta" },
340
+ value: "b",
341
+ },
342
+ },
343
+ ],
344
+ },
345
+ ]);
346
+ expect(nodes[0]).toMatchObject({
347
+ $type: "Select",
348
+ defaultValue: "b",
349
+ });
350
+ });
351
+
352
+ it("omits defaultValue when static_select initial_option is not an option", () => {
353
+ const { nodes } = fromSlackBlocks([
354
+ {
355
+ type: "actions",
356
+ elements: [
357
+ {
358
+ type: "static_select",
359
+ action_id: "pick",
360
+ options: [
361
+ { text: { type: "plain_text", text: "Alpha" }, value: "a" },
362
+ ],
363
+ initial_option: {
364
+ text: { type: "plain_text", text: "Missing" },
365
+ value: "missing",
366
+ },
367
+ },
368
+ ],
369
+ },
370
+ ]);
371
+ expect(nodes[0]).not.toHaveProperty("defaultValue");
372
+ });
373
+
322
374
  it("inverts a datepicker, reading value from initial_date and omitting it when absent", () => {
323
375
  const { nodes: withDate } = fromSlackBlocks([
324
376
  {
@@ -394,6 +446,68 @@ describe("fromSlackBlocks", () => {
394
446
  expect(unchecked[0]).not.toHaveProperty("defaultChecked");
395
447
  });
396
448
 
449
+ it("inverts multi-option checkboxes into CheckboxGroup with matching initial options", () => {
450
+ const { nodes } = fromSlackBlocks([
451
+ {
452
+ type: "actions",
453
+ elements: [
454
+ {
455
+ type: "checkboxes",
456
+ action_id: "sizes",
457
+ value: '{"source":"picker"}',
458
+ options: [
459
+ { text: { type: "plain_text", text: "Small" }, value: "s" },
460
+ { text: { type: "plain_text", text: "Large" }, value: "l" },
461
+ ],
462
+ initial_options: [
463
+ { text: { type: "plain_text", text: "Large" }, value: "l" },
464
+ {
465
+ text: { type: "plain_text", text: "Missing" },
466
+ value: "missing",
467
+ },
468
+ ],
469
+ },
470
+ ],
471
+ },
472
+ ]);
473
+ expect(nodes).toEqual([
474
+ {
475
+ $type: "CheckboxGroup",
476
+ options: [
477
+ { label: "Small", value: "s" },
478
+ { label: "Large", value: "l" },
479
+ ],
480
+ defaultValue: ["l"],
481
+ $action: { type: "sizes", source: "picker" },
482
+ },
483
+ ]);
484
+ });
485
+
486
+ it("drops malformed CheckboxGroup options", () => {
487
+ const { nodes } = fromSlackBlocks([
488
+ {
489
+ type: "actions",
490
+ elements: [
491
+ {
492
+ type: "checkboxes",
493
+ action_id: "sizes",
494
+ options: [
495
+ { text: { type: "plain_text", text: "Small" }, value: "s" },
496
+ { text: { type: "plain_text", text: "Missing value" } },
497
+ ],
498
+ },
499
+ ],
500
+ },
501
+ ]);
502
+ expect(nodes).toEqual([
503
+ {
504
+ $type: "CheckboxGroup",
505
+ options: [{ label: "Small", value: "s" }],
506
+ $action: { type: "sizes" },
507
+ },
508
+ ]);
509
+ });
510
+
397
511
  it("inverts radio_buttons, reading defaultValue from initial_option and omitting it when absent", () => {
398
512
  const { nodes: withValue } = fromSlackBlocks([
399
513
  {
@@ -504,6 +618,33 @@ describe("fromSlackBlocks", () => {
504
618
  expect(nodes[0]).not.toHaveProperty("multiline");
505
619
  });
506
620
 
621
+ it("reads defaultValue from a string initial_value, including empty text", () => {
622
+ const withText = fromSlackBlocks([
623
+ {
624
+ type: "input",
625
+ label: { type: "plain_text", text: "Notes" },
626
+ element: {
627
+ type: "plain_text_input",
628
+ action_id: "notes",
629
+ initial_value: "Draft reply",
630
+ },
631
+ },
632
+ ]);
633
+ const withEmptyText = fromSlackBlocks([
634
+ {
635
+ type: "input",
636
+ label: { type: "plain_text", text: "Notes" },
637
+ element: {
638
+ type: "plain_text_input",
639
+ action_id: "notes",
640
+ initial_value: "",
641
+ },
642
+ },
643
+ ]);
644
+ expect(withText.nodes[0]).toMatchObject({ defaultValue: "Draft reply" });
645
+ expect(withEmptyText.nodes[0]).toMatchObject({ defaultValue: "" });
646
+ });
647
+
507
648
  it("drops an input block wrapping an unrecognized element", () => {
508
649
  const { nodes, warnings } = fromSlackBlocks([
509
650
  {
@@ -765,6 +906,27 @@ describe("fromSlackBlocks", () => {
765
906
  });
766
907
 
767
908
  describe("round trip", () => {
909
+ it("keeps Input and Select defaultValue props", () => {
910
+ const tree = [
911
+ {
912
+ $type: "Input",
913
+ label: "Notes",
914
+ defaultValue: "Draft reply",
915
+ $action: { type: "notes" },
916
+ },
917
+ {
918
+ $type: "Select",
919
+ options: [
920
+ { label: "Alpha", value: "a" },
921
+ { label: "Beta", value: "b" },
922
+ ],
923
+ defaultValue: "b",
924
+ $action: { type: "pick" },
925
+ },
926
+ ];
927
+ expect(fromSlackBlocks(toSlackBlocks(tree).blocks).nodes).toEqual(tree);
928
+ });
929
+
768
930
  const fixtures: readonly {
769
931
  readonly name: string;
770
932
  readonly tree: unknown;
@@ -898,6 +1060,29 @@ describe("fromSlackBlocks", () => {
898
1060
  },
899
1061
  ],
900
1062
  },
1063
+ {
1064
+ name: "CheckboxGroup with defaultValue",
1065
+ tree: {
1066
+ $type: "CheckboxGroup",
1067
+ options: [
1068
+ { label: "Small", value: "s" },
1069
+ { label: "Large", value: "l" },
1070
+ ],
1071
+ defaultValue: ["s", "l"],
1072
+ $action: { type: "sizes" },
1073
+ },
1074
+ expected: [
1075
+ {
1076
+ $type: "CheckboxGroup",
1077
+ options: [
1078
+ { label: "Small", value: "s" },
1079
+ { label: "Large", value: "l" },
1080
+ ],
1081
+ defaultValue: ["s", "l"],
1082
+ $action: { type: "sizes" },
1083
+ },
1084
+ ],
1085
+ },
901
1086
  {
902
1087
  name: "Input",
903
1088
  tree: {
@@ -1063,7 +1248,7 @@ describe("fromSlackBlocks", () => {
1063
1248
  });
1064
1249
 
1065
1250
  describe("fromSlackBlocks checkbox and radio caps", () => {
1066
- it("marks defaultChecked only when the first option is initially selected", () => {
1251
+ it("marks defaultChecked only when its single option is initially selected", () => {
1067
1252
  const base = {
1068
1253
  type: "actions",
1069
1254
  elements: [
@@ -1072,7 +1257,6 @@ describe("fromSlackBlocks checkbox and radio caps", () => {
1072
1257
  action_id: "toggle",
1073
1258
  options: [
1074
1259
  { text: { type: "plain_text", text: "First" }, value: "first" },
1075
- { text: { type: "plain_text", text: "Second" }, value: "second" },
1076
1260
  ],
1077
1261
  initial_options: [
1078
1262
  { text: { type: "plain_text", text: "Second" }, value: "second" },
@@ -1119,6 +1303,41 @@ describe("fromSlackBlocks checkbox and radio caps", () => {
1119
1303
  ),
1120
1304
  ).toBe(true);
1121
1305
  });
1306
+
1307
+ it("clamps inbound CheckboxGroup options to the checkbox cap", () => {
1308
+ const { nodes, warnings } = fromSlackBlocks([
1309
+ {
1310
+ type: "actions",
1311
+ elements: [
1312
+ {
1313
+ type: "checkboxes",
1314
+ action_id: "sizes",
1315
+ options: Array.from(
1316
+ { length: CHECKBOX_OPTION_CAP + 3 },
1317
+ (_, i) => ({
1318
+ text: { type: "plain_text", text: `O${i}` },
1319
+ value: `v${i}`,
1320
+ }),
1321
+ ),
1322
+ },
1323
+ ],
1324
+ },
1325
+ ]);
1326
+ const checkboxGroup = nodes[0];
1327
+ if (
1328
+ checkboxGroup?.$type !== "CheckboxGroup" ||
1329
+ !Array.isArray(checkboxGroup.options)
1330
+ ) {
1331
+ throw new Error("Expected a CheckboxGroup with options.");
1332
+ }
1333
+ expect(checkboxGroup.options).toHaveLength(CHECKBOX_OPTION_CAP);
1334
+ expect(
1335
+ warnings.some(
1336
+ (warning) =>
1337
+ warning.component === "CheckboxGroup" && warning.code === "clamped",
1338
+ ),
1339
+ ).toBe(true);
1340
+ });
1122
1341
  });
1123
1342
 
1124
1343
  it("bounds static-select options when an array replaces its slice", () => {
@@ -4,6 +4,7 @@ import {
4
4
  ACTIONS_ELEMENT_CAP,
5
5
  CARD_ACTIONS_CAP,
6
6
  CAROUSEL_CARD_CAP,
7
+ CHECKBOX_OPTION_CAP,
7
8
  CONTEXT_ELEMENT_CAP,
8
9
  DATA_TABLE_COLUMN_CAP,
9
10
  DATA_TABLE_ROW_CAP,
@@ -212,10 +213,20 @@ const selectFrom = (
212
213
  "Select",
213
214
  `options were clamped to ${SELECT_OPTION_CAP} entries.`,
214
215
  );
216
+ const convertedOptions = options.map(optionFrom).filter(isDefined);
217
+ const initialOption = element["initial_option"];
218
+ const defaultValue =
219
+ isRecord(initialOption) && typeof initialOption["value"] === "string"
220
+ ? initialOption["value"]
221
+ : undefined;
215
222
  const placeholder = element["placeholder"];
216
223
  return {
217
224
  $type: "Select",
218
- options: options.map(optionFrom).filter(isDefined),
225
+ options: convertedOptions,
226
+ ...(defaultValue !== undefined &&
227
+ convertedOptions.some((option) => option.value === defaultValue)
228
+ ? { defaultValue }
229
+ : {}),
219
230
  ...(isRecord(placeholder) ? { placeholder: textOf(placeholder) } : {}),
220
231
  $action: decodeAction(element["action_id"], element["value"]),
221
232
  };
@@ -250,6 +261,40 @@ const checkboxFrom = (element: Record<string, unknown>): UIElement => {
250
261
  };
251
262
  };
252
263
 
264
+ const checkboxGroupFrom = (
265
+ element: Record<string, unknown>,
266
+ warnings: SlackConversionWarning[],
267
+ ): UIElement => {
268
+ const options = boundedArray(
269
+ element["options"],
270
+ CHECKBOX_OPTION_CAP,
271
+ warnings,
272
+ "CheckboxGroup",
273
+ `options were clamped to ${CHECKBOX_OPTION_CAP} entries.`,
274
+ )
275
+ .map(optionFrom)
276
+ .filter(isDefined);
277
+ const defaultValue = clampArray(
278
+ element["initial_options"],
279
+ CHECKBOX_OPTION_CAP,
280
+ ).items.flatMap((initialOption) => {
281
+ const value =
282
+ isRecord(initialOption) && typeof initialOption["value"] === "string"
283
+ ? initialOption["value"]
284
+ : undefined;
285
+ return value !== undefined &&
286
+ options.some((option) => option.value === value)
287
+ ? [value]
288
+ : [];
289
+ });
290
+ return {
291
+ $type: "CheckboxGroup",
292
+ options,
293
+ ...(defaultValue.length > 0 ? { defaultValue } : {}),
294
+ $action: decodeAction(element["action_id"], element["value"]),
295
+ };
296
+ };
297
+
253
298
  const radioGroupFrom = (
254
299
  element: Record<string, unknown>,
255
300
  warnings: SlackConversionWarning[],
@@ -287,7 +332,10 @@ const actionElementFrom = (
287
332
  case "datepicker":
288
333
  return datePickerFrom(element);
289
334
  case "checkboxes":
290
- return checkboxFrom(element);
335
+ return Array.isArray(element["options"]) &&
336
+ element["options"].length > 1
337
+ ? checkboxGroupFrom(element, warnings)
338
+ : checkboxFrom(element);
291
339
  case "radio_buttons":
292
340
  return radioGroupFrom(element, warnings);
293
341
  }
@@ -340,6 +388,9 @@ const inputFrom = (
340
388
  label: textOf(block["label"]),
341
389
  ...(isRecord(placeholder) ? { placeholder: textOf(placeholder) } : {}),
342
390
  ...(element["multiline"] === true ? { multiline: true } : {}),
391
+ ...(typeof element["initial_value"] === "string"
392
+ ? { defaultValue: element["initial_value"] }
393
+ : {}),
343
394
  $action: decodeAction(element["action_id"], undefined),
344
395
  },
345
396
  ];