@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
@@ -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,11 @@
1
1
  import type { Action } from "../ir";
2
+ import { resolveFieldReferences } from "../fieldReferences";
3
+ import {
4
+ formatTemporalUnixSeconds,
5
+ mergeTemporalMinutes,
6
+ splitTemporalMinutes,
7
+ } from "../temporal";
8
+ import type { FieldMapping } from "./types";
2
9
 
3
10
  const isRecord = (value: unknown): value is Record<string, unknown> =>
4
11
  typeof value === "object" && value !== null && !Array.isArray(value);
@@ -8,12 +15,158 @@ const optionValue = (value: unknown): string | undefined =>
8
15
  ? value["value"]
9
16
  : undefined;
10
17
 
18
+ const selectedValue = (
19
+ action: Record<string, unknown>,
20
+ field?: FieldMapping,
21
+ ): unknown => {
22
+ if (action["type"] === "datetimepicker" || "selected_date_time" in action) {
23
+ const seconds = action["selected_date_time"];
24
+ return seconds === null
25
+ ? ""
26
+ : typeof seconds === "number"
27
+ ? formatTemporalUnixSeconds(seconds, field?.offset)
28
+ : undefined;
29
+ }
30
+ if (action["type"] === "timepicker" || "selected_time" in action) {
31
+ const time = action["selected_time"];
32
+ if (time === null) return "";
33
+ const parts =
34
+ typeof time === "string" ? splitTemporalMinutes(time) : undefined;
35
+ return parts?.date === undefined ? parts?.time : undefined;
36
+ }
37
+ if (field?.part === "date" && action["selected_date"] === null) return "";
38
+ const selectedOption = optionValue(action["selected_option"]);
39
+ const selectedDate =
40
+ typeof action["selected_date"] === "string"
41
+ ? action["selected_date"]
42
+ : undefined;
43
+ const selectedOptions = Array.isArray(action["selected_options"])
44
+ ? action["selected_options"]
45
+ .map(optionValue)
46
+ .filter((value): value is string => value !== undefined)
47
+ : undefined;
48
+ const numberInput =
49
+ action["type"] === "number_input" && typeof action["value"] === "string"
50
+ ? Number(action["value"])
51
+ : undefined;
52
+ return (
53
+ selectedOption ??
54
+ selectedDate ??
55
+ (selectedOptions !== undefined
56
+ ? selectedOptions
57
+ : Number.isFinite(numberInput)
58
+ ? numberInput
59
+ : undefined)
60
+ );
61
+ };
62
+
63
+ const fieldMappingsFromBlockId = (blockId: string): readonly FieldMapping[] => {
64
+ if (!/^aui:\d+:/.test(blockId)) return [];
65
+ const mappingStart = blockId.indexOf(":", 4);
66
+ if (mappingStart === -1) return [];
67
+ try {
68
+ const parsed: unknown = JSON.parse(blockId.slice(mappingStart + 1));
69
+ if (!Array.isArray(parsed)) return [];
70
+ return parsed.flatMap((entry): FieldMapping[] => {
71
+ if (
72
+ !Array.isArray(entry) ||
73
+ typeof entry[0] !== "string" ||
74
+ typeof entry[1] !== "string"
75
+ )
76
+ return [];
77
+ return [
78
+ {
79
+ actionId: entry[0],
80
+ name: entry[1],
81
+ component: typeof entry[2] === "string" ? entry[2] : "",
82
+ ...(entry[2] === "DatePicker" &&
83
+ (entry[3] === "time" || entry[3] === "datetime")
84
+ ? {
85
+ inputType: entry[3],
86
+ ...(typeof entry[4] === "string" ? { offset: entry[4] } : {}),
87
+ ...((entry[5] === "date" || entry[5] === "time") &&
88
+ Number.isSafeInteger(entry[6]) &&
89
+ entry[6] >= 0
90
+ ? { part: entry[5], pairId: entry[6] }
91
+ : {}),
92
+ }
93
+ : {}),
94
+ },
95
+ ];
96
+ });
97
+ } catch {
98
+ return [];
99
+ }
100
+ };
101
+
102
+ const checkboxValue = (
103
+ value: Record<string, unknown>,
104
+ name: string,
105
+ ): boolean | undefined =>
106
+ Array.isArray(value["selected_options"])
107
+ ? value["selected_options"].some((option) => optionValue(option) === name)
108
+ : undefined;
109
+
110
+ const stateFieldValue = (value: unknown, field: FieldMapping): unknown => {
111
+ if (!isRecord(value)) return undefined;
112
+ if (field.component === "Checkbox" && value["type"] === "checkboxes") {
113
+ return checkboxValue(value, field.name);
114
+ }
115
+ if (value["type"] === "plain_text_input") {
116
+ return typeof value["value"] === "string" ? value["value"] : undefined;
117
+ }
118
+ return selectedValue(value, field);
119
+ };
120
+
121
+ const fieldValuesFromState = (
122
+ stateValues: unknown,
123
+ action: Record<string, unknown>,
124
+ actionField: FieldMapping | undefined,
125
+ ) => {
126
+ const fields: Record<string, unknown> = Object.create(null);
127
+ const pairs = new Map<number, { name: string; date: string; time: string }>();
128
+ const collect = (raw: unknown, field: FieldMapping) => {
129
+ const value = stateFieldValue(raw, field);
130
+ if (field.part !== undefined && field.pairId !== undefined) {
131
+ const pair = pairs.get(field.pairId) ?? {
132
+ name: field.name,
133
+ date: "",
134
+ time: "",
135
+ };
136
+ pair[field.part] = typeof value === "string" ? value : "";
137
+ pairs.set(field.pairId, pair);
138
+ } else if (field.name && value !== undefined) {
139
+ fields[field.name] = value;
140
+ }
141
+ };
142
+ if (isRecord(stateValues)) {
143
+ for (const [blockId, rawBlockValues] of Object.entries(stateValues)) {
144
+ if (!isRecord(rawBlockValues)) continue;
145
+ for (const field of fieldMappingsFromBlockId(blockId)) {
146
+ if (Object.hasOwn(rawBlockValues, field.actionId))
147
+ collect(rawBlockValues[field.actionId], field);
148
+ }
149
+ }
150
+ }
151
+ if (actionField?.inputType !== undefined) collect(action, actionField);
152
+ for (const pair of pairs.values()) {
153
+ if (pair.name)
154
+ fields[pair.name] = mergeTemporalMinutes(pair.date, pair.time);
155
+ }
156
+ return { fields, pairs };
157
+ };
158
+
11
159
  /**
12
160
  * Decodes one structural entry from a Slack `block_actions` payload.
13
161
  * `$input` is reserved for the runtime selection: a `$input` key inside the
14
162
  * button's JSON `value` payload is dropped rather than spread into the result.
163
+ * `stateValues` is the companion `state.values` object used to resolve named
164
+ * control values referenced by `{ "$field": name }` in the action payload.
15
165
  */
