@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
@@ -1,5 +1,10 @@
1
+ import { decodeBlockAction } from "./decodeBlockAction";
1
2
  import { describe, expect, it } from "vitest";
2
- import { CHILDREN_CAP, NODE_BUDGET } from "../convert/boundSpec";
3
+ import {
4
+ CHILDREN_CAP,
5
+ MAX_TRAVERSAL_DEPTH,
6
+ NODE_BUDGET,
7
+ } from "../convert/boundSpec";
3
8
  import { toSlackBlocks } from "./toSlackBlocks";
4
9
  import {
5
10
  ACTION_ID_CAP,
@@ -10,6 +15,7 @@ import {
10
15
  CARD_SUBTEXT_CAP,
11
16
  CARD_TITLE_CAP,
12
17
  CAROUSEL_CARD_CAP,
18
+ CHECKBOX_OPTION_CAP,
13
19
  CONTEXT_ELEMENT_CAP,
14
20
  CONTEXT_TEXT_CAP,
15
21
  DATA_TABLE_CHAR_BUDGET,
@@ -172,6 +178,19 @@ describe("toSlackBlocks", () => {
172
178
  expect(warnings).toEqual([]);
173
179
  });
174
180
 
181
+ it("appends a directional fact delta to the value text", () => {
182
+ const { blocks } = toSlackBlocks({
183
+ $type: "Fact",
184
+ label: "Revenue",
185
+ value: "$12.4k",
186
+ delta: "8%",
187
+ trend: "down",
188
+ });
189
+ expect((blocks[0] as SlackSectionBlock).fields?.[0]?.text).toBe(
190
+ "*Revenue*\n$12.4k (↓ 8%)",
191
+ );
192
+ });
193
+
175
194
  it("breaks Fact merging on a non-Fact sibling", () => {
176
195
  const root = [
177
196
  { $type: "Fact", label: "A", value: "1" },
@@ -334,6 +353,37 @@ describe("toSlackBlocks", () => {
334
353
  });
335
354
  });
336
355
 
356
+ it("preserves field references until state is available to the decoder", () => {
357
+ const { blocks, warnings } = toSlackBlocks({
358
+ $type: "Button",
359
+ label: "Save",
360
+ $action: {
361
+ type: "save",
362
+ note: { $field: "note" },
363
+ form: {
364
+ id: 7,
365
+ name: { $field: "name", fallback: "Ada" },
366
+ plan: [{ $field: "plan" }],
367
+ },
368
+ },
369
+ });
370
+ expect((blocks[0] as SlackActionsBlock).elements[0]).toMatchObject({
371
+ action_id: "save",
372
+ value: JSON.stringify({
373
+ note: { $field: "note" },
374
+ form: {
375
+ id: 7,
376
+ name: { $field: "name", fallback: "Ada" },
377
+ plan: [{ $field: "plan" }],
378
+ },
379
+ }),
380
+ });
381
+ expect(warnings).toEqual([]);
382
+ expect(
383
+ decodeBlockAction((blocks[0] as SlackActionsBlock).elements[0]),
384
+ ).toEqual({ type: "save", form: { id: 7, name: "Ada", plan: [] } });
385
+ });
386
+
337
387
  it(`omits value instead of truncating and warns when the serialized action payload exceeds ${BUTTON_VALUE_CAP} characters`, () => {
338
388
  const { blocks, warnings } = toSlackBlocks({
339
389
  $type: "Button",
@@ -394,6 +444,51 @@ describe("toSlackBlocks", () => {
394
444
  });
395
445
  });
396
446
 
447
+ it("sets initial_option from a matching defaultValue", () => {
448
+ const { blocks } = toSlackBlocks({
449
+ $type: "Select",
450
+ defaultValue: "b",
451
+ options: [
452
+ { label: "Alpha", value: "a" },
453
+ { label: "Beta", value: "b" },
454
+ ],
455
+ });
456
+ expect(
457
+ (
458
+ (blocks[0] as SlackActionsBlock)
459
+ .elements[0] as SlackStaticSelectElement
460
+ ).initial_option,
461
+ ).toEqual({ text: { type: "plain_text", text: "Beta" }, value: "b" });
462
+ });
463
+
464
+ it("sets initial_option from a matching empty defaultValue", () => {
465
+ const { blocks } = toSlackBlocks({
466
+ $type: "Select",
467
+ defaultValue: "",
468
+ options: [{ label: "No preference", value: "" }],
469
+ });
470
+ expect(
471
+ (
472
+ (blocks[0] as SlackActionsBlock)
473
+ .elements[0] as SlackStaticSelectElement
474
+ ).initial_option,
475
+ ).toEqual({
476
+ text: { type: "plain_text", text: "No preference" },
477
+ value: "",
478
+ });
479
+ });
480
+
481
+ it("omits initial_option when defaultValue has no matching option", () => {
482
+ const { blocks } = toSlackBlocks({
483
+ $type: "Select",
484
+ defaultValue: "missing",
485
+ options: [{ label: "Alpha", value: "a" }],
486
+ });
487
+ expect((blocks[0] as SlackActionsBlock).elements[0]).not.toHaveProperty(
488
+ "initial_option",
489
+ );
490
+ });
491
+
397
492
  it(`clamps options past ${SELECT_OPTION_CAP} entries and warns`, () => {
398
493
  const options = Array.from({ length: SELECT_OPTION_CAP + 5 }, (_, i) => ({
399
494
  label: `L${i}`,
@@ -479,6 +574,153 @@ describe("toSlackBlocks", () => {
479
574
  });
480
575
 
481
576
  describe("Input", () => {
577
+ it("keeps number text and drops the omitted password's field fallback", () => {
578
+ const { blocks, warnings } = toSlackBlocks([
579
+ {
580
+ $type: "Input",
581
+ name: "quantity",
582
+ inputType: "number",
583
+ defaultValue: "2.5",
584
+ $action: { type: "edit_quantity" },
585
+ },
586
+ {
587
+ $type: "Input",
588
+ name: "password",
589
+ label: "Password",
590
+ inputType: "password",
591
+ defaultValue: "secret",
592
+ },
593
+ {
594
+ $type: "Button",
595
+ label: "Submit",
596
+ $action: {
597
+ type: "submit",
598
+ quantity: { $field: "quantity" },
599
+ password: { $field: "password", fallback: "secret" },
600
+ },
601
+ },
602
+ ]);
603
+ expect(blocks[0]).toMatchObject({
604
+ type: "input",
605
+ element: { type: "plain_text_input", initial_value: "2.5" },
606
+ });
607
+ expect(blocks[1]).toEqual({
608
+ type: "context",
609
+ elements: [
610
+ { type: "mrkdwn", text: "Password input omitted on Slack." },
611
+ ],
612
+ });
613
+ expect(blocks.filter((block) => block.type === "input")).toHaveLength(1);
614
+ expect(JSON.stringify(blocks)).not.toContain("secret");
615
+ expect(warnings).toContainEqual({
616
+ code: "dropped",
617
+ component: "Input",
618
+ detail: "Password input was replaced by a Slack omission note.",
619
+ });
620
+ const button = blocks[2];
621
+ const numberInput = blocks[0];
622
+ if (
623
+ button?.type !== "actions" ||
624
+ button.elements[0]?.type !== "button" ||
625
+ numberInput?.type !== "input" ||
626
+ !numberInput.block_id
627
+ ) {
628
+ throw new Error("Expected a named number input and submit button");
629
+ }
630
+ expect(
631
+ decodeBlockAction(button.elements[0], {
632
+ [numberInput.block_id]: {
633
+ edit_quantity: { type: "plain_text_input", value: "3.5" },
634
+ },
635
+ }),
636
+ ).toEqual({
637
+ type: "submit",
638
+ quantity: "3.5",
639
+ });
640
+ });
641
+
642
+ it("drops nested password fallbacks from button values", () => {
643
+ const { blocks } = toSlackBlocks({
644
+ $type: "Col",
645
+ children: [
646
+ {
647
+ $type: "Input",
648
+ name: "password",
649
+ inputType: "password",
650
+ },
651
+ {
652
+ $type: "Button",
653
+ label: "Submit",
654
+ $action: {
655
+ type: "submit",
656
+ fields: [
657
+ { password: { $field: "password", fallback: "secret" } },
658
+ ],
659
+ },
660
+ },
661
+ ],
662
+ });
663
+ expect(JSON.stringify(blocks)).not.toContain("secret");
664
+ });
665
+
666
+ it("drops a button value that nests a password reference past the traversal limit", () => {
667
+ let nested: unknown = { $field: "password", fallback: "secret" };
668
+ for (let depth = 0; depth <= MAX_TRAVERSAL_DEPTH; depth++) {
669
+ nested = { nested };
670
+ }
671
+ const { blocks, warnings } = toSlackBlocks({
672
+ $type: "Col",
673
+ children: [
674
+ { $type: "Input", name: "password", inputType: "password" },
675
+ {
676
+ $type: "Button",
677
+ label: "Submit",
678
+ $action: { type: "submit", nested },
679
+ },
680
+ ],
681
+ });
682
+ expect(JSON.stringify(blocks)).not.toContain("secret");
683
+ expect(warnings).toContainEqual({
684
+ code: "dropped",
685
+ component: "Button",
686
+ detail:
687
+ "value was dropped because the action payload could not be serialized.",
688
+ });
689
+ const button = blocks[1];
690
+ if (button?.type !== "actions" || button.elements[0]?.type !== "button") {
691
+ throw new Error("Expected a submit button");
692
+ }
693
+ expect(button.elements[0].value).toBeUndefined();
694
+ });
695
+
696
+ it("carries a named control in its block_id", () => {
697
+ const { blocks, warnings } = toSlackBlocks({
698
+ $type: "Input",
699
+ name: "email",
700
+ label: "Email",
701
+ });
702
+ expect(blocks[0]).toMatchObject({
703
+ type: "input",
704
+ block_id: `aui:0:${JSON.stringify([["action", "email"]])}`,
705
+ });
706
+ expect(warnings).toEqual([]);
707
+ });
708
+
709
+ it("warns when a name cannot fit in Slack's block_id limit", () => {
710
+ const { blocks, warnings } = toSlackBlocks({
711
+ $type: "Input",
712
+ name: "n".repeat(250),
713
+ label: "Name",
714
+ });
715
+ expect(blocks[0]).not.toHaveProperty("block_id");
716
+ expect(warnings).toContainEqual({
717
+ code: "dropped",
718
+ component: "Input",
719
+ detail:
720
+ "name could not be mapped because its Slack block_id exceeded 255 characters.",
721
+ });
722
+ });
723
+
482
724
  it("wraps a plain_text_input element, passing multiline through", () => {
483
725
  const { blocks } = toSlackBlocks({
484
726
  $type: "Input",
@@ -510,6 +752,39 @@ describe("toSlackBlocks", () => {
510
752
  );
511
753
  });
512
754
 
755
+ it("omits multiline for a number input", () => {
756
+ const { blocks } = toSlackBlocks({
757
+ $type: "Input",
758
+ inputType: "number",
759
+ multiline: true,
760
+ });
761
+ expect((blocks[0] as SlackInputBlock).element).not.toHaveProperty(
762
+ "multiline",
763
+ );
764
+ });
765
+
766
+ it("sets initial_value from a non-empty defaultValue", () => {
767
+ const { blocks } = toSlackBlocks({
768
+ $type: "Input",
769
+ label: "Notes",
770
+ defaultValue: "Draft reply",
771
+ });
772
+ expect((blocks[0] as SlackInputBlock).element.initial_value).toBe(
773
+ "Draft reply",
774
+ );
775
+ });
776
+
777
+ it("omits initial_value when defaultValue is empty", () => {
778
+ const { blocks } = toSlackBlocks({
779
+ $type: "Input",
780
+ label: "Notes",
781
+ defaultValue: "",
782
+ });
783
+ expect((blocks[0] as SlackInputBlock).element).not.toHaveProperty(
784
+ "initial_value",
785
+ );
786
+ });
787
+
513
788
  it(`clamps label to ${INPUT_LABEL_CAP} and placeholder to ${PLACEHOLDER_TEXT_CAP} characters, warning on each`, () => {
514
789
  const label = "l".repeat(INPUT_LABEL_CAP + 10);
515
790
  const placeholder = "p".repeat(PLACEHOLDER_TEXT_CAP + 10);
@@ -584,6 +859,63 @@ describe("toSlackBlocks", () => {
584
859
  });
585
860
  });
586
861
 
862
+ describe("Slider", () => {
863
+ it("renders a number input block with its numeric bounds", () => {
864
+ const { blocks } = toSlackBlocks({
865
+ $type: "Slider",
866
+ label: "Quantity",
867
+ min: 1,
868
+ max: 12,
869
+ step: 0.5,
870
+ defaultValue: 3.5,
871
+ $action: { type: "set_quantity" },
872
+ });
873
+
874
+ expect(blocks).toEqual([
875
+ {
876
+ type: "input",
877
+ label: { type: "plain_text", text: "Quantity" },
878
+ element: {
879
+ type: "number_input",
880
+ action_id: "set_quantity",
881
+ min_value: 1,
882
+ max_value: 12,
883
+ initial_value: 3.5,
884
+ is_decimal_allowed: true,
885
+ },
886
+ },
887
+ ]);
888
+ });
889
+
890
+ it.each([
891
+ ["minimum", { min: 0.5, max: 2, step: 1 }],
892
+ ["maximum", { min: 0, max: 2.5 }],
893
+ ["default value", { min: 0, max: 2, step: 1, defaultValue: 0.5 }],
894
+ ])("allows decimals when the %s is fractional", (_property, values) => {
895
+ const { blocks } = toSlackBlocks({
896
+ $type: "Slider",
897
+ label: "Quantity",
898
+ ...values,
899
+ });
900
+
901
+ expect(blocks[0]).toMatchObject({
902
+ element: { type: "number_input", is_decimal_allowed: true },
903
+ });
904
+ });
905
+
906
+ it("uses the same input block when nested in a Form", () => {
907
+ const { blocks } = toSlackBlocks({
908
+ $type: "Form",
909
+ children: { $type: "Slider", label: "Quantity", min: 1, max: 12 },
910
+ });
911
+
912
+ expect(blocks[0]).toMatchObject({
913
+ type: "input",
914
+ element: { type: "number_input", min_value: 1, max_value: 12 },
915
+ });
916
+ });
917
+ });
918
+
587
919
  describe("Checkbox", () => {
588
920
  it("emits a single-option checkboxes element", () => {
589
921
  const { blocks } = toSlackBlocks({
@@ -594,6 +926,7 @@ describe("toSlackBlocks", () => {
594
926
  });
595
927
  expect(blocks[0]).toEqual({
596
928
  type: "actions",
929
+ block_id: `aui:0:${JSON.stringify([["toggle", "agree", "Checkbox"]])}`,
597
930
  elements: [
598
931
  {
599
932
  type: "checkboxes",
@@ -604,6 +937,27 @@ describe("toSlackBlocks", () => {
604
937
  },
605
938
  ],
606
939
  });
940
+ const block = blocks[0];
941
+ if (block?.type !== "actions" || !block.block_id) {
942
+ throw new Error("Expected a named checkbox block");
943
+ }
944
+ const checkbox = block.elements[0];
945
+ if (checkbox?.type !== "checkboxes") {
946
+ throw new Error("Expected a checkbox");
947
+ }
948
+ for (const [selectedOptions, expected] of [
949
+ [[], []],
950
+ [checkbox.options, ["agree"]],
951
+ ] as const) {
952
+ expect(
953
+ decodeBlockAction({
954
+ type: "checkboxes",
955
+ block_id: block.block_id,
956
+ action_id: checkbox.action_id,
957
+ selected_options: selectedOptions,
958
+ }),
959
+ ).toEqual({ type: "toggle", $input: expected });
960
+ }
607
961
  });
608
962
 
609
963
  it("falls back to the label as the option value when name is absent", () => {
@@ -642,9 +996,42 @@ describe("toSlackBlocks", () => {
642
996
  "initial_options",
643
997
  );
644
998
  });
999
+
1000
+ it("maps a switch like a checkbox", () => {
1001
+ const { blocks } = toSlackBlocks({
1002
+ $type: "Checkbox",
1003
+ label: "Agree",
1004
+ variant: "switch",
1005
+ });
1006
+ expect((blocks[0] as SlackActionsBlock).elements[0]).toMatchObject({
1007
+ type: "checkboxes",
1008
+ options: [{ value: "Agree" }],
1009
+ });
1010
+ });
645
1011
  });
646
1012
 
647
1013
  describe("RadioGroup", () => {
1014
+ it("maps option descriptions to Slack option descriptions", () => {
1015
+ const { blocks } = toSlackBlocks({
1016
+ $type: "RadioGroup",
1017
+ options: [
1018
+ {
1019
+ label: "Free",
1020
+ description: "For personal projects",
1021
+ value: "free",
1022
+ },
1023
+ ],
1024
+ });
1025
+
1026
+ expect((blocks[0] as SlackActionsBlock).elements[0]).toMatchObject({
1027
+ options: [
1028
+ {
1029
+ description: { type: "plain_text", text: "For personal projects" },
1030
+ },
1031
+ ],
1032
+ });
1033
+ });
1034
+
648
1035
  it("names RadioGroup when the same option loss happens there", () => {
649
1036
  const { warnings } = toSlackBlocks({
650
1037
  $type: "RadioGroup",
@@ -718,6 +1105,89 @@ describe("toSlackBlocks", () => {
718
1105
  });
719
1106
  });
720
1107
 
1108
+ describe("CheckboxGroup", () => {
1109
+ const options = [
1110
+ { label: "Small", value: "s" },
1111
+ { label: "Large", value: "l" },
1112
+ ];
1113
+
1114
+ it("emits a multi-option checkboxes element", () => {
1115
+ const { blocks } = toSlackBlocks({
1116
+ $type: "CheckboxGroup",
1117
+ options,
1118
+ $action: { type: "sizes" },
1119
+ });
1120
+ expect(blocks[0]).toEqual({
1121
+ type: "actions",
1122
+ elements: [
1123
+ {
1124
+ type: "checkboxes",
1125
+ action_id: "sizes",
1126
+ options: [
1127
+ { text: { type: "plain_text", text: "Small" }, value: "s" },
1128
+ { text: { type: "plain_text", text: "Large" }, value: "l" },
1129
+ ],
1130
+ },
1131
+ ],
1132
+ });
1133
+ });
1134
+
1135
+ it("sets initial_options from matching defaultValue entries", () => {
1136
+ const { blocks } = toSlackBlocks({
1137
+ $type: "CheckboxGroup",
1138
+ options,
1139
+ defaultValue: ["l", "missing"],
1140
+ $action: { type: "sizes" },
1141
+ });
1142
+ expect(
1143
+ ((blocks[0] as SlackActionsBlock).elements[0] as SlackCheckboxesElement)
1144
+ .initial_options,
1145
+ ).toEqual([{ text: { type: "plain_text", text: "Large" }, value: "l" }]);
1146
+
1147
+ const { blocks: withoutInitialOptions } = toSlackBlocks({
1148
+ $type: "CheckboxGroup",
1149
+ options,
1150
+ defaultValue: ["missing"],
1151
+ $action: { type: "sizes" },
1152
+ });
1153
+ expect(
1154
+ (withoutInitialOptions[0] as SlackActionsBlock).elements[0],
1155
+ ).not.toHaveProperty("initial_options");
1156
+ });
1157
+
1158
+ it("names CheckboxGroup when malformed options are dropped", () => {
1159
+ const { warnings } = toSlackBlocks({
1160
+ $type: "CheckboxGroup",
1161
+ options: [{ label: "ok", value: "a" }, { label: "bad" }],
1162
+ });
1163
+ expect(warnings).toContainEqual({
1164
+ code: "dropped",
1165
+ component: "CheckboxGroup",
1166
+ detail: "1 option was dropped for want of a string label and value.",
1167
+ });
1168
+ });
1169
+
1170
+ it(`clamps options past ${CHECKBOX_OPTION_CAP} entries and warns`, () => {
1171
+ const manyOptions = Array.from(
1172
+ { length: CHECKBOX_OPTION_CAP + 3 },
1173
+ (_, i) => ({ label: `L${i}`, value: `v${i}` }),
1174
+ );
1175
+ const { blocks, warnings } = toSlackBlocks({
1176
+ $type: "CheckboxGroup",
1177
+ options: manyOptions,
1178
+ $action: { type: "sizes" },
1179
+ });
1180
+ const element = (blocks[0] as SlackActionsBlock)
1181
+ .elements[0] as SlackCheckboxesElement;
1182
+ expect(element.options).toHaveLength(CHECKBOX_OPTION_CAP);
1183
+ expect(warnings).toContainEqual({
1184
+ code: "clamped",
1185
+ component: "CheckboxGroup",
1186
+ detail: `options were clamped to ${CHECKBOX_OPTION_CAP} entries.`,
1187
+ });
1188
+ });
1189
+ });
1190
+
721
1191
  describe("interactive grouping", () => {
722
1192
  it("groups consecutive interactive siblings into one actions block, split by a non-interactive sibling", () => {
723
1193
  const root = [
@@ -727,6 +1197,11 @@ describe("toSlackBlocks", () => {
727
1197
  options: [{ label: "X", value: "x" }],
728
1198
  $action: { type: "b" },
729
1199
  },
1200
+ {
1201
+ $type: "CheckboxGroup",
1202
+ options: [{ label: "Y", value: "y" }],
1203
+ $action: { type: "checkbox" },
1204
+ },
730
1205
  { $type: "Text", value: "gap" },
731
1206
  { $type: "Button", label: "C", $action: { type: "c" } },
732
1207
  ];
@@ -734,7 +1209,11 @@ describe("toSlackBlocks", () => {
734
1209
  expect(blocks).toHaveLength(3);
735
1210
  expect(blocks[0]).toMatchObject({
736
1211
  type: "actions",
737
- elements: [{ type: "button" }, { type: "static_select" }],
1212
+ elements: [
1213
+ { type: "button" },
1214
+ { type: "static_select" },
1215
+ { type: "checkboxes" },
1216
+ ],
738
1217
  });
739
1218
  expect(blocks[1]).toMatchObject({ type: "section" });
740
1219
  expect(blocks[2]).toMatchObject({
@@ -1695,6 +2174,29 @@ describe("toSlackBlocks", () => {
1695
2174
  ]);
1696
2175
  });
1697
2176
 
2177
+ it("formats table cells from their columns", () => {
2178
+ const { blocks } = toSlackBlocks({
2179
+ $type: "Table",
2180
+ columns: [
2181
+ { label: "Number", format: { kind: "number", decimals: 1 } },
2182
+ {
2183
+ label: "Revenue",
2184
+ format: { kind: "currency", currency: "USD", decimals: 2 },
2185
+ },
2186
+ { label: "Share", format: { kind: "percent", decimals: 0 } },
2187
+ { label: "Date", format: { kind: "date" } },
2188
+ ],
2189
+ rows: [[1234.5, 12.4, 0.08, "2024-01-02"]],
2190
+ });
2191
+ const table = blocks[0] as SlackDataTableBlock;
2192
+ expect(table.rows[1]?.map((cell) => cell.text)).toEqual([
2193
+ "1,234.5",
2194
+ "$12.40",
2195
+ "8%",
2196
+ "Jan 2, 2024",
2197
+ ]);
2198
+ });
2199
+
1698
2200
  it("requires a caption on every emitted table", () => {
1699
2201
  const { blocks } = toSlackBlocks({
1700
2202
  $type: "Table",
@@ -2059,6 +2561,7 @@ describe("toSlackBlocks", () => {
2059
2561
  expect(blocks).toEqual([
2060
2562
  {
2061
2563
  type: "input",
2564
+ block_id: `aui:0:${JSON.stringify([["action", "name"]])}`,
2062
2565
  label: { type: "plain_text", text: "Name" },
2063
2566
  element: { type: "plain_text_input", action_id: "action" },
2064
2567
  },
@@ -2075,6 +2578,136 @@ describe("toSlackBlocks", () => {
2075
2578
  },
2076
2579
  ]);
2077
2580
  });
2581
+
2582
+ it("maps named controls per element when they share an actions block", () => {
2583
+ const { blocks, warnings } = toSlackBlocks([
2584
+ {
2585
+ $type: "Select",
2586
+ name: "plan",
2587
+ options: [{ label: "Pro", value: "pro" }],
2588
+ $action: { type: "choose_plan" },
2589
+ },
2590
+ {
2591
+ $type: "CheckboxGroup",
2592
+ name: "tags",
2593
+ options: [{ label: "News", value: "news" }],
2594
+ $action: { type: "choose_tags" },
2595
+ },
2596
+ {
2597
+ $type: "Button",
2598
+ label: "Save",
2599
+ $action: {
2600
+ type: "save",
2601
+ plan: { $field: "plan" },
2602
+ tags: { $field: "tags" },
2603
+ },
2604
+ },
2605
+ ]);
2606
+ expect(blocks[0]).toMatchObject({
2607
+ type: "actions",
2608
+ block_id: `aui:0:${JSON.stringify([
2609
+ ["choose_plan", "plan"],
2610
+ ["choose_tags", "tags"],
2611
+ ])}`,
2612
+ });
2613
+ expect(warnings).toEqual([]);
2614
+ const block = blocks[0];
2615
+ if (block?.type !== "actions" || !block.block_id) {
2616
+ throw new Error("Expected a named actions block");
2617
+ }
2618
+ const button = block.elements.find(
2619
+ (element) => element.type === "button",
2620
+ );
2621
+ if (!button) throw new Error("Expected a button");
2622
+ for (const [selectedOptions, tags] of [
2623
+ [[], []],
2624
+ [[{ value: "news" }], ["news"]],
2625
+ ] as const) {
2626
+ expect(
2627
+ decodeBlockAction({
2628
+ type: "checkboxes",
2629
+ block_id: block.block_id,
2630
+ action_id: "choose_tags",
2631
+ selected_options: selectedOptions,
2632
+ }),
2633
+ ).toEqual({ type: "choose_tags", $input: tags });
2634
+ expect(
2635
+ decodeBlockAction(button, {
2636
+ [block.block_id]: {
2637
+ choose_tags: {
2638
+ type: "checkboxes",
2639
+ selected_options: selectedOptions,
2640
+ },
2641
+ },
2642
+ }),
2643
+ ).toEqual({ type: "save", tags });
2644
+ }
2645
+ });
2646
+
2647
+ it("starts a new actions block when repeated action ids need distinct field mappings", () => {
2648
+ const { blocks } = toSlackBlocks([
2649
+ {
2650
+ $type: "Select",
2651
+ name: "first",
2652
+ options: [{ label: "One", value: "one" }],
2653
+ $action: { type: "choose" },
2654
+ },
2655
+ {
2656
+ $type: "Select",
2657
+ name: "second",
2658
+ options: [{ label: "Two", value: "two" }],
2659
+ $action: { type: "choose" },
2660
+ },
2661
+ ]);
2662
+ expect(blocks).toHaveLength(2);
2663
+ expect(blocks[0]).toMatchObject({
2664
+ type: "actions",
2665
+ block_id: `aui:0:${JSON.stringify([["choose", "first"]])}`,
2666
+ });
2667
+ expect(blocks[1]).toMatchObject({
2668
+ type: "actions",
2669
+ block_id: `aui:1:${JSON.stringify([["choose", "second"]])}`,
2670
+ });
2671
+ });
2672
+
2673
+ it("keeps unnamed controls with repeated action ids in their existing block", () => {
2674
+ const { blocks } = toSlackBlocks([
2675
+ {
2676
+ $type: "Button",
2677
+ label: "First",
2678
+ $action: { type: "choose" },
2679
+ },
2680
+ {
2681
+ $type: "Button",
2682
+ label: "Second",
2683
+ $action: { type: "choose" },
2684
+ },
2685
+ ]);
2686
+ expect(blocks).toHaveLength(1);
2687
+ expect(blocks[0]).not.toHaveProperty("block_id");
2688
+ expect((blocks[0] as SlackActionsBlock).elements).toHaveLength(2);
2689
+ });
2690
+
2691
+ it("splits grouped fields when their mapping would exceed Slack's block_id limit", () => {
2692
+ const { blocks, warnings } = toSlackBlocks([
2693
+ {
2694
+ $type: "Select",
2695
+ name: "a".repeat(120),
2696
+ options: [{ label: "A", value: "a" }],
2697
+ $action: { type: "choose_a" },
2698
+ },
2699
+ {
2700
+ $type: "Select",
2701
+ name: "b".repeat(120),
2702
+ options: [{ label: "B", value: "b" }],
2703
+ $action: { type: "choose_b" },
2704
+ },
2705
+ ]);
2706
+ expect(blocks).toHaveLength(2);
2707
+ expect(blocks[0]).toHaveProperty("block_id");
2708
+ expect(blocks[1]).toHaveProperty("block_id");
2709
+ expect(warnings).toEqual([]);
2710
+ });
2078
2711
  });
2079
2712
 
2080
2713
  describe("Root", () => {
@@ -2221,11 +2854,19 @@ describe("toSlackBlocks", () => {
2221
2854
  expect(() =>
2222
2855
  toSlackBlocks({ $type: "Button", label: "Go", $action: circular }),
2223
2856
  ).not.toThrow();
2224
- const { blocks } = toSlackBlocks({
2857
+ const { blocks, warnings } = toSlackBlocks({
2225
2858
  $type: "Button",
2226
2859
  label: "Go",
2227
2860
  $action: circular,
2228
2861
  });
2862
+ expect(warnings).toEqual([
2863
+ {
2864
+ code: "dropped",
2865
+ component: "Button",
2866
+ detail:
2867
+ "value was dropped because the action payload could not be serialized.",
2868
+ },
2869
+ ]);
2229
2870
  expect(blocks[0]).toEqual({
2230
2871
  type: "actions",
2231
2872
  elements: [
@@ -2384,3 +3025,41 @@ describe("toSlackBlocks data_table integrity", () => {
2384
3025
  ).toBe(true);
2385
3026
  });
2386
3027
  });
3028
+
3029
+ it("resolves a Slider field reference from numeric Slack state", () => {
3030
+ const { blocks } = toSlackBlocks([
3031
+ {
3032
+ $type: "Slider",
3033
+ name: "count",
3034
+ label: "Count",
3035
+ min: 0,
3036
+ max: 10,
3037
+ $action: { type: "change_count" },
3038
+ },
3039
+ {
3040
+ $type: "Button",
3041
+ label: "Submit",
3042
+ $action: { type: "submit", count: { $field: "count" } },
3043
+ },
3044
+ ]);
3045
+ const slider = blocks.find((block) => block.type === "input");
3046
+ const actions = blocks.find((block) => block.type === "actions");
3047
+ if (
3048
+ !slider ||
3049
+ slider.type !== "input" ||
3050
+ !slider.block_id ||
3051
+ !actions ||
3052
+ actions.type !== "actions"
3053
+ )
3054
+ throw new Error("Expected slider and action blocks");
3055
+ const button = actions.elements[0];
3056
+ if (!button || button.type !== "button")
3057
+ throw new Error("Expected submit button");
3058
+ expect(
3059
+ decodeBlockAction(button, {
3060
+ [slider.block_id]: {
3061
+ change_count: { type: "number_input", value: "3" },
3062
+ },
3063
+ }),
3064
+ ).toEqual({ type: "submit", count: 3 });
3065
+ });