16
- export function decodeBlockAction(action: unknown): Action | undefined {
166
+ export function decodeBlockAction(
167
+ action: unknown,
168
+ stateValues?: unknown,
169
+ ): Action | undefined {
17
170
  try {
18
171
  if (!isRecord(action) || typeof action["action_id"] !== "string") {
19
172
  return undefined;
@@ -25,7 +178,9 @@ export function decodeBlockAction(action: unknown): Action | undefined {
25
178
  const rawValue = action["value"];
26
179
  let payload: Record<string, unknown> = {};
27
180
  let plainValue: string | undefined;
28
- if (typeof rawValue === "string") {
181
+ if (action["type"] === "plain_text_input" && typeof rawValue === "string") {
182
+ plainValue = rawValue;
183
+ } else if (typeof rawValue === "string") {
29
184
  try {
30
185
  const parsed: unknown = JSON.parse(rawValue);
31
186
  if (isRecord(parsed)) {
@@ -38,28 +193,39 @@ export function decodeBlockAction(action: unknown): Action | undefined {
38
193
  }
39
194
  }
40
195
 
41
- const selectedOption = optionValue(action["selected_option"]);
42
- const selectedDate =
43
- typeof action["selected_date"] === "string"
44
- ? action["selected_date"]
196
+ const actionField =
197
+ typeof action["block_id"] === "string"
198
+ ? fieldMappingsFromBlockId(action["block_id"]).find(
199
+ (field) => field.actionId === actionId,
200
+ )
45
201
  : 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);
202
+ const { fields, pairs } = fieldValuesFromState(
203
+ stateValues,
204
+ action,
205
+ actionField,
206
+ );
207
+ let input = selectedValue(action, actionField) ?? plainValue;
208
+ if (actionField?.pairId !== undefined) {
209
+ const pair = pairs.get(actionField.pairId);
210
+ input =
211
+ pair === undefined ? "" : mergeTemporalMinutes(pair.date, pair.time);
212
+ if (!input) return undefined;
213
+ }
214
+ if (
215
+ (action["type"] === "timepicker" ||
216
+ action["type"] === "datetimepicker") &&
217
+ input === undefined
218
+ )
219
+ return undefined;
55
220
 
56
- return {
221
+ const decoded = {
57
222
  ...Object.fromEntries(
58
223
  Object.entries(payload).filter(([key]) => key !== "$input"),
59
224
  ),
60
225
  type: actionId,
61
226
  ...(input !== undefined ? { $input: input } : {}),
62
227
  };
228
+ return resolveFieldReferences(decoded, fields) as Action;
63
229
  } catch {
64
230
  return undefined;
65
231
  }
@@ -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", () => {