@assistant-ui/react-generative-ui 0.0.23 → 0.1.0
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.
- package/README.md +2 -0
- package/dist/a2ui.d.ts +1 -4
- package/dist/a2ui.d.ts.map +1 -1
- package/dist/a2ui.js +1 -4
- package/dist/index.d.ts +1 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -10
- package/dist/index.server.d.ts +1 -2
- package/dist/index.server.d.ts.map +1 -1
- package/dist/index.server.js +1 -10
- package/dist/ir.d.ts +1 -111
- package/dist/ir.d.ts.map +1 -1
- package/dist/ir.js +1 -175
- package/dist/slack.d.ts +1 -4
- package/dist/slack.d.ts.map +1 -1
- package/dist/slack.js +1 -4
- package/dist/teams.d.ts +1 -4
- package/dist/teams.d.ts.map +1 -1
- package/dist/teams.js +1 -4
- package/package.json +5 -5
- package/src/a2ui.ts +1 -22
- package/src/index.server.ts +1 -2
- package/src/index.test.ts +75 -0
- package/src/index.ts +1 -2
- package/src/ir.ts +1 -279
- package/src/slack.ts +1 -39
- package/src/teams.ts +1 -35
- package/dist/JSONGenerativeUI.client.d.ts +0 -28
- package/dist/JSONGenerativeUI.client.d.ts.map +0 -1
- package/dist/JSONGenerativeUI.client.js +0 -101
- package/dist/JSONGenerativeUI.client.js.map +0 -1
- package/dist/JSONGenerativeUI.server.d.ts +0 -21
- package/dist/JSONGenerativeUI.server.d.ts.map +0 -1
- package/dist/JSONGenerativeUI.server.js +0 -32
- package/dist/JSONGenerativeUI.server.js.map +0 -1
- package/dist/JSONGenerativeUI.shared.d.ts +0 -63
- package/dist/JSONGenerativeUI.shared.d.ts.map +0 -1
- package/dist/JSONGenerativeUI.shared.js +0 -31
- package/dist/JSONGenerativeUI.shared.js.map +0 -1
- package/dist/RadioGroupScope.d.ts +0 -6
- package/dist/RadioGroupScope.d.ts.map +0 -1
- package/dist/RadioGroupScope.js +0 -25
- package/dist/RadioGroupScope.js.map +0 -1
- package/dist/a2ui/BindingContext.d.ts +0 -11
- package/dist/a2ui/BindingContext.d.ts.map +0 -1
- package/dist/a2ui/BindingContext.js +0 -12
- package/dist/a2ui/BindingContext.js.map +0 -1
- package/dist/a2ui/PresentRenderer.d.ts +0 -11
- package/dist/a2ui/PresentRenderer.d.ts.map +0 -1
- package/dist/a2ui/PresentRenderer.js +0 -39
- package/dist/a2ui/PresentRenderer.js.map +0 -1
- package/dist/a2ui/convert.d.ts +0 -15
- package/dist/a2ui/convert.d.ts.map +0 -1
- package/dist/a2ui/convert.js +0 -728
- package/dist/a2ui/convert.js.map +0 -1
- package/dist/a2ui/dataModel.d.ts +0 -9
- package/dist/a2ui/dataModel.d.ts.map +0 -1
- package/dist/a2ui/dataModel.js +0 -44
- package/dist/a2ui/dataModel.js.map +0 -1
- package/dist/a2ui/reducer.d.ts +0 -8
- package/dist/a2ui/reducer.d.ts.map +0 -1
- package/dist/a2ui/reducer.js +0 -242
- package/dist/a2ui/reducer.js.map +0 -1
- package/dist/a2ui/snapshot.d.ts +0 -3
- package/dist/a2ui/snapshot.d.ts.map +0 -1
- package/dist/a2ui/snapshot.js +0 -33
- package/dist/a2ui/snapshot.js.map +0 -1
- package/dist/a2ui/types.d.ts +0 -88
- package/dist/a2ui/types.d.ts.map +0 -1
- package/dist/a2ui/types.js +0 -6
- package/dist/a2ui/types.js.map +0 -1
- package/dist/a2ui/valueFunctions.d.ts +0 -16
- package/dist/a2ui/valueFunctions.d.ts.map +0 -1
- package/dist/a2ui/valueFunctions.js +0 -332
- package/dist/a2ui/valueFunctions.js.map +0 -1
- package/dist/actionRegistry.d.ts +0 -29
- package/dist/actionRegistry.d.ts.map +0 -1
- package/dist/actionRegistry.js +0 -41
- package/dist/actionRegistry.js.map +0 -1
- package/dist/answeredValues.d.ts +0 -16
- package/dist/answeredValues.d.ts.map +0 -1
- package/dist/answeredValues.js +0 -37
- package/dist/answeredValues.js.map +0 -1
- package/dist/buildPresentParameters.d.ts +0 -21
- package/dist/buildPresentParameters.d.ts.map +0 -1
- package/dist/buildPresentParameters.js +0 -95
- package/dist/buildPresentParameters.js.map +0 -1
- package/dist/constants.d.ts +0 -15
- package/dist/constants.d.ts.map +0 -1
- package/dist/constants.js +0 -19
- package/dist/constants.js.map +0 -1
- package/dist/convert/boundSpec.d.ts +0 -48
- package/dist/convert/boundSpec.d.ts.map +0 -1
- package/dist/convert/boundSpec.js +0 -108
- package/dist/convert/boundSpec.js.map +0 -1
- package/dist/convert/copyBounded.d.ts +0 -25
- package/dist/convert/copyBounded.d.ts.map +0 -1
- package/dist/convert/copyBounded.js +0 -38
- package/dist/convert/copyBounded.js.map +0 -1
- package/dist/convert/isElement.d.ts +0 -3
- package/dist/convert/isElement.d.ts.map +0 -1
- package/dist/convert/isElement.js +0 -6
- package/dist/convert/isElement.js.map +0 -1
- package/dist/convert/takeRun.d.ts +0 -6
- package/dist/convert/takeRun.d.ts.map +0 -1
- package/dist/convert/takeRun.js +0 -20
- package/dist/convert/takeRun.js.map +0 -1
- package/dist/defineGenerativeComponents.d.ts +0 -29
- package/dist/defineGenerativeComponents.d.ts.map +0 -1
- package/dist/defineGenerativeComponents.js +0 -34
- package/dist/defineGenerativeComponents.js.map +0 -1
- package/dist/fieldReferences.d.ts +0 -9
- package/dist/fieldReferences.d.ts.map +0 -1
- package/dist/fieldReferences.js +0 -53
- package/dist/fieldReferences.js.map +0 -1
- package/dist/generativeUIToJSX.d.ts +0 -18
- package/dist/generativeUIToJSX.d.ts.map +0 -1
- package/dist/generativeUIToJSX.js +0 -76
- package/dist/generativeUIToJSX.js.map +0 -1
- package/dist/index.shared.d.ts +0 -15
- package/dist/index.shared.d.ts.map +0 -1
- package/dist/index.shared.js +0 -9
- package/dist/ir.js.map +0 -1
- package/dist/renderGenerativeUI.d.ts +0 -14
- package/dist/renderGenerativeUI.d.ts.map +0 -1
- package/dist/renderGenerativeUI.js +0 -83
- package/dist/renderGenerativeUI.js.map +0 -1
- package/dist/scopeSchema.d.ts +0 -6
- package/dist/scopeSchema.d.ts.map +0 -1
- package/dist/scopeSchema.js +0 -56
- package/dist/scopeSchema.js.map +0 -1
- package/dist/slack/constants.d.ts +0 -93
- package/dist/slack/constants.d.ts.map +0 -1
- package/dist/slack/constants.js +0 -146
- package/dist/slack/constants.js.map +0 -1
- package/dist/slack/decodeBlockAction.d.ts +0 -10
- package/dist/slack/decodeBlockAction.d.ts.map +0 -1
- package/dist/slack/decodeBlockAction.js +0 -129
- package/dist/slack/decodeBlockAction.js.map +0 -1
- package/dist/slack/fromSlackBlocks.d.ts +0 -27
- package/dist/slack/fromSlackBlocks.d.ts.map +0 -1
- package/dist/slack/fromSlackBlocks.js +0 -483
- package/dist/slack/fromSlackBlocks.js.map +0 -1
- package/dist/slack/toSlackBlocks.d.ts +0 -4
- package/dist/slack/toSlackBlocks.d.ts.map +0 -1
- package/dist/slack/toSlackBlocks.js +0 -901
- package/dist/slack/toSlackBlocks.js.map +0 -1
- package/dist/slack/types.d.ts +0 -204
- package/dist/slack/types.d.ts.map +0 -1
- package/dist/slack/types.js +0 -0
- package/dist/teams/constants.d.ts +0 -34
- package/dist/teams/constants.d.ts.map +0 -1
- package/dist/teams/constants.js +0 -68
- package/dist/teams/constants.js.map +0 -1
- package/dist/teams/decodeSubmitData.d.ts +0 -26
- package/dist/teams/decodeSubmitData.d.ts.map +0 -1
- package/dist/teams/decodeSubmitData.js +0 -70
- package/dist/teams/decodeSubmitData.js.map +0 -1
- package/dist/teams/temporalId.d.ts +0 -5
- package/dist/teams/temporalId.d.ts.map +0 -1
- package/dist/teams/temporalId.js +0 -28
- package/dist/teams/temporalId.js.map +0 -1
- package/dist/teams/toAdaptiveCard.d.ts +0 -45
- package/dist/teams/toAdaptiveCard.d.ts.map +0 -1
- package/dist/teams/toAdaptiveCard.js +0 -642
- package/dist/teams/toAdaptiveCard.js.map +0 -1
- package/dist/teams/toTeamsAttachments.d.ts +0 -10
- package/dist/teams/toTeamsAttachments.d.ts.map +0 -1
- package/dist/teams/toTeamsAttachments.js +0 -99
- package/dist/teams/toTeamsAttachments.js.map +0 -1
- package/dist/teams/types.d.ts +0 -224
- package/dist/teams/types.d.ts.map +0 -1
- package/dist/teams/types.js +0 -0
- package/dist/temporal.d.ts +0 -33
- package/dist/temporal.d.ts.map +0 -1
- package/dist/temporal.js +0 -152
- package/dist/temporal.js.map +0 -1
- package/dist/types.d.ts +0 -99
- package/dist/types.d.ts.map +0 -1
- package/dist/types.js +0 -0
- package/dist/vocabulary/alert.d.ts +0 -32
- package/dist/vocabulary/alert.d.ts.map +0 -1
- package/dist/vocabulary/alert.js +0 -160
- package/dist/vocabulary/alert.js.map +0 -1
- package/dist/vocabulary/collectFormValues.d.ts +0 -23
- package/dist/vocabulary/collectFormValues.d.ts.map +0 -1
- package/dist/vocabulary/collectFormValues.js +0 -44
- package/dist/vocabulary/collectFormValues.js.map +0 -1
- package/dist/vocabulary/data.d.ts +0 -66
- package/dist/vocabulary/data.d.ts.map +0 -1
- package/dist/vocabulary/data.js +0 -503
- package/dist/vocabulary/data.js.map +0 -1
- package/dist/vocabulary/dispatch.d.ts +0 -9
- package/dist/vocabulary/dispatch.d.ts.map +0 -1
- package/dist/vocabulary/dispatch.js +0 -50
- package/dist/vocabulary/dispatch.js.map +0 -1
- package/dist/vocabulary/fact.d.ts +0 -19
- package/dist/vocabulary/fact.d.ts.map +0 -1
- package/dist/vocabulary/fact.js +0 -51
- package/dist/vocabulary/fact.js.map +0 -1
- package/dist/vocabulary/form.d.ts +0 -11
- package/dist/vocabulary/form.d.ts.map +0 -1
- package/dist/vocabulary/form.js +0 -23
- package/dist/vocabulary/form.js.map +0 -1
- package/dist/vocabulary/formatValue.d.ts +0 -7
- package/dist/vocabulary/formatValue.d.ts.map +0 -1
- package/dist/vocabulary/formatValue.js +0 -76
- package/dist/vocabulary/formatValue.js.map +0 -1
- package/dist/vocabulary/icon.d.ts +0 -41
- package/dist/vocabulary/icon.d.ts.map +0 -1
- package/dist/vocabulary/icon.js +0 -272
- package/dist/vocabulary/icon.js.map +0 -1
- package/dist/vocabulary/index.d.ts +0 -12
- package/dist/vocabulary/index.d.ts.map +0 -1
- package/dist/vocabulary/index.js +0 -36
- package/dist/vocabulary/index.js.map +0 -1
- package/dist/vocabulary/interactive.d.ts +0 -230
- package/dist/vocabulary/interactive.d.ts.map +0 -1
- package/dist/vocabulary/interactive.js +0 -530
- package/dist/vocabulary/interactive.js.map +0 -1
- package/dist/vocabulary/layout.d.ts +0 -79
- package/dist/vocabulary/layout.d.ts.map +0 -1
- package/dist/vocabulary/layout.js +0 -139
- package/dist/vocabulary/layout.js.map +0 -1
- package/dist/vocabulary/list.d.ts +0 -14
- package/dist/vocabulary/list.d.ts.map +0 -1
- package/dist/vocabulary/list.js +0 -50
- package/dist/vocabulary/list.js.map +0 -1
- package/dist/vocabulary/media.d.ts +0 -25
- package/dist/vocabulary/media.d.ts.map +0 -1
- package/dist/vocabulary/media.js +0 -42
- package/dist/vocabulary/media.js.map +0 -1
- package/dist/vocabulary/text.d.ts +0 -57
- package/dist/vocabulary/text.d.ts.map +0 -1
- package/dist/vocabulary/text.js +0 -49
- package/dist/vocabulary/text.js.map +0 -1
- package/dist/vocabulary/toTextContent.d.ts +0 -2
- package/dist/vocabulary/toTextContent.d.ts.map +0 -1
- package/dist/vocabulary/toTextContent.js +0 -6
- package/dist/vocabulary/toTextContent.js.map +0 -1
- package/src/JSONGenerativeUI.client.tsx +0 -168
- package/src/JSONGenerativeUI.server.tsx +0 -39
- package/src/JSONGenerativeUI.shared.ts +0 -90
- package/src/JSONGenerativeUI.test.tsx +0 -1298
- package/src/RadioGroupScope.server.test.tsx +0 -24
- package/src/RadioGroupScope.tsx +0 -34
- package/src/a2ui/BindingContext.ts +0 -26
- package/src/a2ui/PresentRenderer.tsx +0 -83
- package/src/a2ui/convert.test.ts +0 -2923
- package/src/a2ui/convert.ts +0 -1207
- package/src/a2ui/dataModel.ts +0 -69
- package/src/a2ui/reducer.test.ts +0 -422
- package/src/a2ui/reducer.ts +0 -363
- package/src/a2ui/snapshot.test.ts +0 -52
- package/src/a2ui/snapshot.ts +0 -52
- package/src/a2ui/types.ts +0 -114
- package/src/a2ui/valueFunctions.test.ts +0 -271
- package/src/a2ui/valueFunctions.ts +0 -523
- package/src/actionRegistry.test.ts +0 -90
- package/src/actionRegistry.ts +0 -88
- package/src/answeredValues.test.ts +0 -33
- package/src/answeredValues.tsx +0 -70
- package/src/buildPresentParameters.test.ts +0 -246
- package/src/buildPresentParameters.ts +0 -130
- package/src/constants.ts +0 -17
- package/src/convert/boundSpec.test.ts +0 -192
- package/src/convert/boundSpec.ts +0 -134
- package/src/convert/copyBounded.test.ts +0 -131
- package/src/convert/copyBounded.ts +0 -38
- package/src/convert/isElement.ts +0 -6
- package/src/convert/takeRun.test.ts +0 -64
- package/src/convert/takeRun.ts +0 -19
- package/src/defineGenerativeComponents.ts +0 -38
- package/src/fieldReferences.test.ts +0 -66
- package/src/fieldReferences.ts +0 -83
- package/src/generativeUIToJSX.test.ts +0 -268
- package/src/generativeUIToJSX.ts +0 -152
- package/src/index.server.test.ts +0 -106
- package/src/index.shared.ts +0 -61
- package/src/ir.test.ts +0 -203
- package/src/renderGenerativeUI.test.tsx +0 -373
- package/src/renderGenerativeUI.tsx +0 -165
- package/src/scopeSchema.test.ts +0 -72
- package/src/scopeSchema.ts +0 -71
- package/src/slack/a2uiSlack.test.ts +0 -198
- package/src/slack/constants.ts +0 -194
- package/src/slack/datePicker.test.ts +0 -612
- package/src/slack/decodeBlockAction.test.ts +0 -233
- package/src/slack/decodeBlockAction.ts +0 -232
- package/src/slack/fromSlackBlocks.test.ts +0 -1465
- package/src/slack/fromSlackBlocks.ts +0 -789
- package/src/slack/toSlackBlocks.test.ts +0 -3065
- package/src/slack/toSlackBlocks.ts +0 -1818
- package/src/slack/types.ts +0 -264
- package/src/teams/constants.ts +0 -93
- package/src/teams/decodeSubmitData.test.ts +0 -397
- package/src/teams/decodeSubmitData.ts +0 -110
- package/src/teams/temporal.test.ts +0 -766
- package/src/teams/temporalId.ts +0 -56
- package/src/teams/toAdaptiveCard.test.ts +0 -1897
- package/src/teams/toAdaptiveCard.ts +0 -1057
- package/src/teams/toTeamsAttachments.test.ts +0 -74
- package/src/teams/toTeamsAttachments.ts +0 -133
- package/src/teams/types.ts +0 -266
- package/src/temporal.test.ts +0 -319
- package/src/temporal.ts +0 -259
- package/src/types.ts +0 -119
- package/src/vocabulary/alert.test.tsx +0 -427
- package/src/vocabulary/alert.tsx +0 -217
- package/src/vocabulary/collectFormValues.test.ts +0 -389
- package/src/vocabulary/collectFormValues.ts +0 -116
- package/src/vocabulary/data.test.tsx +0 -858
- package/src/vocabulary/data.tsx +0 -819
- package/src/vocabulary/datepicker.dom.test.tsx +0 -1148
- package/src/vocabulary/dispatch.ts +0 -83
- package/src/vocabulary/fact.test.tsx +0 -94
- package/src/vocabulary/fact.tsx +0 -54
- package/src/vocabulary/form.test.tsx +0 -113
- package/src/vocabulary/form.tsx +0 -39
- package/src/vocabulary/formatValue.test.ts +0 -72
- package/src/vocabulary/formatValue.ts +0 -127
- package/src/vocabulary/icon.test.tsx +0 -64
- package/src/vocabulary/icon.tsx +0 -174
- package/src/vocabulary/index.test.ts +0 -65
- package/src/vocabulary/index.ts +0 -33
- package/src/vocabulary/interactive.dispatch.test.tsx +0 -454
- package/src/vocabulary/interactive.dom.test.tsx +0 -1747
- package/src/vocabulary/interactive.test.tsx +0 -377
- package/src/vocabulary/interactive.tsx +0 -1048
- package/src/vocabulary/layout.test.tsx +0 -323
- package/src/vocabulary/layout.tsx +0 -247
- package/src/vocabulary/list.test.tsx +0 -222
- package/src/vocabulary/list.tsx +0 -50
- package/src/vocabulary/media.test.tsx +0 -85
- package/src/vocabulary/media.tsx +0 -56
- package/src/vocabulary/text.test.tsx +0 -102
- package/src/vocabulary/text.tsx +0 -60
- package/src/vocabulary/toTextContent.ts +0 -2
|
@@ -1,3065 +0,0 @@
|
|
|
1
|
-
import { decodeBlockAction } from "./decodeBlockAction";
|
|
2
|
-
import { describe, expect, it } from "vitest";
|
|
3
|
-
import {
|
|
4
|
-
CHILDREN_CAP,
|
|
5
|
-
MAX_TRAVERSAL_DEPTH,
|
|
6
|
-
NODE_BUDGET,
|
|
7
|
-
} from "../convert/boundSpec";
|
|
8
|
-
import { toSlackBlocks } from "./toSlackBlocks";
|
|
9
|
-
import {
|
|
10
|
-
ACTION_ID_CAP,
|
|
11
|
-
ACTIONS_ELEMENT_CAP,
|
|
12
|
-
ALERT_TEXT_CAP,
|
|
13
|
-
BUTTON_VALUE_CAP,
|
|
14
|
-
CARD_BODY_CAP,
|
|
15
|
-
CARD_SUBTEXT_CAP,
|
|
16
|
-
CARD_TITLE_CAP,
|
|
17
|
-
CAROUSEL_CARD_CAP,
|
|
18
|
-
CHECKBOX_OPTION_CAP,
|
|
19
|
-
CONTEXT_ELEMENT_CAP,
|
|
20
|
-
CONTEXT_TEXT_CAP,
|
|
21
|
-
DATA_TABLE_CHAR_BUDGET,
|
|
22
|
-
DATA_TABLE_COLUMN_CAP,
|
|
23
|
-
DATA_TABLE_ROW_CAP,
|
|
24
|
-
FACT_FIELD_CAP,
|
|
25
|
-
FACT_FIELD_TEXT_CAP,
|
|
26
|
-
HEADER_TEXT_CAP,
|
|
27
|
-
INPUT_LABEL_CAP,
|
|
28
|
-
INTERACTIVE_TEXT_CAP,
|
|
29
|
-
MARKDOWN_TEXT_BUDGET,
|
|
30
|
-
MESSAGE_BLOCK_CAP,
|
|
31
|
-
MODAL_BLOCK_CAP,
|
|
32
|
-
PLACEHOLDER_TEXT_CAP,
|
|
33
|
-
RADIO_OPTION_CAP,
|
|
34
|
-
SECTION_TEXT_CAP,
|
|
35
|
-
SELECT_OPTION_CAP,
|
|
36
|
-
TABLE_CAPTION,
|
|
37
|
-
} from "./constants";
|
|
38
|
-
import type {
|
|
39
|
-
SlackActionsBlock,
|
|
40
|
-
SlackAlertBlock,
|
|
41
|
-
SlackCardBlock,
|
|
42
|
-
SlackCarouselBlock,
|
|
43
|
-
SlackCheckboxesElement,
|
|
44
|
-
SlackContextBlock,
|
|
45
|
-
SlackDataTableBlock,
|
|
46
|
-
SlackInputBlock,
|
|
47
|
-
SlackRadioButtonsElement,
|
|
48
|
-
SlackSectionBlock,
|
|
49
|
-
SlackStaticSelectElement,
|
|
50
|
-
} from "./types";
|
|
51
|
-
|
|
52
|
-
const hostileSpecies = <T>(
|
|
53
|
-
source: T[],
|
|
54
|
-
injected: unknown[],
|
|
55
|
-
onDispatch: () => void,
|
|
56
|
-
): T[] => {
|
|
57
|
-
function HostileCtor() {
|
|
58
|
-
return {
|
|
59
|
-
map: () => {
|
|
60
|
-
onDispatch();
|
|
61
|
-
return injected;
|
|
62
|
-
},
|
|
63
|
-
[Symbol.iterator]: function* () {
|
|
64
|
-
onDispatch();
|
|
65
|
-
yield* injected;
|
|
66
|
-
},
|
|
67
|
-
};
|
|
68
|
-
}
|
|
69
|
-
const constructor = { [Symbol.species]: HostileCtor };
|
|
70
|
-
return new Proxy(source, {
|
|
71
|
-
get: (target, prop, receiver) =>
|
|
72
|
-
prop === "constructor"
|
|
73
|
-
? constructor
|
|
74
|
-
: Reflect.get(target, prop, receiver),
|
|
75
|
-
});
|
|
76
|
-
};
|
|
77
|
-
|
|
78
|
-
describe("toSlackBlocks", () => {
|
|
79
|
-
describe("Header", () => {
|
|
80
|
-
it("renders a header block", () => {
|
|
81
|
-
const { blocks } = toSlackBlocks({ $type: "Header", text: "Title" });
|
|
82
|
-
expect(blocks).toEqual([
|
|
83
|
-
{ type: "header", text: { type: "plain_text", text: "Title" } },
|
|
84
|
-
]);
|
|
85
|
-
});
|
|
86
|
-
|
|
87
|
-
it(`clamps text to ${HEADER_TEXT_CAP} characters and warns`, () => {
|
|
88
|
-
const text = "h".repeat(200);
|
|
89
|
-
const { blocks, warnings } = toSlackBlocks({ $type: "Header", text });
|
|
90
|
-
expect(blocks).toEqual([
|
|
91
|
-
{
|
|
92
|
-
type: "header",
|
|
93
|
-
text: { type: "plain_text", text: "h".repeat(HEADER_TEXT_CAP) },
|
|
94
|
-
},
|
|
95
|
-
]);
|
|
96
|
-
expect(warnings).toEqual([
|
|
97
|
-
{
|
|
98
|
-
code: "clamped",
|
|
99
|
-
component: "Header",
|
|
100
|
-
detail: `text was clamped to ${HEADER_TEXT_CAP} characters.`,
|
|
101
|
-
},
|
|
102
|
-
]);
|
|
103
|
-
});
|
|
104
|
-
});
|
|
105
|
-
|
|
106
|
-
describe("Text", () => {
|
|
107
|
-
it("renders a section with mrkdwn text", () => {
|
|
108
|
-
const { blocks } = toSlackBlocks({ $type: "Text", value: "Hello" });
|
|
109
|
-
expect(blocks).toEqual([
|
|
110
|
-
{ type: "section", text: { type: "mrkdwn", text: "Hello" } },
|
|
111
|
-
]);
|
|
112
|
-
});
|
|
113
|
-
|
|
114
|
-
it(`clamps value to ${SECTION_TEXT_CAP} characters and warns`, () => {
|
|
115
|
-
const value = "t".repeat(4000);
|
|
116
|
-
const { blocks, warnings } = toSlackBlocks({ $type: "Text", value });
|
|
117
|
-
expect((blocks[0] as SlackSectionBlock).text?.text).toHaveLength(
|
|
118
|
-
SECTION_TEXT_CAP,
|
|
119
|
-
);
|
|
120
|
-
expect(warnings).toEqual([
|
|
121
|
-
{
|
|
122
|
-
code: "clamped",
|
|
123
|
-
component: "Text",
|
|
124
|
-
detail: `value was clamped to ${SECTION_TEXT_CAP} characters.`,
|
|
125
|
-
},
|
|
126
|
-
]);
|
|
127
|
-
});
|
|
128
|
-
});
|
|
129
|
-
|
|
130
|
-
describe("Caption", () => {
|
|
131
|
-
it("renders a context block with one mrkdwn element", () => {
|
|
132
|
-
const { blocks } = toSlackBlocks({
|
|
133
|
-
$type: "Caption",
|
|
134
|
-
value: "Fine print",
|
|
135
|
-
});
|
|
136
|
-
expect(blocks).toEqual([
|
|
137
|
-
{ type: "context", elements: [{ type: "mrkdwn", text: "Fine print" }] },
|
|
138
|
-
]);
|
|
139
|
-
});
|
|
140
|
-
});
|
|
141
|
-
|
|
142
|
-
describe("Badge", () => {
|
|
143
|
-
it("renders standalone as a context block", () => {
|
|
144
|
-
const { blocks } = toSlackBlocks({ $type: "Badge", value: "New" });
|
|
145
|
-
expect(blocks).toEqual([
|
|
146
|
-
{ type: "context", elements: [{ type: "mrkdwn", text: "New" }] },
|
|
147
|
-
]);
|
|
148
|
-
});
|
|
149
|
-
});
|
|
150
|
-
|
|
151
|
-
describe("Fact", () => {
|
|
152
|
-
it("renders a single fact as one section field", () => {
|
|
153
|
-
const { blocks } = toSlackBlocks({
|
|
154
|
-
$type: "Fact",
|
|
155
|
-
label: "Status",
|
|
156
|
-
value: "Active",
|
|
157
|
-
});
|
|
158
|
-
expect(blocks).toEqual([
|
|
159
|
-
{
|
|
160
|
-
type: "section",
|
|
161
|
-
fields: [{ type: "mrkdwn", text: "*Status*\nActive" }],
|
|
162
|
-
},
|
|
163
|
-
]);
|
|
164
|
-
});
|
|
165
|
-
|
|
166
|
-
it("merges consecutive Fact siblings into one section, starting a new section past the field cap", () => {
|
|
167
|
-
const facts = Array.from({ length: FACT_FIELD_CAP + 2 }, (_, i) => ({
|
|
168
|
-
$type: "Fact",
|
|
169
|
-
label: `L${i}`,
|
|
170
|
-
value: `V${i}`,
|
|
171
|
-
}));
|
|
172
|
-
const { blocks, warnings } = toSlackBlocks(facts);
|
|
173
|
-
expect(blocks).toHaveLength(2);
|
|
174
|
-
expect((blocks[0] as SlackSectionBlock).fields).toHaveLength(
|
|
175
|
-
FACT_FIELD_CAP,
|
|
176
|
-
);
|
|
177
|
-
expect((blocks[1] as SlackSectionBlock).fields).toHaveLength(2);
|
|
178
|
-
expect(warnings).toEqual([]);
|
|
179
|
-
});
|
|
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
|
-
|
|
194
|
-
it("breaks Fact merging on a non-Fact sibling", () => {
|
|
195
|
-
const root = [
|
|
196
|
-
{ $type: "Fact", label: "A", value: "1" },
|
|
197
|
-
{ $type: "Fact", label: "B", value: "2" },
|
|
198
|
-
{ $type: "Text", value: "between" },
|
|
199
|
-
{ $type: "Fact", label: "C", value: "3" },
|
|
200
|
-
];
|
|
201
|
-
const { blocks } = toSlackBlocks(root);
|
|
202
|
-
expect(blocks).toHaveLength(3);
|
|
203
|
-
expect((blocks[0] as SlackSectionBlock).fields).toHaveLength(2);
|
|
204
|
-
expect((blocks[1] as SlackSectionBlock).text).toEqual({
|
|
205
|
-
type: "mrkdwn",
|
|
206
|
-
text: "between",
|
|
207
|
-
});
|
|
208
|
-
expect((blocks[2] as SlackSectionBlock).fields).toHaveLength(1);
|
|
209
|
-
});
|
|
210
|
-
|
|
211
|
-
it(`clamps a fact field to ${FACT_FIELD_TEXT_CAP} characters and warns`, () => {
|
|
212
|
-
const value = "v".repeat(FACT_FIELD_TEXT_CAP + 100);
|
|
213
|
-
const { blocks, warnings } = toSlackBlocks({
|
|
214
|
-
$type: "Fact",
|
|
215
|
-
label: "L",
|
|
216
|
-
value,
|
|
217
|
-
});
|
|
218
|
-
const field = (blocks[0] as SlackSectionBlock).fields?.[0];
|
|
219
|
-
expect(field?.text).toHaveLength(FACT_FIELD_TEXT_CAP);
|
|
220
|
-
expect(warnings).toEqual([
|
|
221
|
-
{
|
|
222
|
-
code: "clamped",
|
|
223
|
-
component: "Fact",
|
|
224
|
-
detail: `field was clamped to ${FACT_FIELD_TEXT_CAP} characters.`,
|
|
225
|
-
},
|
|
226
|
-
]);
|
|
227
|
-
});
|
|
228
|
-
});
|
|
229
|
-
|
|
230
|
-
describe("Image", () => {
|
|
231
|
-
it("renders image_url and alt_text, dropping other props silently", () => {
|
|
232
|
-
const { blocks, warnings } = toSlackBlocks({
|
|
233
|
-
$type: "Image",
|
|
234
|
-
src: "https://x/y.png",
|
|
235
|
-
alt: "A photo",
|
|
236
|
-
size: "lg",
|
|
237
|
-
round: true,
|
|
238
|
-
});
|
|
239
|
-
expect(blocks).toEqual([
|
|
240
|
-
{ type: "image", image_url: "https://x/y.png", alt_text: "A photo" },
|
|
241
|
-
]);
|
|
242
|
-
expect(warnings).toEqual([]);
|
|
243
|
-
});
|
|
244
|
-
});
|
|
245
|
-
|
|
246
|
-
describe("Divider", () => {
|
|
247
|
-
it("renders a divider block", () => {
|
|
248
|
-
const { blocks } = toSlackBlocks({ $type: "Divider" });
|
|
249
|
-
expect(blocks).toEqual([{ type: "divider" }]);
|
|
250
|
-
});
|
|
251
|
-
});
|
|
252
|
-
|
|
253
|
-
describe("Button", () => {
|
|
254
|
-
it("maps buttonStyle primary/danger to Slack style, omitting style otherwise", () => {
|
|
255
|
-
const { blocks: primaryBlocks } = toSlackBlocks({
|
|
256
|
-
$type: "Button",
|
|
257
|
-
label: "Go",
|
|
258
|
-
buttonStyle: "primary",
|
|
259
|
-
$action: { type: "go" },
|
|
260
|
-
});
|
|
261
|
-
expect((primaryBlocks[0] as SlackActionsBlock).elements[0]).toMatchObject(
|
|
262
|
-
{ style: "primary" },
|
|
263
|
-
);
|
|
264
|
-
|
|
265
|
-
const { blocks: dangerBlocks } = toSlackBlocks({
|
|
266
|
-
$type: "Button",
|
|
267
|
-
label: "Delete",
|
|
268
|
-
buttonStyle: "danger",
|
|
269
|
-
$action: { type: "del" },
|
|
270
|
-
});
|
|
271
|
-
expect((dangerBlocks[0] as SlackActionsBlock).elements[0]).toMatchObject({
|
|
272
|
-
style: "danger",
|
|
273
|
-
});
|
|
274
|
-
|
|
275
|
-
const { blocks: plainBlocks } = toSlackBlocks({
|
|
276
|
-
$type: "Button",
|
|
277
|
-
label: "Neutral",
|
|
278
|
-
buttonStyle: "secondary",
|
|
279
|
-
$action: { type: "noop" },
|
|
280
|
-
});
|
|
281
|
-
expect(
|
|
282
|
-
(plainBlocks[0] as SlackActionsBlock).elements[0],
|
|
283
|
-
).not.toHaveProperty("style");
|
|
284
|
-
});
|
|
285
|
-
|
|
286
|
-
it('derives action_id from $action.type, falling back to "action", and serializes the remaining payload as value', () => {
|
|
287
|
-
const { blocks } = toSlackBlocks({
|
|
288
|
-
$type: "Button",
|
|
289
|
-
label: "Buy",
|
|
290
|
-
$action: { type: "buy", sku: "abc" },
|
|
291
|
-
});
|
|
292
|
-
expect(blocks[0]).toEqual({
|
|
293
|
-
type: "actions",
|
|
294
|
-
elements: [
|
|
295
|
-
{
|
|
296
|
-
type: "button",
|
|
297
|
-
text: { type: "plain_text", text: "Buy" },
|
|
298
|
-
action_id: "buy",
|
|
299
|
-
value: JSON.stringify({ sku: "abc" }),
|
|
300
|
-
},
|
|
301
|
-
],
|
|
302
|
-
});
|
|
303
|
-
|
|
304
|
-
const { blocks: fallbackBlocks } = toSlackBlocks({
|
|
305
|
-
$type: "Button",
|
|
306
|
-
label: "Go",
|
|
307
|
-
});
|
|
308
|
-
expect(fallbackBlocks[0]).toEqual({
|
|
309
|
-
type: "actions",
|
|
310
|
-
elements: [
|
|
311
|
-
{
|
|
312
|
-
type: "button",
|
|
313
|
-
text: { type: "plain_text", text: "Go" },
|
|
314
|
-
action_id: "action",
|
|
315
|
-
},
|
|
316
|
-
],
|
|
317
|
-
});
|
|
318
|
-
});
|
|
319
|
-
|
|
320
|
-
it(`clamps the label to ${INTERACTIVE_TEXT_CAP} characters and warns`, () => {
|
|
321
|
-
const label = "x".repeat(100);
|
|
322
|
-
const { blocks, warnings } = toSlackBlocks({
|
|
323
|
-
$type: "Button",
|
|
324
|
-
label,
|
|
325
|
-
$action: { type: "go" },
|
|
326
|
-
});
|
|
327
|
-
expect((blocks[0] as SlackActionsBlock).elements[0]).toMatchObject({
|
|
328
|
-
text: { type: "plain_text", text: "x".repeat(INTERACTIVE_TEXT_CAP) },
|
|
329
|
-
});
|
|
330
|
-
expect(warnings).toEqual([
|
|
331
|
-
{
|
|
332
|
-
code: "clamped",
|
|
333
|
-
component: "Button",
|
|
334
|
-
detail: `label was clamped to ${INTERACTIVE_TEXT_CAP} characters.`,
|
|
335
|
-
},
|
|
336
|
-
]);
|
|
337
|
-
});
|
|
338
|
-
|
|
339
|
-
it(`clamps action_id to ${ACTION_ID_CAP} characters and warns`, () => {
|
|
340
|
-
const longType = "a".repeat(ACTION_ID_CAP + 20);
|
|
341
|
-
const { blocks, warnings } = toSlackBlocks({
|
|
342
|
-
$type: "Button",
|
|
343
|
-
label: "Go",
|
|
344
|
-
$action: { type: longType },
|
|
345
|
-
});
|
|
346
|
-
expect((blocks[0] as SlackActionsBlock).elements[0]).toMatchObject({
|
|
347
|
-
action_id: "a".repeat(ACTION_ID_CAP),
|
|
348
|
-
});
|
|
349
|
-
expect(warnings).toContainEqual({
|
|
350
|
-
code: "clamped",
|
|
351
|
-
component: "Button",
|
|
352
|
-
detail: `action_id was clamped to ${ACTION_ID_CAP} characters.`,
|
|
353
|
-
});
|
|
354
|
-
});
|
|
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
|
-
|
|
387
|
-
it(`omits value instead of truncating and warns when the serialized action payload exceeds ${BUTTON_VALUE_CAP} characters`, () => {
|
|
388
|
-
const { blocks, warnings } = toSlackBlocks({
|
|
389
|
-
$type: "Button",
|
|
390
|
-
label: "Go",
|
|
391
|
-
$action: { type: "go", blob: "z".repeat(BUTTON_VALUE_CAP) },
|
|
392
|
-
});
|
|
393
|
-
expect((blocks[0] as SlackActionsBlock).elements[0]).not.toHaveProperty(
|
|
394
|
-
"value",
|
|
395
|
-
);
|
|
396
|
-
expect(warnings).toContainEqual({
|
|
397
|
-
code: "dropped",
|
|
398
|
-
component: "Button",
|
|
399
|
-
detail:
|
|
400
|
-
"value was dropped because the action payload was too large to round-trip.",
|
|
401
|
-
});
|
|
402
|
-
});
|
|
403
|
-
});
|
|
404
|
-
|
|
405
|
-
describe("Select", () => {
|
|
406
|
-
it("reports options dropped for want of a string label and value", () => {
|
|
407
|
-
const { blocks, warnings } = toSlackBlocks({
|
|
408
|
-
$type: "Select",
|
|
409
|
-
options: [{ label: "ok", value: "a" }, { label: "bad" }, "nope"],
|
|
410
|
-
});
|
|
411
|
-
const select = (blocks[0] as SlackActionsBlock)
|
|
412
|
-
.elements[0] as SlackStaticSelectElement;
|
|
413
|
-
expect(select.options).toHaveLength(1);
|
|
414
|
-
expect(warnings).toContainEqual({
|
|
415
|
-
code: "dropped",
|
|
416
|
-
component: "Select",
|
|
417
|
-
detail: "2 options were dropped for want of a string label and value.",
|
|
418
|
-
});
|
|
419
|
-
});
|
|
420
|
-
|
|
421
|
-
it("converts options and placeholder", () => {
|
|
422
|
-
const { blocks } = toSlackBlocks({
|
|
423
|
-
$type: "Select",
|
|
424
|
-
placeholder: "Pick one",
|
|
425
|
-
options: [
|
|
426
|
-
{ label: "Alpha", value: "a" },
|
|
427
|
-
{ label: "Beta", value: "b" },
|
|
428
|
-
],
|
|
429
|
-
$action: { type: "pick" },
|
|
430
|
-
});
|
|
431
|
-
expect(blocks[0]).toEqual({
|
|
432
|
-
type: "actions",
|
|
433
|
-
elements: [
|
|
434
|
-
{
|
|
435
|
-
type: "static_select",
|
|
436
|
-
action_id: "pick",
|
|
437
|
-
options: [
|
|
438
|
-
{ text: { type: "plain_text", text: "Alpha" }, value: "a" },
|
|
439
|
-
{ text: { type: "plain_text", text: "Beta" }, value: "b" },
|
|
440
|
-
],
|
|
441
|
-
placeholder: { type: "plain_text", text: "Pick one" },
|
|
442
|
-
},
|
|
443
|
-
],
|
|
444
|
-
});
|
|
445
|
-
});
|
|
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
|
-
|
|
492
|
-
it(`clamps options past ${SELECT_OPTION_CAP} entries and warns`, () => {
|
|
493
|
-
const options = Array.from({ length: SELECT_OPTION_CAP + 5 }, (_, i) => ({
|
|
494
|
-
label: `L${i}`,
|
|
495
|
-
value: `v${i}`,
|
|
496
|
-
}));
|
|
497
|
-
const { blocks, warnings } = toSlackBlocks({
|
|
498
|
-
$type: "Select",
|
|
499
|
-
options,
|
|
500
|
-
$action: { type: "pick" },
|
|
501
|
-
});
|
|
502
|
-
const element = (blocks[0] as SlackActionsBlock)
|
|
503
|
-
.elements[0] as SlackStaticSelectElement;
|
|
504
|
-
expect(element.options).toHaveLength(SELECT_OPTION_CAP);
|
|
505
|
-
expect(warnings).toContainEqual({
|
|
506
|
-
code: "clamped",
|
|
507
|
-
component: "Select",
|
|
508
|
-
detail: `options were clamped to ${SELECT_OPTION_CAP} entries.`,
|
|
509
|
-
});
|
|
510
|
-
});
|
|
511
|
-
|
|
512
|
-
it("does not dispatch a prop array through Symbol.species", () => {
|
|
513
|
-
let dispatches = 0;
|
|
514
|
-
const injected = Array.from(
|
|
515
|
-
{ length: SELECT_OPTION_CAP + 1 },
|
|
516
|
-
(_, i) => ({ label: `injected-${i}`, value: `injected-${i}` }),
|
|
517
|
-
);
|
|
518
|
-
const { blocks, warnings } = toSlackBlocks({
|
|
519
|
-
$type: "Select",
|
|
520
|
-
options: hostileSpecies(
|
|
521
|
-
[{ label: "kept", value: "kept" }],
|
|
522
|
-
injected,
|
|
523
|
-
() => {
|
|
524
|
-
dispatches += 1;
|
|
525
|
-
},
|
|
526
|
-
),
|
|
527
|
-
$action: { type: "pick" },
|
|
528
|
-
});
|
|
529
|
-
const element = (blocks[0] as SlackActionsBlock)
|
|
530
|
-
.elements[0] as SlackStaticSelectElement;
|
|
531
|
-
expect(element.options).toEqual([
|
|
532
|
-
{ text: { type: "plain_text", text: "kept" }, value: "kept" },
|
|
533
|
-
]);
|
|
534
|
-
expect(dispatches).toBe(0);
|
|
535
|
-
expect(warnings).toEqual([]);
|
|
536
|
-
});
|
|
537
|
-
|
|
538
|
-
it(`clamps an option label to ${INTERACTIVE_TEXT_CAP} characters and warns`, () => {
|
|
539
|
-
const longLabel = "y".repeat(90);
|
|
540
|
-
const { blocks, warnings } = toSlackBlocks({
|
|
541
|
-
$type: "Select",
|
|
542
|
-
options: [{ label: longLabel, value: "v" }],
|
|
543
|
-
$action: { type: "pick" },
|
|
544
|
-
});
|
|
545
|
-
const element = (blocks[0] as SlackActionsBlock)
|
|
546
|
-
.elements[0] as SlackStaticSelectElement;
|
|
547
|
-
expect(element.options[0]?.text.text).toBe(
|
|
548
|
-
"y".repeat(INTERACTIVE_TEXT_CAP),
|
|
549
|
-
);
|
|
550
|
-
expect(warnings).toContainEqual({
|
|
551
|
-
code: "clamped",
|
|
552
|
-
component: "Select",
|
|
553
|
-
detail: `option label was clamped to ${INTERACTIVE_TEXT_CAP} characters.`,
|
|
554
|
-
});
|
|
555
|
-
});
|
|
556
|
-
|
|
557
|
-
it(`clamps the placeholder to ${PLACEHOLDER_TEXT_CAP} characters and warns`, () => {
|
|
558
|
-
const placeholder = "p".repeat(PLACEHOLDER_TEXT_CAP + 20);
|
|
559
|
-
const { blocks, warnings } = toSlackBlocks({
|
|
560
|
-
$type: "Select",
|
|
561
|
-
placeholder,
|
|
562
|
-
options: [{ label: "A", value: "a" }],
|
|
563
|
-
$action: { type: "pick" },
|
|
564
|
-
});
|
|
565
|
-
const element = (blocks[0] as SlackActionsBlock)
|
|
566
|
-
.elements[0] as SlackStaticSelectElement;
|
|
567
|
-
expect(element.placeholder?.text).toHaveLength(PLACEHOLDER_TEXT_CAP);
|
|
568
|
-
expect(warnings).toContainEqual({
|
|
569
|
-
code: "clamped",
|
|
570
|
-
component: "Select",
|
|
571
|
-
detail: `placeholder was clamped to ${PLACEHOLDER_TEXT_CAP} characters.`,
|
|
572
|
-
});
|
|
573
|
-
});
|
|
574
|
-
});
|
|
575
|
-
|
|
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
|
-
|
|
724
|
-
it("wraps a plain_text_input element, passing multiline through", () => {
|
|
725
|
-
const { blocks } = toSlackBlocks({
|
|
726
|
-
$type: "Input",
|
|
727
|
-
label: "Notes",
|
|
728
|
-
placeholder: "Type here",
|
|
729
|
-
multiline: true,
|
|
730
|
-
$action: { type: "notes" },
|
|
731
|
-
});
|
|
732
|
-
expect(blocks[0]).toEqual({
|
|
733
|
-
type: "input",
|
|
734
|
-
label: { type: "plain_text", text: "Notes" },
|
|
735
|
-
element: {
|
|
736
|
-
type: "plain_text_input",
|
|
737
|
-
action_id: "notes",
|
|
738
|
-
multiline: true,
|
|
739
|
-
placeholder: { type: "plain_text", text: "Type here" },
|
|
740
|
-
},
|
|
741
|
-
});
|
|
742
|
-
});
|
|
743
|
-
|
|
744
|
-
it("omits multiline when not set", () => {
|
|
745
|
-
const { blocks } = toSlackBlocks({
|
|
746
|
-
$type: "Input",
|
|
747
|
-
label: "Name",
|
|
748
|
-
$action: { type: "name" },
|
|
749
|
-
});
|
|
750
|
-
expect((blocks[0] as SlackInputBlock).element).not.toHaveProperty(
|
|
751
|
-
"multiline",
|
|
752
|
-
);
|
|
753
|
-
});
|
|
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
|
-
|
|
788
|
-
it(`clamps label to ${INPUT_LABEL_CAP} and placeholder to ${PLACEHOLDER_TEXT_CAP} characters, warning on each`, () => {
|
|
789
|
-
const label = "l".repeat(INPUT_LABEL_CAP + 10);
|
|
790
|
-
const placeholder = "p".repeat(PLACEHOLDER_TEXT_CAP + 10);
|
|
791
|
-
const { blocks, warnings } = toSlackBlocks({
|
|
792
|
-
$type: "Input",
|
|
793
|
-
label,
|
|
794
|
-
placeholder,
|
|
795
|
-
$action: { type: "notes" },
|
|
796
|
-
});
|
|
797
|
-
const input = blocks[0] as SlackInputBlock;
|
|
798
|
-
expect(input.label.text).toHaveLength(INPUT_LABEL_CAP);
|
|
799
|
-
expect(input.element.placeholder?.text).toHaveLength(
|
|
800
|
-
PLACEHOLDER_TEXT_CAP,
|
|
801
|
-
);
|
|
802
|
-
expect(warnings).toContainEqual({
|
|
803
|
-
code: "clamped",
|
|
804
|
-
component: "Input",
|
|
805
|
-
detail: `label was clamped to ${INPUT_LABEL_CAP} characters.`,
|
|
806
|
-
});
|
|
807
|
-
expect(warnings).toContainEqual({
|
|
808
|
-
code: "clamped",
|
|
809
|
-
component: "Input",
|
|
810
|
-
detail: `placeholder was clamped to ${PLACEHOLDER_TEXT_CAP} characters.`,
|
|
811
|
-
});
|
|
812
|
-
});
|
|
813
|
-
});
|
|
814
|
-
|
|
815
|
-
describe("DatePicker", () => {
|
|
816
|
-
it("sets initial_date from value", () => {
|
|
817
|
-
const { blocks } = toSlackBlocks({
|
|
818
|
-
$type: "DatePicker",
|
|
819
|
-
value: "2026-07-15",
|
|
820
|
-
$action: { type: "pick_date" },
|
|
821
|
-
});
|
|
822
|
-
expect(blocks[0]).toEqual({
|
|
823
|
-
type: "actions",
|
|
824
|
-
elements: [
|
|
825
|
-
{
|
|
826
|
-
type: "datepicker",
|
|
827
|
-
action_id: "pick_date",
|
|
828
|
-
initial_date: "2026-07-15",
|
|
829
|
-
},
|
|
830
|
-
],
|
|
831
|
-
});
|
|
832
|
-
});
|
|
833
|
-
|
|
834
|
-
it("omits initial_date when value is absent", () => {
|
|
835
|
-
const { blocks, warnings } = toSlackBlocks({
|
|
836
|
-
$type: "DatePicker",
|
|
837
|
-
$action: { type: "pick_date" },
|
|
838
|
-
});
|
|
839
|
-
expect((blocks[0] as SlackActionsBlock).elements[0]).not.toHaveProperty(
|
|
840
|
-
"initial_date",
|
|
841
|
-
);
|
|
842
|
-
expect(warnings).toEqual([]);
|
|
843
|
-
});
|
|
844
|
-
|
|
845
|
-
it("drops a value that does not match YYYY-MM-DD and warns", () => {
|
|
846
|
-
const { blocks, warnings } = toSlackBlocks({
|
|
847
|
-
$type: "DatePicker",
|
|
848
|
-
value: "07/15/2026",
|
|
849
|
-
$action: { type: "pick_date" },
|
|
850
|
-
});
|
|
851
|
-
expect((blocks[0] as SlackActionsBlock).elements[0]).not.toHaveProperty(
|
|
852
|
-
"initial_date",
|
|
853
|
-
);
|
|
854
|
-
expect(warnings).toContainEqual({
|
|
855
|
-
code: "dropped",
|
|
856
|
-
component: "DatePicker",
|
|
857
|
-
detail: "value did not match the YYYY-MM-DD format and was dropped.",
|
|
858
|
-
});
|
|
859
|
-
});
|
|
860
|
-
});
|
|
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
|
-
|
|
919
|
-
describe("Checkbox", () => {
|
|
920
|
-
it("emits a single-option checkboxes element", () => {
|
|
921
|
-
const { blocks } = toSlackBlocks({
|
|
922
|
-
$type: "Checkbox",
|
|
923
|
-
label: "Agree",
|
|
924
|
-
name: "agree",
|
|
925
|
-
$action: { type: "toggle" },
|
|
926
|
-
});
|
|
927
|
-
expect(blocks[0]).toEqual({
|
|
928
|
-
type: "actions",
|
|
929
|
-
block_id: `aui:0:${JSON.stringify([["toggle", "agree", "Checkbox"]])}`,
|
|
930
|
-
elements: [
|
|
931
|
-
{
|
|
932
|
-
type: "checkboxes",
|
|
933
|
-
action_id: "toggle",
|
|
934
|
-
options: [
|
|
935
|
-
{ text: { type: "plain_text", text: "Agree" }, value: "agree" },
|
|
936
|
-
],
|
|
937
|
-
},
|
|
938
|
-
],
|
|
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
|
-
}
|
|
961
|
-
});
|
|
962
|
-
|
|
963
|
-
it("falls back to the label as the option value when name is absent", () => {
|
|
964
|
-
const { blocks } = toSlackBlocks({
|
|
965
|
-
$type: "Checkbox",
|
|
966
|
-
label: "Agree",
|
|
967
|
-
$action: { type: "toggle" },
|
|
968
|
-
});
|
|
969
|
-
expect((blocks[0] as SlackActionsBlock).elements[0]).toMatchObject({
|
|
970
|
-
options: [{ value: "Agree" }],
|
|
971
|
-
});
|
|
972
|
-
});
|
|
973
|
-
|
|
974
|
-
it("emits initial_options with its single option when defaultChecked is true", () => {
|
|
975
|
-
const { blocks } = toSlackBlocks({
|
|
976
|
-
$type: "Checkbox",
|
|
977
|
-
label: "Agree",
|
|
978
|
-
name: "agree",
|
|
979
|
-
defaultChecked: true,
|
|
980
|
-
$action: { type: "toggle" },
|
|
981
|
-
});
|
|
982
|
-
const element = (blocks[0] as SlackActionsBlock)
|
|
983
|
-
.elements[0] as SlackCheckboxesElement;
|
|
984
|
-
expect(element.initial_options).toEqual([
|
|
985
|
-
{ text: { type: "plain_text", text: "Agree" }, value: "agree" },
|
|
986
|
-
]);
|
|
987
|
-
});
|
|
988
|
-
|
|
989
|
-
it("omits initial_options when defaultChecked is absent or false", () => {
|
|
990
|
-
const { blocks } = toSlackBlocks({
|
|
991
|
-
$type: "Checkbox",
|
|
992
|
-
label: "Agree",
|
|
993
|
-
$action: { type: "toggle" },
|
|
994
|
-
});
|
|
995
|
-
expect((blocks[0] as SlackActionsBlock).elements[0]).not.toHaveProperty(
|
|
996
|
-
"initial_options",
|
|
997
|
-
);
|
|
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
|
-
});
|
|
1011
|
-
});
|
|
1012
|
-
|
|
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
|
-
|
|
1035
|
-
it("names RadioGroup when the same option loss happens there", () => {
|
|
1036
|
-
const { warnings } = toSlackBlocks({
|
|
1037
|
-
$type: "RadioGroup",
|
|
1038
|
-
options: [{ label: "ok", value: "a" }, { label: "bad" }],
|
|
1039
|
-
});
|
|
1040
|
-
expect(warnings).toContainEqual({
|
|
1041
|
-
code: "dropped",
|
|
1042
|
-
component: "RadioGroup",
|
|
1043
|
-
detail: "1 option was dropped for want of a string label and value.",
|
|
1044
|
-
});
|
|
1045
|
-
});
|
|
1046
|
-
|
|
1047
|
-
const options = [
|
|
1048
|
-
{ label: "Small", value: "s" },
|
|
1049
|
-
{ label: "Large", value: "l" },
|
|
1050
|
-
];
|
|
1051
|
-
|
|
1052
|
-
it("sets initial_option from a matching value prop", () => {
|
|
1053
|
-
const { blocks } = toSlackBlocks({
|
|
1054
|
-
$type: "RadioGroup",
|
|
1055
|
-
options,
|
|
1056
|
-
value: "l",
|
|
1057
|
-
$action: { type: "size" },
|
|
1058
|
-
});
|
|
1059
|
-
expect((blocks[0] as SlackActionsBlock).elements[0]).toMatchObject({
|
|
1060
|
-
initial_option: { value: "l" },
|
|
1061
|
-
});
|
|
1062
|
-
});
|
|
1063
|
-
|
|
1064
|
-
it("sets initial_option from a matching defaultValue prop", () => {
|
|
1065
|
-
const { blocks } = toSlackBlocks({
|
|
1066
|
-
$type: "RadioGroup",
|
|
1067
|
-
options,
|
|
1068
|
-
defaultValue: "s",
|
|
1069
|
-
$action: { type: "size" },
|
|
1070
|
-
});
|
|
1071
|
-
expect((blocks[0] as SlackActionsBlock).elements[0]).toMatchObject({
|
|
1072
|
-
initial_option: { value: "s" },
|
|
1073
|
-
});
|
|
1074
|
-
});
|
|
1075
|
-
|
|
1076
|
-
it("omits initial_option when nothing matches", () => {
|
|
1077
|
-
const { blocks } = toSlackBlocks({
|
|
1078
|
-
$type: "RadioGroup",
|
|
1079
|
-
options,
|
|
1080
|
-
$action: { type: "size" },
|
|
1081
|
-
});
|
|
1082
|
-
expect((blocks[0] as SlackActionsBlock).elements[0]).not.toHaveProperty(
|
|
1083
|
-
"initial_option",
|
|
1084
|
-
);
|
|
1085
|
-
});
|
|
1086
|
-
|
|
1087
|
-
it(`clamps options past ${RADIO_OPTION_CAP} entries and warns`, () => {
|
|
1088
|
-
const manyOptions = Array.from(
|
|
1089
|
-
{ length: RADIO_OPTION_CAP + 3 },
|
|
1090
|
-
(_, i) => ({ label: `L${i}`, value: `v${i}` }),
|
|
1091
|
-
);
|
|
1092
|
-
const { blocks, warnings } = toSlackBlocks({
|
|
1093
|
-
$type: "RadioGroup",
|
|
1094
|
-
options: manyOptions,
|
|
1095
|
-
$action: { type: "size" },
|
|
1096
|
-
});
|
|
1097
|
-
const element = (blocks[0] as SlackActionsBlock)
|
|
1098
|
-
.elements[0] as SlackRadioButtonsElement;
|
|
1099
|
-
expect(element.options).toHaveLength(RADIO_OPTION_CAP);
|
|
1100
|
-
expect(warnings).toContainEqual({
|
|
1101
|
-
code: "clamped",
|
|
1102
|
-
component: "RadioGroup",
|
|
1103
|
-
detail: `options were clamped to ${RADIO_OPTION_CAP} entries.`,
|
|
1104
|
-
});
|
|
1105
|
-
});
|
|
1106
|
-
});
|
|
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
|
-
|
|
1191
|
-
describe("interactive grouping", () => {
|
|
1192
|
-
it("groups consecutive interactive siblings into one actions block, split by a non-interactive sibling", () => {
|
|
1193
|
-
const root = [
|
|
1194
|
-
{ $type: "Button", label: "A", $action: { type: "a" } },
|
|
1195
|
-
{
|
|
1196
|
-
$type: "Select",
|
|
1197
|
-
options: [{ label: "X", value: "x" }],
|
|
1198
|
-
$action: { type: "b" },
|
|
1199
|
-
},
|
|
1200
|
-
{
|
|
1201
|
-
$type: "CheckboxGroup",
|
|
1202
|
-
options: [{ label: "Y", value: "y" }],
|
|
1203
|
-
$action: { type: "checkbox" },
|
|
1204
|
-
},
|
|
1205
|
-
{ $type: "Text", value: "gap" },
|
|
1206
|
-
{ $type: "Button", label: "C", $action: { type: "c" } },
|
|
1207
|
-
];
|
|
1208
|
-
const { blocks } = toSlackBlocks(root);
|
|
1209
|
-
expect(blocks).toHaveLength(3);
|
|
1210
|
-
expect(blocks[0]).toMatchObject({
|
|
1211
|
-
type: "actions",
|
|
1212
|
-
elements: [
|
|
1213
|
-
{ type: "button" },
|
|
1214
|
-
{ type: "static_select" },
|
|
1215
|
-
{ type: "checkboxes" },
|
|
1216
|
-
],
|
|
1217
|
-
});
|
|
1218
|
-
expect(blocks[1]).toMatchObject({ type: "section" });
|
|
1219
|
-
expect(blocks[2]).toMatchObject({
|
|
1220
|
-
type: "actions",
|
|
1221
|
-
elements: [{ type: "button" }],
|
|
1222
|
-
});
|
|
1223
|
-
});
|
|
1224
|
-
|
|
1225
|
-
it(`splits an actions block past the ${ACTIONS_ELEMENT_CAP}-element cap`, () => {
|
|
1226
|
-
const total = ACTIONS_ELEMENT_CAP + 5;
|
|
1227
|
-
const buttons = Array.from({ length: total }, (_, i) => ({
|
|
1228
|
-
$type: "Button",
|
|
1229
|
-
label: `B${i}`,
|
|
1230
|
-
$action: { type: `b${i}` },
|
|
1231
|
-
}));
|
|
1232
|
-
const { blocks, warnings } = toSlackBlocks(buttons);
|
|
1233
|
-
expect(blocks).toHaveLength(2);
|
|
1234
|
-
expect((blocks[0] as SlackActionsBlock).elements).toHaveLength(
|
|
1235
|
-
ACTIONS_ELEMENT_CAP,
|
|
1236
|
-
);
|
|
1237
|
-
expect((blocks[1] as SlackActionsBlock).elements).toHaveLength(
|
|
1238
|
-
total - ACTIONS_ELEMENT_CAP,
|
|
1239
|
-
);
|
|
1240
|
-
expect(warnings).toEqual([]);
|
|
1241
|
-
});
|
|
1242
|
-
});
|
|
1243
|
-
|
|
1244
|
-
describe("Alert", () => {
|
|
1245
|
-
it("renders the info tone by default", () => {
|
|
1246
|
-
const { blocks, warnings } = toSlackBlocks({
|
|
1247
|
-
$type: "Alert",
|
|
1248
|
-
title: "Heads up",
|
|
1249
|
-
description: "Something happened.",
|
|
1250
|
-
});
|
|
1251
|
-
expect(blocks).toEqual([
|
|
1252
|
-
{
|
|
1253
|
-
type: "context",
|
|
1254
|
-
elements: [{ type: "mrkdwn", text: "ℹ️ *Heads up*" }],
|
|
1255
|
-
},
|
|
1256
|
-
{
|
|
1257
|
-
type: "section",
|
|
1258
|
-
text: { type: "mrkdwn", text: "Something happened." },
|
|
1259
|
-
},
|
|
1260
|
-
]);
|
|
1261
|
-
expect(warnings).toEqual([
|
|
1262
|
-
{
|
|
1263
|
-
code: "fallback",
|
|
1264
|
-
component: "Alert",
|
|
1265
|
-
detail: "Alert was rendered with message-surface fallback blocks.",
|
|
1266
|
-
},
|
|
1267
|
-
]);
|
|
1268
|
-
});
|
|
1269
|
-
|
|
1270
|
-
it("renders the success tone emoji", () => {
|
|
1271
|
-
const { blocks } = toSlackBlocks({
|
|
1272
|
-
$type: "Alert",
|
|
1273
|
-
title: "Done",
|
|
1274
|
-
tone: "success",
|
|
1275
|
-
});
|
|
1276
|
-
expect(blocks[0]).toEqual({
|
|
1277
|
-
type: "context",
|
|
1278
|
-
elements: [{ type: "mrkdwn", text: "✅ *Done*" }],
|
|
1279
|
-
});
|
|
1280
|
-
});
|
|
1281
|
-
|
|
1282
|
-
it("renders the warning tone emoji", () => {
|
|
1283
|
-
const { blocks } = toSlackBlocks({
|
|
1284
|
-
$type: "Alert",
|
|
1285
|
-
title: "Careful",
|
|
1286
|
-
tone: "warning",
|
|
1287
|
-
});
|
|
1288
|
-
expect(blocks[0]).toEqual({
|
|
1289
|
-
type: "context",
|
|
1290
|
-
elements: [{ type: "mrkdwn", text: "⚠️ *Careful*" }],
|
|
1291
|
-
});
|
|
1292
|
-
});
|
|
1293
|
-
|
|
1294
|
-
it("renders the danger tone emoji", () => {
|
|
1295
|
-
const { blocks } = toSlackBlocks({
|
|
1296
|
-
$type: "Alert",
|
|
1297
|
-
title: "Stop",
|
|
1298
|
-
tone: "danger",
|
|
1299
|
-
});
|
|
1300
|
-
expect(blocks[0]).toEqual({
|
|
1301
|
-
type: "context",
|
|
1302
|
-
elements: [{ type: "mrkdwn", text: "🛑 *Stop*" }],
|
|
1303
|
-
});
|
|
1304
|
-
});
|
|
1305
|
-
|
|
1306
|
-
it("renders just the emoji when no title is given", () => {
|
|
1307
|
-
const { blocks } = toSlackBlocks({
|
|
1308
|
-
$type: "Alert",
|
|
1309
|
-
description: "No title here.",
|
|
1310
|
-
});
|
|
1311
|
-
expect(blocks[0]).toEqual({
|
|
1312
|
-
type: "context",
|
|
1313
|
-
elements: [{ type: "mrkdwn", text: "ℹ️" }],
|
|
1314
|
-
});
|
|
1315
|
-
});
|
|
1316
|
-
|
|
1317
|
-
it("renders a native alert block on the modal surface, joining title and description", () => {
|
|
1318
|
-
const { blocks, warnings } = toSlackBlocks(
|
|
1319
|
-
{
|
|
1320
|
-
$type: "Alert",
|
|
1321
|
-
title: "Heads up",
|
|
1322
|
-
description: "Something happened.",
|
|
1323
|
-
},
|
|
1324
|
-
{ surface: "modal" },
|
|
1325
|
-
);
|
|
1326
|
-
expect(blocks).toEqual([
|
|
1327
|
-
{
|
|
1328
|
-
type: "alert",
|
|
1329
|
-
text: { type: "mrkdwn", text: "Heads up\nSomething happened." },
|
|
1330
|
-
level: "info",
|
|
1331
|
-
},
|
|
1332
|
-
]);
|
|
1333
|
-
expect(warnings).toEqual([]);
|
|
1334
|
-
});
|
|
1335
|
-
|
|
1336
|
-
it("maps the danger tone to the error level on the modal surface", () => {
|
|
1337
|
-
const { blocks } = toSlackBlocks(
|
|
1338
|
-
{ $type: "Alert", title: "Stop", tone: "danger" },
|
|
1339
|
-
{ surface: "modal" },
|
|
1340
|
-
);
|
|
1341
|
-
expect(blocks).toEqual([
|
|
1342
|
-
{
|
|
1343
|
-
type: "alert",
|
|
1344
|
-
text: { type: "mrkdwn", text: "Stop" },
|
|
1345
|
-
level: "error",
|
|
1346
|
-
},
|
|
1347
|
-
]);
|
|
1348
|
-
});
|
|
1349
|
-
|
|
1350
|
-
it("maps the success and warning tones to like-named levels on the modal surface", () => {
|
|
1351
|
-
const { blocks: successBlocks } = toSlackBlocks(
|
|
1352
|
-
{ $type: "Alert", title: "Done", tone: "success" },
|
|
1353
|
-
{ surface: "modal" },
|
|
1354
|
-
);
|
|
1355
|
-
expect((successBlocks[0] as SlackAlertBlock).level).toBe("success");
|
|
1356
|
-
|
|
1357
|
-
const { blocks: warningBlocks } = toSlackBlocks(
|
|
1358
|
-
{ $type: "Alert", title: "Careful", tone: "warning" },
|
|
1359
|
-
{ surface: "modal" },
|
|
1360
|
-
);
|
|
1361
|
-
expect((warningBlocks[0] as SlackAlertBlock).level).toBe("warning");
|
|
1362
|
-
});
|
|
1363
|
-
|
|
1364
|
-
it(`clamps modal alert text to ${ALERT_TEXT_CAP} characters and warns`, () => {
|
|
1365
|
-
const description = "d".repeat(ALERT_TEXT_CAP + 50);
|
|
1366
|
-
const { blocks, warnings } = toSlackBlocks(
|
|
1367
|
-
{ $type: "Alert", description },
|
|
1368
|
-
{ surface: "modal" },
|
|
1369
|
-
);
|
|
1370
|
-
expect((blocks[0] as SlackAlertBlock).text.text).toHaveLength(
|
|
1371
|
-
ALERT_TEXT_CAP,
|
|
1372
|
-
);
|
|
1373
|
-
expect(warnings).toContainEqual({
|
|
1374
|
-
code: "clamped",
|
|
1375
|
-
component: "Alert",
|
|
1376
|
-
detail: `text was clamped to ${ALERT_TEXT_CAP} characters.`,
|
|
1377
|
-
});
|
|
1378
|
-
});
|
|
1379
|
-
|
|
1380
|
-
it("keeps the message-surface fallback unchanged when the surface is explicitly set to message", () => {
|
|
1381
|
-
const { blocks, warnings } = toSlackBlocks(
|
|
1382
|
-
{
|
|
1383
|
-
$type: "Alert",
|
|
1384
|
-
title: "Heads up",
|
|
1385
|
-
description: "Something happened.",
|
|
1386
|
-
},
|
|
1387
|
-
{ surface: "message" },
|
|
1388
|
-
);
|
|
1389
|
-
expect(blocks).toEqual([
|
|
1390
|
-
{
|
|
1391
|
-
type: "context",
|
|
1392
|
-
elements: [{ type: "mrkdwn", text: "ℹ️ *Heads up*" }],
|
|
1393
|
-
},
|
|
1394
|
-
{
|
|
1395
|
-
type: "section",
|
|
1396
|
-
text: { type: "mrkdwn", text: "Something happened." },
|
|
1397
|
-
},
|
|
1398
|
-
]);
|
|
1399
|
-
expect(warnings).toEqual([
|
|
1400
|
-
{
|
|
1401
|
-
code: "fallback",
|
|
1402
|
-
component: "Alert",
|
|
1403
|
-
detail: "Alert was rendered with message-surface fallback blocks.",
|
|
1404
|
-
},
|
|
1405
|
-
]);
|
|
1406
|
-
});
|
|
1407
|
-
});
|
|
1408
|
-
|
|
1409
|
-
describe("Card", () => {
|
|
1410
|
-
it("maps a clean card into a native card block with title, hero image, body, subtext, and actions", () => {
|
|
1411
|
-
const { blocks, warnings } = toSlackBlocks({
|
|
1412
|
-
$type: "Card",
|
|
1413
|
-
title: "Order #42",
|
|
1414
|
-
confirm: { label: "Ship", $action: { type: "ship" } },
|
|
1415
|
-
cancel: { label: "Cancel", $action: { type: "cancel" } },
|
|
1416
|
-
children: [
|
|
1417
|
-
{ $type: "Image", src: "https://x/y.png", alt: "Package" },
|
|
1418
|
-
{ $type: "Text", value: "Shipped" },
|
|
1419
|
-
{ $type: "Caption", value: "Updated just now" },
|
|
1420
|
-
],
|
|
1421
|
-
});
|
|
1422
|
-
expect(blocks).toEqual([
|
|
1423
|
-
{
|
|
1424
|
-
type: "card",
|
|
1425
|
-
hero_image: {
|
|
1426
|
-
type: "image",
|
|
1427
|
-
image_url: "https://x/y.png",
|
|
1428
|
-
alt_text: "Package",
|
|
1429
|
-
},
|
|
1430
|
-
title: { type: "mrkdwn", text: "Order #42" },
|
|
1431
|
-
body: { type: "mrkdwn", text: "Shipped" },
|
|
1432
|
-
subtext: { type: "mrkdwn", text: "Updated just now" },
|
|
1433
|
-
actions: [
|
|
1434
|
-
{
|
|
1435
|
-
type: "button",
|
|
1436
|
-
text: { type: "plain_text", text: "Ship" },
|
|
1437
|
-
action_id: "ship",
|
|
1438
|
-
style: "primary",
|
|
1439
|
-
},
|
|
1440
|
-
{
|
|
1441
|
-
type: "button",
|
|
1442
|
-
text: { type: "plain_text", text: "Cancel" },
|
|
1443
|
-
action_id: "cancel",
|
|
1444
|
-
},
|
|
1445
|
-
],
|
|
1446
|
-
},
|
|
1447
|
-
]);
|
|
1448
|
-
expect(warnings).toEqual([]);
|
|
1449
|
-
});
|
|
1450
|
-
|
|
1451
|
-
it("maps confirm/cancel to the actions array directly, without a title or body", () => {
|
|
1452
|
-
const { blocks } = toSlackBlocks({
|
|
1453
|
-
$type: "Card",
|
|
1454
|
-
confirm: { label: "Yes", $action: { type: "confirm_yes" } },
|
|
1455
|
-
cancel: { label: "No", $action: { type: "confirm_no" } },
|
|
1456
|
-
});
|
|
1457
|
-
expect(blocks).toEqual([
|
|
1458
|
-
{
|
|
1459
|
-
type: "card",
|
|
1460
|
-
actions: [
|
|
1461
|
-
{
|
|
1462
|
-
type: "button",
|
|
1463
|
-
text: { type: "plain_text", text: "Yes" },
|
|
1464
|
-
action_id: "confirm_yes",
|
|
1465
|
-
style: "primary",
|
|
1466
|
-
},
|
|
1467
|
-
{
|
|
1468
|
-
type: "button",
|
|
1469
|
-
text: { type: "plain_text", text: "No" },
|
|
1470
|
-
action_id: "confirm_no",
|
|
1471
|
-
},
|
|
1472
|
-
],
|
|
1473
|
-
},
|
|
1474
|
-
]);
|
|
1475
|
-
});
|
|
1476
|
-
|
|
1477
|
-
it("falls back to a header and inline sequence when content does not fit the card shape", () => {
|
|
1478
|
-
const { blocks, warnings } = toSlackBlocks({
|
|
1479
|
-
$type: "Card",
|
|
1480
|
-
title: "Order #42",
|
|
1481
|
-
children: [
|
|
1482
|
-
{ $type: "Text", value: "Shipped" },
|
|
1483
|
-
{ $type: "Fact", label: "Carrier", value: "UPS" },
|
|
1484
|
-
],
|
|
1485
|
-
});
|
|
1486
|
-
expect(blocks).toEqual([
|
|
1487
|
-
{ type: "header", text: { type: "plain_text", text: "Order #42" } },
|
|
1488
|
-
{ type: "section", text: { type: "mrkdwn", text: "Shipped" } },
|
|
1489
|
-
{
|
|
1490
|
-
type: "section",
|
|
1491
|
-
fields: [{ type: "mrkdwn", text: "*Carrier*\nUPS" }],
|
|
1492
|
-
},
|
|
1493
|
-
]);
|
|
1494
|
-
expect(warnings).toEqual([
|
|
1495
|
-
{
|
|
1496
|
-
code: "fallback",
|
|
1497
|
-
component: "Card",
|
|
1498
|
-
detail:
|
|
1499
|
-
"A card with content beyond hero_image, body, subtext, and actions was rendered inline.",
|
|
1500
|
-
},
|
|
1501
|
-
]);
|
|
1502
|
-
});
|
|
1503
|
-
|
|
1504
|
-
it("falls back when a second Image cannot fit the single hero_image slot", () => {
|
|
1505
|
-
const { blocks, warnings } = toSlackBlocks({
|
|
1506
|
-
$type: "Card",
|
|
1507
|
-
title: "Gallery",
|
|
1508
|
-
children: [
|
|
1509
|
-
{ $type: "Image", src: "https://x/1.png", alt: "One" },
|
|
1510
|
-
{ $type: "Image", src: "https://x/2.png", alt: "Two" },
|
|
1511
|
-
],
|
|
1512
|
-
});
|
|
1513
|
-
expect(blocks[0]).toMatchObject({ type: "header" });
|
|
1514
|
-
expect(warnings).toContainEqual({
|
|
1515
|
-
code: "fallback",
|
|
1516
|
-
component: "Card",
|
|
1517
|
-
detail:
|
|
1518
|
-
"A card with content beyond hero_image, body, subtext, and actions was rendered inline.",
|
|
1519
|
-
});
|
|
1520
|
-
});
|
|
1521
|
-
|
|
1522
|
-
it("falls back inline when the converted card content overflows the surface block budget", () => {
|
|
1523
|
-
const headers = Array.from({ length: MESSAGE_BLOCK_CAP + 1 }, (_, i) => ({
|
|
1524
|
-
$type: "Header",
|
|
1525
|
-
text: `H${i}`,
|
|
1526
|
-
}));
|
|
1527
|
-
const { blocks, warnings } = toSlackBlocks({
|
|
1528
|
-
$type: "Card",
|
|
1529
|
-
title: "Big",
|
|
1530
|
-
children: headers,
|
|
1531
|
-
});
|
|
1532
|
-
expect(blocks[0]).toEqual({
|
|
1533
|
-
type: "header",
|
|
1534
|
-
text: { type: "plain_text", text: "Big" },
|
|
1535
|
-
});
|
|
1536
|
-
expect(blocks).toHaveLength(MESSAGE_BLOCK_CAP);
|
|
1537
|
-
expect(blocks[blocks.length - 1]).toMatchObject({ type: "context" });
|
|
1538
|
-
expect(warnings).toContainEqual({
|
|
1539
|
-
code: "fallback",
|
|
1540
|
-
component: "Card",
|
|
1541
|
-
detail:
|
|
1542
|
-
"A card with content beyond hero_image, body, subtext, and actions was rendered inline.",
|
|
1543
|
-
});
|
|
1544
|
-
expect(
|
|
1545
|
-
warnings.some((w) => w.code === "clamped" && w.component === "Root"),
|
|
1546
|
-
).toBe(true);
|
|
1547
|
-
});
|
|
1548
|
-
|
|
1549
|
-
it(`clamps the title to ${CARD_TITLE_CAP} characters and warns`, () => {
|
|
1550
|
-
const title = "c".repeat(200);
|
|
1551
|
-
const { blocks, warnings } = toSlackBlocks({ $type: "Card", title });
|
|
1552
|
-
expect(blocks).toEqual([
|
|
1553
|
-
{
|
|
1554
|
-
type: "card",
|
|
1555
|
-
title: { type: "mrkdwn", text: "c".repeat(CARD_TITLE_CAP) },
|
|
1556
|
-
},
|
|
1557
|
-
]);
|
|
1558
|
-
expect(warnings).toContainEqual({
|
|
1559
|
-
code: "clamped",
|
|
1560
|
-
component: "Card",
|
|
1561
|
-
detail: `title was clamped to ${CARD_TITLE_CAP} characters.`,
|
|
1562
|
-
});
|
|
1563
|
-
});
|
|
1564
|
-
|
|
1565
|
-
it(`clamps body to ${CARD_BODY_CAP} characters and warns`, () => {
|
|
1566
|
-
const value = "b".repeat(CARD_BODY_CAP + 50);
|
|
1567
|
-
const { blocks, warnings } = toSlackBlocks({
|
|
1568
|
-
$type: "Card",
|
|
1569
|
-
children: [{ $type: "Text", value }],
|
|
1570
|
-
});
|
|
1571
|
-
const card = blocks[0] as SlackCardBlock;
|
|
1572
|
-
expect(card.body?.text).toHaveLength(CARD_BODY_CAP);
|
|
1573
|
-
expect(warnings).toContainEqual({
|
|
1574
|
-
code: "clamped",
|
|
1575
|
-
component: "Card",
|
|
1576
|
-
detail: `body was clamped to ${CARD_BODY_CAP} characters.`,
|
|
1577
|
-
});
|
|
1578
|
-
});
|
|
1579
|
-
|
|
1580
|
-
it(`clamps subtext to ${CARD_SUBTEXT_CAP} characters and warns`, () => {
|
|
1581
|
-
const value = "s".repeat(CARD_SUBTEXT_CAP + 50);
|
|
1582
|
-
const { blocks, warnings } = toSlackBlocks({
|
|
1583
|
-
$type: "Card",
|
|
1584
|
-
title: "Order",
|
|
1585
|
-
children: [{ $type: "Caption", value }],
|
|
1586
|
-
});
|
|
1587
|
-
const card = blocks[0] as SlackCardBlock;
|
|
1588
|
-
expect(card.subtext?.text).toHaveLength(CARD_SUBTEXT_CAP);
|
|
1589
|
-
expect(warnings).toContainEqual({
|
|
1590
|
-
code: "clamped",
|
|
1591
|
-
component: "Card",
|
|
1592
|
-
detail: `subtext was clamped to ${CARD_SUBTEXT_CAP} characters.`,
|
|
1593
|
-
});
|
|
1594
|
-
});
|
|
1595
|
-
|
|
1596
|
-
it("drops a card with none of hero_image, title, body, or actions instead of emitting an invalid block", () => {
|
|
1597
|
-
const { blocks, warnings } = toSlackBlocks({ $type: "Card" });
|
|
1598
|
-
expect(blocks).toEqual([]);
|
|
1599
|
-
expect(warnings).toContainEqual({
|
|
1600
|
-
code: "dropped",
|
|
1601
|
-
component: "Card",
|
|
1602
|
-
detail:
|
|
1603
|
-
"A card with none of hero_image, title, body, or actions was dropped.",
|
|
1604
|
-
});
|
|
1605
|
-
});
|
|
1606
|
-
});
|
|
1607
|
-
|
|
1608
|
-
describe("Carousel", () => {
|
|
1609
|
-
it("converts clean Card children into carousel elements", () => {
|
|
1610
|
-
const { blocks, warnings } = toSlackBlocks({
|
|
1611
|
-
$type: "Carousel",
|
|
1612
|
-
children: [
|
|
1613
|
-
{
|
|
1614
|
-
$type: "Card",
|
|
1615
|
-
title: "One",
|
|
1616
|
-
children: [{ $type: "Text", value: "First" }],
|
|
1617
|
-
},
|
|
1618
|
-
{
|
|
1619
|
-
$type: "Card",
|
|
1620
|
-
title: "Two",
|
|
1621
|
-
children: [{ $type: "Text", value: "Second" }],
|
|
1622
|
-
},
|
|
1623
|
-
],
|
|
1624
|
-
});
|
|
1625
|
-
expect(blocks).toEqual([
|
|
1626
|
-
{
|
|
1627
|
-
type: "carousel",
|
|
1628
|
-
elements: [
|
|
1629
|
-
{
|
|
1630
|
-
type: "card",
|
|
1631
|
-
title: { type: "mrkdwn", text: "One" },
|
|
1632
|
-
body: { type: "mrkdwn", text: "First" },
|
|
1633
|
-
},
|
|
1634
|
-
{
|
|
1635
|
-
type: "card",
|
|
1636
|
-
title: { type: "mrkdwn", text: "Two" },
|
|
1637
|
-
body: { type: "mrkdwn", text: "Second" },
|
|
1638
|
-
},
|
|
1639
|
-
],
|
|
1640
|
-
},
|
|
1641
|
-
]);
|
|
1642
|
-
expect(warnings).toEqual([]);
|
|
1643
|
-
});
|
|
1644
|
-
|
|
1645
|
-
it(`clamps to ${CAROUSEL_CARD_CAP} elements and warns`, () => {
|
|
1646
|
-
const cards = Array.from({ length: CAROUSEL_CARD_CAP + 1 }, (_, i) => ({
|
|
1647
|
-
$type: "Card",
|
|
1648
|
-
title: `Card ${i}`,
|
|
1649
|
-
}));
|
|
1650
|
-
const { blocks, warnings } = toSlackBlocks({
|
|
1651
|
-
$type: "Carousel",
|
|
1652
|
-
children: cards,
|
|
1653
|
-
});
|
|
1654
|
-
expect((blocks[0] as SlackCarouselBlock).elements).toHaveLength(
|
|
1655
|
-
CAROUSEL_CARD_CAP,
|
|
1656
|
-
);
|
|
1657
|
-
expect(warnings).toContainEqual({
|
|
1658
|
-
code: "clamped",
|
|
1659
|
-
component: "Carousel",
|
|
1660
|
-
detail: `cards were clamped to ${CAROUSEL_CARD_CAP} entries.`,
|
|
1661
|
-
});
|
|
1662
|
-
});
|
|
1663
|
-
|
|
1664
|
-
it("drops non-card children with a warning, keeping valid cards", () => {
|
|
1665
|
-
const { blocks, warnings } = toSlackBlocks({
|
|
1666
|
-
$type: "Carousel",
|
|
1667
|
-
children: [
|
|
1668
|
-
{ $type: "Card", title: "Kept" },
|
|
1669
|
-
{ $type: "Text", value: "not a card" },
|
|
1670
|
-
],
|
|
1671
|
-
});
|
|
1672
|
-
expect((blocks[0] as SlackCarouselBlock).elements).toEqual([
|
|
1673
|
-
{ type: "card", title: { type: "mrkdwn", text: "Kept" } },
|
|
1674
|
-
]);
|
|
1675
|
-
expect(warnings).toContainEqual({
|
|
1676
|
-
code: "dropped",
|
|
1677
|
-
component: "Carousel",
|
|
1678
|
-
detail: "1 non-card child was dropped.",
|
|
1679
|
-
});
|
|
1680
|
-
});
|
|
1681
|
-
|
|
1682
|
-
it("degrades a child card that cannot map cleanly instead of falling back to a block sequence", () => {
|
|
1683
|
-
const { blocks, warnings } = toSlackBlocks({
|
|
1684
|
-
$type: "Carousel",
|
|
1685
|
-
children: [
|
|
1686
|
-
{
|
|
1687
|
-
$type: "Card",
|
|
1688
|
-
title: "Order #1",
|
|
1689
|
-
children: [
|
|
1690
|
-
{ $type: "Text", value: "Shipped" },
|
|
1691
|
-
{ $type: "Fact", label: "Carrier", value: "UPS" },
|
|
1692
|
-
],
|
|
1693
|
-
},
|
|
1694
|
-
],
|
|
1695
|
-
});
|
|
1696
|
-
expect(blocks).toEqual([
|
|
1697
|
-
{
|
|
1698
|
-
type: "carousel",
|
|
1699
|
-
elements: [
|
|
1700
|
-
{
|
|
1701
|
-
type: "card",
|
|
1702
|
-
title: { type: "mrkdwn", text: "Order #1" },
|
|
1703
|
-
body: { type: "mrkdwn", text: "Shipped\nUPS\nCarrier" },
|
|
1704
|
-
},
|
|
1705
|
-
],
|
|
1706
|
-
},
|
|
1707
|
-
]);
|
|
1708
|
-
expect(warnings).toContainEqual({
|
|
1709
|
-
code: "fallback",
|
|
1710
|
-
component: "Card",
|
|
1711
|
-
detail: "A card inside a carousel was reshaped to title and body.",
|
|
1712
|
-
});
|
|
1713
|
-
expect(warnings.some((warning) => warning.code === "dropped")).toBe(
|
|
1714
|
-
false,
|
|
1715
|
-
);
|
|
1716
|
-
});
|
|
1717
|
-
|
|
1718
|
-
it("does not report a Caption as lost, since its text reaches the body", () => {
|
|
1719
|
-
const { blocks, warnings } = toSlackBlocks({
|
|
1720
|
-
$type: "Carousel",
|
|
1721
|
-
children: [
|
|
1722
|
-
{
|
|
1723
|
-
$type: "Card",
|
|
1724
|
-
title: "Plan",
|
|
1725
|
-
children: [
|
|
1726
|
-
{ $type: "Text", value: "body" },
|
|
1727
|
-
{ $type: "Caption", value: "fine print" },
|
|
1728
|
-
{ $type: "Divider" },
|
|
1729
|
-
],
|
|
1730
|
-
},
|
|
1731
|
-
],
|
|
1732
|
-
});
|
|
1733
|
-
const card = (blocks[0] as SlackCarouselBlock).elements[0];
|
|
1734
|
-
expect(card?.body).toEqual({ type: "mrkdwn", text: "body\nfine print" });
|
|
1735
|
-
expect(warnings.some((warning) => warning.code === "dropped")).toBe(
|
|
1736
|
-
false,
|
|
1737
|
-
);
|
|
1738
|
-
});
|
|
1739
|
-
|
|
1740
|
-
it("reports the images and controls a reshaped card really loses", () => {
|
|
1741
|
-
const { blocks, warnings } = toSlackBlocks({
|
|
1742
|
-
$type: "Carousel",
|
|
1743
|
-
children: [
|
|
1744
|
-
{
|
|
1745
|
-
$type: "Card",
|
|
1746
|
-
title: "Plan",
|
|
1747
|
-
confirm: { label: "Buy", $action: { type: "buy" } },
|
|
1748
|
-
children: [
|
|
1749
|
-
{ $type: "Image", src: "hero.png", alt: "hero" },
|
|
1750
|
-
{ $type: "Text", value: "body" },
|
|
1751
|
-
{ $type: "Caption", value: "fine print" },
|
|
1752
|
-
{ $type: "Divider" },
|
|
1753
|
-
],
|
|
1754
|
-
},
|
|
1755
|
-
],
|
|
1756
|
-
});
|
|
1757
|
-
const card = (blocks[0] as SlackCarouselBlock).elements[0];
|
|
1758
|
-
expect(card?.hero_image).toBeUndefined();
|
|
1759
|
-
expect(card?.actions).toBeUndefined();
|
|
1760
|
-
expect(card?.body).toEqual({ type: "mrkdwn", text: "body\nfine print" });
|
|
1761
|
-
expect(warnings).toContainEqual({
|
|
1762
|
-
code: "fallback",
|
|
1763
|
-
component: "Card",
|
|
1764
|
-
detail: "A card inside a carousel was reshaped to title and body.",
|
|
1765
|
-
});
|
|
1766
|
-
expect(warnings).toContainEqual({
|
|
1767
|
-
code: "dropped",
|
|
1768
|
-
component: "Card",
|
|
1769
|
-
detail: "A reshaped carousel card's images and controls were dropped.",
|
|
1770
|
-
});
|
|
1771
|
-
});
|
|
1772
|
-
|
|
1773
|
-
it.each([
|
|
1774
|
-
["tables", { $type: "Table", columns: [{ label: "A" }], rows: [["1"]] }],
|
|
1775
|
-
["charts", { $type: "Chart", series: [] }],
|
|
1776
|
-
[
|
|
1777
|
-
"images",
|
|
1778
|
-
{ $type: "ListViewItem", children: [{ $type: "Image", src: "x.png" }] },
|
|
1779
|
-
],
|
|
1780
|
-
[
|
|
1781
|
-
"controls",
|
|
1782
|
-
{
|
|
1783
|
-
$type: "ListViewItem",
|
|
1784
|
-
children: [{ $type: "Button", label: "Go", $action: { type: "go" } }],
|
|
1785
|
-
},
|
|
1786
|
-
],
|
|
1787
|
-
["controls", { $type: "Select", options: [{ label: "A", value: "a" }] }],
|
|
1788
|
-
["controls", { $type: "Input", name: "email", label: "Email" }],
|
|
1789
|
-
[
|
|
1790
|
-
"controls",
|
|
1791
|
-
{ $type: "Form", children: [{ $type: "Text", value: "inner" }] },
|
|
1792
|
-
],
|
|
1793
|
-
[
|
|
1794
|
-
"controls",
|
|
1795
|
-
{
|
|
1796
|
-
$type: "Card",
|
|
1797
|
-
title: "nested",
|
|
1798
|
-
confirm: { label: "Buy", $action: { type: "buy" } },
|
|
1799
|
-
},
|
|
1800
|
-
],
|
|
1801
|
-
])(
|
|
1802
|
-
"reports %s that a reshape cannot carry, however deep",
|
|
1803
|
-
(kind, child) => {
|
|
1804
|
-
const { warnings } = toSlackBlocks({
|
|
1805
|
-
$type: "Carousel",
|
|
1806
|
-
children: [
|
|
1807
|
-
{
|
|
1808
|
-
$type: "Card",
|
|
1809
|
-
title: "Plan",
|
|
1810
|
-
children: [
|
|
1811
|
-
{ $type: "Text", value: "b" },
|
|
1812
|
-
child,
|
|
1813
|
-
{ $type: "Divider" },
|
|
1814
|
-
],
|
|
1815
|
-
},
|
|
1816
|
-
],
|
|
1817
|
-
});
|
|
1818
|
-
expect(warnings).toContainEqual({
|
|
1819
|
-
code: "dropped",
|
|
1820
|
-
component: "Card",
|
|
1821
|
-
detail: `A reshaped carousel card's ${kind} were dropped.`,
|
|
1822
|
-
});
|
|
1823
|
-
},
|
|
1824
|
-
);
|
|
1825
|
-
|
|
1826
|
-
it("scans below a child whose text the reshape consumes", () => {
|
|
1827
|
-
const { warnings } = toSlackBlocks({
|
|
1828
|
-
$type: "Carousel",
|
|
1829
|
-
children: [
|
|
1830
|
-
{
|
|
1831
|
-
$type: "Card",
|
|
1832
|
-
title: "Plan",
|
|
1833
|
-
children: [
|
|
1834
|
-
{
|
|
1835
|
-
$type: "Text",
|
|
1836
|
-
value: "body",
|
|
1837
|
-
children: [{ $type: "Image", src: "x.png" }],
|
|
1838
|
-
},
|
|
1839
|
-
{ $type: "Divider" },
|
|
1840
|
-
],
|
|
1841
|
-
},
|
|
1842
|
-
],
|
|
1843
|
-
});
|
|
1844
|
-
expect(warnings).toContainEqual({
|
|
1845
|
-
code: "dropped",
|
|
1846
|
-
component: "Card",
|
|
1847
|
-
detail: "A reshaped carousel card's images were dropped.",
|
|
1848
|
-
});
|
|
1849
|
-
});
|
|
1850
|
-
|
|
1851
|
-
it("does not report a ListViewItem whose action renders no accessory", () => {
|
|
1852
|
-
const { warnings } = toSlackBlocks({
|
|
1853
|
-
$type: "Carousel",
|
|
1854
|
-
children: [
|
|
1855
|
-
{
|
|
1856
|
-
$type: "Card",
|
|
1857
|
-
title: "Plan",
|
|
1858
|
-
children: [
|
|
1859
|
-
{ $type: "Text", value: "body" },
|
|
1860
|
-
{ $type: "ListViewItem", title: "row", $action: "open" },
|
|
1861
|
-
{ $type: "Divider" },
|
|
1862
|
-
],
|
|
1863
|
-
},
|
|
1864
|
-
],
|
|
1865
|
-
});
|
|
1866
|
-
expect(warnings.some((warning) => warning.code === "dropped")).toBe(
|
|
1867
|
-
false,
|
|
1868
|
-
);
|
|
1869
|
-
});
|
|
1870
|
-
|
|
1871
|
-
it("does not report an action on a node that renders no control", () => {
|
|
1872
|
-
const { warnings } = toSlackBlocks({
|
|
1873
|
-
$type: "Carousel",
|
|
1874
|
-
children: [
|
|
1875
|
-
{
|
|
1876
|
-
$type: "Card",
|
|
1877
|
-
title: "Plan",
|
|
1878
|
-
children: [
|
|
1879
|
-
{
|
|
1880
|
-
$type: "Box",
|
|
1881
|
-
$action: { type: "open" },
|
|
1882
|
-
children: [{ $type: "Text", value: "body" }],
|
|
1883
|
-
},
|
|
1884
|
-
{ $type: "Divider" },
|
|
1885
|
-
],
|
|
1886
|
-
},
|
|
1887
|
-
],
|
|
1888
|
-
});
|
|
1889
|
-
expect(warnings.some((warning) => warning.code === "dropped")).toBe(
|
|
1890
|
-
false,
|
|
1891
|
-
);
|
|
1892
|
-
});
|
|
1893
|
-
|
|
1894
|
-
it("clamps a reshaped card's title and body instead of slicing them silently", () => {
|
|
1895
|
-
const { warnings } = toSlackBlocks({
|
|
1896
|
-
$type: "Carousel",
|
|
1897
|
-
children: [
|
|
1898
|
-
{
|
|
1899
|
-
$type: "Card",
|
|
1900
|
-
title: "t".repeat(CARD_TITLE_CAP + 10),
|
|
1901
|
-
children: [
|
|
1902
|
-
{ $type: "Text", value: "b".repeat(CARD_BODY_CAP + 10) },
|
|
1903
|
-
{ $type: "Fact", label: "L", value: "V" },
|
|
1904
|
-
],
|
|
1905
|
-
},
|
|
1906
|
-
],
|
|
1907
|
-
});
|
|
1908
|
-
expect(warnings).toContainEqual({
|
|
1909
|
-
code: "clamped",
|
|
1910
|
-
component: "Card",
|
|
1911
|
-
detail: `title was clamped to ${CARD_TITLE_CAP} characters.`,
|
|
1912
|
-
});
|
|
1913
|
-
expect(warnings).toContainEqual({
|
|
1914
|
-
code: "clamped",
|
|
1915
|
-
component: "Card",
|
|
1916
|
-
detail: `body was clamped to ${CARD_BODY_CAP} characters.`,
|
|
1917
|
-
});
|
|
1918
|
-
});
|
|
1919
|
-
|
|
1920
|
-
it("uses the first text chunk as the title when a degraded card has no title of its own", () => {
|
|
1921
|
-
const { blocks } = toSlackBlocks({
|
|
1922
|
-
$type: "Carousel",
|
|
1923
|
-
children: [
|
|
1924
|
-
{
|
|
1925
|
-
$type: "Card",
|
|
1926
|
-
children: [
|
|
1927
|
-
{ $type: "Fact", label: "Carrier", value: "UPS" },
|
|
1928
|
-
{ $type: "Fact", label: "Status", value: "Delivered" },
|
|
1929
|
-
],
|
|
1930
|
-
},
|
|
1931
|
-
],
|
|
1932
|
-
});
|
|
1933
|
-
const carousel = blocks[0] as SlackCarouselBlock;
|
|
1934
|
-
expect(carousel.elements[0]).toEqual({
|
|
1935
|
-
type: "card",
|
|
1936
|
-
title: { type: "mrkdwn", text: "UPS" },
|
|
1937
|
-
body: { type: "mrkdwn", text: "Carrier\nDelivered\nStatus" },
|
|
1938
|
-
});
|
|
1939
|
-
});
|
|
1940
|
-
|
|
1941
|
-
it("is dropped when it has no renderable cards", () => {
|
|
1942
|
-
const { blocks, warnings } = toSlackBlocks({
|
|
1943
|
-
$type: "Carousel",
|
|
1944
|
-
children: [{ $type: "Text", value: "not a card" }],
|
|
1945
|
-
});
|
|
1946
|
-
expect(blocks).toEqual([]);
|
|
1947
|
-
expect(warnings).toContainEqual({
|
|
1948
|
-
code: "dropped",
|
|
1949
|
-
component: "Carousel",
|
|
1950
|
-
detail: "A carousel without renderable cards was dropped.",
|
|
1951
|
-
});
|
|
1952
|
-
});
|
|
1953
|
-
|
|
1954
|
-
it("drops a child card that degrades to empty, which then trips the zero-renderable-cards carousel drop", () => {
|
|
1955
|
-
const { blocks, warnings } = toSlackBlocks({
|
|
1956
|
-
$type: "Carousel",
|
|
1957
|
-
children: [{ $type: "Card", children: [{ $type: "Divider" }] }],
|
|
1958
|
-
});
|
|
1959
|
-
expect(blocks).toEqual([]);
|
|
1960
|
-
expect(warnings).toContainEqual({
|
|
1961
|
-
code: "dropped",
|
|
1962
|
-
component: "Card",
|
|
1963
|
-
detail:
|
|
1964
|
-
"A card with none of hero_image, title, body, or actions was dropped.",
|
|
1965
|
-
});
|
|
1966
|
-
expect(warnings).toContainEqual({
|
|
1967
|
-
code: "dropped",
|
|
1968
|
-
component: "Carousel",
|
|
1969
|
-
detail: "A carousel without renderable cards was dropped.",
|
|
1970
|
-
});
|
|
1971
|
-
});
|
|
1972
|
-
});
|
|
1973
|
-
|
|
1974
|
-
describe("Col and Box", () => {
|
|
1975
|
-
it("flatten children in order", () => {
|
|
1976
|
-
const { blocks } = toSlackBlocks({
|
|
1977
|
-
$type: "Col",
|
|
1978
|
-
children: [
|
|
1979
|
-
{ $type: "Header", text: "Hi" },
|
|
1980
|
-
{ $type: "Text", value: "There" },
|
|
1981
|
-
],
|
|
1982
|
-
});
|
|
1983
|
-
expect(blocks).toEqual([
|
|
1984
|
-
{ type: "header", text: { type: "plain_text", text: "Hi" } },
|
|
1985
|
-
{ type: "section", text: { type: "mrkdwn", text: "There" } },
|
|
1986
|
-
]);
|
|
1987
|
-
});
|
|
1988
|
-
|
|
1989
|
-
it("Box flattens the same way", () => {
|
|
1990
|
-
const { blocks } = toSlackBlocks({
|
|
1991
|
-
$type: "Box",
|
|
1992
|
-
children: { $type: "Divider" },
|
|
1993
|
-
});
|
|
1994
|
-
expect(blocks).toEqual([{ type: "divider" }]);
|
|
1995
|
-
});
|
|
1996
|
-
});
|
|
1997
|
-
|
|
1998
|
-
describe("Row", () => {
|
|
1999
|
-
it("flattens children in order when they are not all Badge/Caption", () => {
|
|
2000
|
-
const { blocks } = toSlackBlocks({
|
|
2001
|
-
$type: "Row",
|
|
2002
|
-
children: [
|
|
2003
|
-
{ $type: "Header", text: "Hi" },
|
|
2004
|
-
{ $type: "Text", value: "There" },
|
|
2005
|
-
],
|
|
2006
|
-
});
|
|
2007
|
-
expect(blocks).toEqual([
|
|
2008
|
-
{ type: "header", text: { type: "plain_text", text: "Hi" } },
|
|
2009
|
-
{ type: "section", text: { type: "mrkdwn", text: "There" } },
|
|
2010
|
-
]);
|
|
2011
|
-
});
|
|
2012
|
-
|
|
2013
|
-
it("merges an all-Badge/Caption row into one context block", () => {
|
|
2014
|
-
const { blocks } = toSlackBlocks({
|
|
2015
|
-
$type: "Row",
|
|
2016
|
-
children: [
|
|
2017
|
-
{ $type: "Badge", value: "New" },
|
|
2018
|
-
{ $type: "Caption", value: "since today" },
|
|
2019
|
-
],
|
|
2020
|
-
});
|
|
2021
|
-
expect(blocks).toEqual([
|
|
2022
|
-
{
|
|
2023
|
-
type: "context",
|
|
2024
|
-
elements: [
|
|
2025
|
-
{ type: "mrkdwn", text: "New" },
|
|
2026
|
-
{ type: "mrkdwn", text: "since today" },
|
|
2027
|
-
],
|
|
2028
|
-
},
|
|
2029
|
-
]);
|
|
2030
|
-
});
|
|
2031
|
-
|
|
2032
|
-
it("does not merge into context when a non-Badge/Caption child is present", () => {
|
|
2033
|
-
const { blocks } = toSlackBlocks({
|
|
2034
|
-
$type: "Row",
|
|
2035
|
-
children: [{ $type: "Badge", value: "New" }, "plain text"],
|
|
2036
|
-
});
|
|
2037
|
-
expect(blocks).toEqual([
|
|
2038
|
-
{ type: "context", elements: [{ type: "mrkdwn", text: "New" }] },
|
|
2039
|
-
{ type: "section", text: { type: "mrkdwn", text: "plain text" } },
|
|
2040
|
-
]);
|
|
2041
|
-
});
|
|
2042
|
-
|
|
2043
|
-
it(`clamps a Badge/Caption row to ${CONTEXT_ELEMENT_CAP} elements and warns`, () => {
|
|
2044
|
-
const badges = Array.from(
|
|
2045
|
-
{ length: CONTEXT_ELEMENT_CAP + 2 },
|
|
2046
|
-
(_, i) => ({ $type: "Badge", value: `B${i}` }),
|
|
2047
|
-
);
|
|
2048
|
-
const { blocks, warnings } = toSlackBlocks({
|
|
2049
|
-
$type: "Row",
|
|
2050
|
-
children: badges,
|
|
2051
|
-
});
|
|
2052
|
-
expect((blocks[0] as SlackContextBlock).elements).toHaveLength(
|
|
2053
|
-
CONTEXT_ELEMENT_CAP,
|
|
2054
|
-
);
|
|
2055
|
-
expect(warnings).toEqual([
|
|
2056
|
-
{
|
|
2057
|
-
code: "clamped",
|
|
2058
|
-
component: "Row",
|
|
2059
|
-
detail: `context elements were clamped to ${CONTEXT_ELEMENT_CAP} entries.`,
|
|
2060
|
-
},
|
|
2061
|
-
]);
|
|
2062
|
-
});
|
|
2063
|
-
});
|
|
2064
|
-
|
|
2065
|
-
describe("context text cap", () => {
|
|
2066
|
-
it(`clamps Caption, standalone Badge, and Row-merged badge/caption text to ${CONTEXT_TEXT_CAP} characters, warning on each`, () => {
|
|
2067
|
-
const long = "c".repeat(CONTEXT_TEXT_CAP + 50);
|
|
2068
|
-
|
|
2069
|
-
const caption = toSlackBlocks({ $type: "Caption", value: long });
|
|
2070
|
-
expect(
|
|
2071
|
-
(caption.blocks[0] as SlackContextBlock).elements[0]?.text,
|
|
2072
|
-
).toHaveLength(CONTEXT_TEXT_CAP);
|
|
2073
|
-
expect(caption.warnings).toContainEqual({
|
|
2074
|
-
code: "clamped",
|
|
2075
|
-
component: "Caption",
|
|
2076
|
-
detail: `value was clamped to ${CONTEXT_TEXT_CAP} characters.`,
|
|
2077
|
-
});
|
|
2078
|
-
|
|
2079
|
-
const badge = toSlackBlocks({ $type: "Badge", value: long });
|
|
2080
|
-
expect(
|
|
2081
|
-
(badge.blocks[0] as SlackContextBlock).elements[0]?.text,
|
|
2082
|
-
).toHaveLength(CONTEXT_TEXT_CAP);
|
|
2083
|
-
expect(badge.warnings).toContainEqual({
|
|
2084
|
-
code: "clamped",
|
|
2085
|
-
component: "Badge",
|
|
2086
|
-
detail: `value was clamped to ${CONTEXT_TEXT_CAP} characters.`,
|
|
2087
|
-
});
|
|
2088
|
-
|
|
2089
|
-
const row = toSlackBlocks({
|
|
2090
|
-
$type: "Row",
|
|
2091
|
-
children: [
|
|
2092
|
-
{ $type: "Badge", value: long },
|
|
2093
|
-
{ $type: "Caption", value: "short" },
|
|
2094
|
-
],
|
|
2095
|
-
});
|
|
2096
|
-
expect(
|
|
2097
|
-
(row.blocks[0] as SlackContextBlock).elements[0]?.text,
|
|
2098
|
-
).toHaveLength(CONTEXT_TEXT_CAP);
|
|
2099
|
-
expect(row.warnings).toContainEqual({
|
|
2100
|
-
code: "clamped",
|
|
2101
|
-
component: "Badge",
|
|
2102
|
-
detail: `value was clamped to ${CONTEXT_TEXT_CAP} characters.`,
|
|
2103
|
-
});
|
|
2104
|
-
});
|
|
2105
|
-
});
|
|
2106
|
-
|
|
2107
|
-
describe("Spacer and Icon", () => {
|
|
2108
|
-
it("are dropped silently", () => {
|
|
2109
|
-
const { blocks, warnings } = toSlackBlocks({
|
|
2110
|
-
$type: "Col",
|
|
2111
|
-
children: [
|
|
2112
|
-
{ $type: "Spacer" },
|
|
2113
|
-
{ $type: "Icon", name: "star" },
|
|
2114
|
-
{ $type: "Text", value: "kept" },
|
|
2115
|
-
],
|
|
2116
|
-
});
|
|
2117
|
-
expect(blocks).toEqual([
|
|
2118
|
-
{ type: "section", text: { type: "mrkdwn", text: "kept" } },
|
|
2119
|
-
]);
|
|
2120
|
-
expect(warnings).toEqual([]);
|
|
2121
|
-
});
|
|
2122
|
-
});
|
|
2123
|
-
|
|
2124
|
-
describe("Table", () => {
|
|
2125
|
-
it.each([["A"], [{}], [{ label: 42 }]])(
|
|
2126
|
-
"keeps an unlabeled column's position in the header and reports it: %j",
|
|
2127
|
-
(column) => {
|
|
2128
|
-
const { blocks, warnings } = toSlackBlocks({
|
|
2129
|
-
$type: "Table",
|
|
2130
|
-
columns: [column, { label: "B" }],
|
|
2131
|
-
rows: [["1", "2"]],
|
|
2132
|
-
});
|
|
2133
|
-
const table = blocks[0] as SlackDataTableBlock;
|
|
2134
|
-
expect(table.rows[0]).toEqual([
|
|
2135
|
-
{ type: "raw_text", text: "" },
|
|
2136
|
-
{ type: "raw_text", text: "B" },
|
|
2137
|
-
]);
|
|
2138
|
-
expect(warnings).toContainEqual({
|
|
2139
|
-
code: "dropped",
|
|
2140
|
-
component: "Table",
|
|
2141
|
-
detail: "1 column header was left blank for want of a string label.",
|
|
2142
|
-
});
|
|
2143
|
-
},
|
|
2144
|
-
);
|
|
2145
|
-
|
|
2146
|
-
it("converts columns and rows into a header-first rows array with typed cells", () => {
|
|
2147
|
-
const { blocks } = toSlackBlocks({
|
|
2148
|
-
$type: "Table",
|
|
2149
|
-
columns: [{ label: "Name" }, { label: "Age" }],
|
|
2150
|
-
rows: [
|
|
2151
|
-
["Ada", 36],
|
|
2152
|
-
["Bob", 24],
|
|
2153
|
-
],
|
|
2154
|
-
});
|
|
2155
|
-
expect(blocks).toEqual([
|
|
2156
|
-
{
|
|
2157
|
-
type: "data_table",
|
|
2158
|
-
caption: TABLE_CAPTION,
|
|
2159
|
-
rows: [
|
|
2160
|
-
[
|
|
2161
|
-
{ type: "raw_text", text: "Name" },
|
|
2162
|
-
{ type: "raw_text", text: "Age" },
|
|
2163
|
-
],
|
|
2164
|
-
[
|
|
2165
|
-
{ type: "raw_text", text: "Ada" },
|
|
2166
|
-
{ type: "raw_number", text: "36" },
|
|
2167
|
-
],
|
|
2168
|
-
[
|
|
2169
|
-
{ type: "raw_text", text: "Bob" },
|
|
2170
|
-
{ type: "raw_number", text: "24" },
|
|
2171
|
-
],
|
|
2172
|
-
],
|
|
2173
|
-
},
|
|
2174
|
-
]);
|
|
2175
|
-
});
|
|
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
|
-
|
|
2200
|
-
it("requires a caption on every emitted table", () => {
|
|
2201
|
-
const { blocks } = toSlackBlocks({
|
|
2202
|
-
$type: "Table",
|
|
2203
|
-
columns: [{ label: "Name" }],
|
|
2204
|
-
rows: [["Ada"]],
|
|
2205
|
-
});
|
|
2206
|
-
expect((blocks[0] as SlackDataTableBlock).caption).toBe(TABLE_CAPTION);
|
|
2207
|
-
});
|
|
2208
|
-
|
|
2209
|
-
it(`clamps data rows to ${DATA_TABLE_ROW_CAP} (plus the header row) and columns to ${DATA_TABLE_COLUMN_CAP}, warning on each`, () => {
|
|
2210
|
-
const columnCount = DATA_TABLE_COLUMN_CAP + 3;
|
|
2211
|
-
const columns = Array.from({ length: columnCount }, () => ({
|
|
2212
|
-
label: "C",
|
|
2213
|
-
}));
|
|
2214
|
-
const rows = Array.from({ length: DATA_TABLE_ROW_CAP + 5 }, () =>
|
|
2215
|
-
Array.from({ length: columnCount }, () => "x"),
|
|
2216
|
-
);
|
|
2217
|
-
const { blocks, warnings } = toSlackBlocks({
|
|
2218
|
-
$type: "Table",
|
|
2219
|
-
columns,
|
|
2220
|
-
rows,
|
|
2221
|
-
});
|
|
2222
|
-
const table = blocks[0] as SlackDataTableBlock;
|
|
2223
|
-
expect(table.rows).toHaveLength(DATA_TABLE_ROW_CAP + 1);
|
|
2224
|
-
expect(table.rows[0]).toHaveLength(DATA_TABLE_COLUMN_CAP);
|
|
2225
|
-
expect(table.rows[1]).toHaveLength(DATA_TABLE_COLUMN_CAP);
|
|
2226
|
-
expect(warnings).toContainEqual({
|
|
2227
|
-
code: "clamped",
|
|
2228
|
-
component: "Table",
|
|
2229
|
-
detail: `columns were clamped to ${DATA_TABLE_COLUMN_CAP} entries.`,
|
|
2230
|
-
});
|
|
2231
|
-
expect(warnings).toContainEqual({
|
|
2232
|
-
code: "clamped",
|
|
2233
|
-
component: "Table",
|
|
2234
|
-
detail: `rows were clamped to ${DATA_TABLE_ROW_CAP} entries.`,
|
|
2235
|
-
});
|
|
2236
|
-
expect(warnings.some((w) => w.detail.includes("table budget"))).toBe(
|
|
2237
|
-
false,
|
|
2238
|
-
);
|
|
2239
|
-
});
|
|
2240
|
-
|
|
2241
|
-
it("does not dispatch columns, rows, or row arrays through Symbol.species", () => {
|
|
2242
|
-
const dispatches = { columns: 0, rows: 0, row: 0 };
|
|
2243
|
-
const row = hostileSpecies(
|
|
2244
|
-
["kept"],
|
|
2245
|
-
["injected", "also-injected"],
|
|
2246
|
-
() => {
|
|
2247
|
-
dispatches.row += 1;
|
|
2248
|
-
},
|
|
2249
|
-
);
|
|
2250
|
-
const { blocks } = toSlackBlocks({
|
|
2251
|
-
$type: "Table",
|
|
2252
|
-
columns: hostileSpecies(
|
|
2253
|
-
[{ label: "Kept" }],
|
|
2254
|
-
[{ label: "Injected" }, { label: "Also injected" }],
|
|
2255
|
-
() => {
|
|
2256
|
-
dispatches.columns += 1;
|
|
2257
|
-
},
|
|
2258
|
-
),
|
|
2259
|
-
rows: hostileSpecies([row], [row, row], () => {
|
|
2260
|
-
dispatches.rows += 1;
|
|
2261
|
-
}),
|
|
2262
|
-
});
|
|
2263
|
-
const table = blocks[0] as SlackDataTableBlock;
|
|
2264
|
-
expect(table.rows).toHaveLength(2);
|
|
2265
|
-
expect(table.rows[0]).toEqual([{ type: "raw_text", text: "Kept" }]);
|
|
2266
|
-
expect(table.rows[1]).toEqual([{ type: "raw_text", text: "kept" }]);
|
|
2267
|
-
expect(dispatches).toEqual({ columns: 0, rows: 0, row: 0 });
|
|
2268
|
-
});
|
|
2269
|
-
|
|
2270
|
-
it(`clamps rows to fit the ${DATA_TABLE_CHAR_BUDGET}-character table budget, always keeping the header row`, () => {
|
|
2271
|
-
const bigCell = "z".repeat(Math.ceil(DATA_TABLE_CHAR_BUDGET / 5));
|
|
2272
|
-
const rows = Array.from({ length: 6 }, (_, r) => [`row${r}`, bigCell]);
|
|
2273
|
-
const { blocks, warnings } = toSlackBlocks({
|
|
2274
|
-
$type: "Table",
|
|
2275
|
-
columns: [{ label: "Id" }, { label: "Blob" }],
|
|
2276
|
-
rows,
|
|
2277
|
-
});
|
|
2278
|
-
const table = blocks[0] as SlackDataTableBlock;
|
|
2279
|
-
expect(table.rows.length).toBeLessThan(rows.length + 1);
|
|
2280
|
-
expect(table.rows[0]).toEqual([
|
|
2281
|
-
{ type: "raw_text", text: "Id" },
|
|
2282
|
-
{ type: "raw_text", text: "Blob" },
|
|
2283
|
-
]);
|
|
2284
|
-
expect(warnings).toContainEqual({
|
|
2285
|
-
code: "clamped",
|
|
2286
|
-
component: "Table",
|
|
2287
|
-
detail: `rows were clamped to fit the ${DATA_TABLE_CHAR_BUDGET}-character table budget.`,
|
|
2288
|
-
});
|
|
2289
|
-
});
|
|
2290
|
-
|
|
2291
|
-
it("shares the character budget across multiple data-table blocks in one payload", () => {
|
|
2292
|
-
const secondValue = "second";
|
|
2293
|
-
const filler = "f".repeat(DATA_TABLE_CHAR_BUDGET - secondValue.length);
|
|
2294
|
-
const { blocks, warnings } = toSlackBlocks([
|
|
2295
|
-
{ $type: "Table", columns: [{ label: "A" }], rows: [[filler]] },
|
|
2296
|
-
{ $type: "Table", columns: [{ label: "B" }], rows: [[secondValue]] },
|
|
2297
|
-
]);
|
|
2298
|
-
const first = blocks[0] as SlackDataTableBlock;
|
|
2299
|
-
const second = blocks[1] as SlackDataTableBlock;
|
|
2300
|
-
expect(first.rows).toEqual([
|
|
2301
|
-
[{ type: "raw_text", text: "A" }],
|
|
2302
|
-
[{ type: "raw_text", text: filler }],
|
|
2303
|
-
]);
|
|
2304
|
-
expect(second.rows).toEqual([[{ type: "raw_text", text: "B" }]]);
|
|
2305
|
-
expect(warnings).toContainEqual({
|
|
2306
|
-
code: "clamped",
|
|
2307
|
-
component: "Table",
|
|
2308
|
-
detail: `rows were clamped to fit the ${DATA_TABLE_CHAR_BUDGET}-character table budget.`,
|
|
2309
|
-
});
|
|
2310
|
-
});
|
|
2311
|
-
|
|
2312
|
-
it("synthesizes an empty-cell header row matching the data width when rows are given without columns", () => {
|
|
2313
|
-
const { blocks } = toSlackBlocks({
|
|
2314
|
-
$type: "Table",
|
|
2315
|
-
rows: [
|
|
2316
|
-
["Ada", 36],
|
|
2317
|
-
["Bob", 24],
|
|
2318
|
-
],
|
|
2319
|
-
});
|
|
2320
|
-
const table = blocks[0] as SlackDataTableBlock;
|
|
2321
|
-
expect(table.rows[0]).toEqual([
|
|
2322
|
-
{ type: "raw_text", text: "" },
|
|
2323
|
-
{ type: "raw_text", text: "" },
|
|
2324
|
-
]);
|
|
2325
|
-
expect(table.rows).toEqual([
|
|
2326
|
-
table.rows[0],
|
|
2327
|
-
[
|
|
2328
|
-
{ type: "raw_text", text: "Ada" },
|
|
2329
|
-
{ type: "raw_number", text: "36" },
|
|
2330
|
-
],
|
|
2331
|
-
[
|
|
2332
|
-
{ type: "raw_text", text: "Bob" },
|
|
2333
|
-
{ type: "raw_number", text: "24" },
|
|
2334
|
-
],
|
|
2335
|
-
]);
|
|
2336
|
-
});
|
|
2337
|
-
|
|
2338
|
-
it(`counts the header row's own cell text toward the ${DATA_TABLE_CHAR_BUDGET}-character budget`, () => {
|
|
2339
|
-
const bigHeader = "H".repeat(DATA_TABLE_CHAR_BUDGET - 1);
|
|
2340
|
-
const rows = Array.from({ length: 50 }, () => ["y"]);
|
|
2341
|
-
const { blocks } = toSlackBlocks({
|
|
2342
|
-
$type: "Table",
|
|
2343
|
-
columns: [{ label: bigHeader }],
|
|
2344
|
-
rows,
|
|
2345
|
-
});
|
|
2346
|
-
const table = blocks[0] as SlackDataTableBlock;
|
|
2347
|
-
expect(table.rows).toHaveLength(2);
|
|
2348
|
-
});
|
|
2349
|
-
|
|
2350
|
-
it("emits Slack's documented ceiling of 200 data rows unclamped", () => {
|
|
2351
|
-
const { blocks, warnings } = toSlackBlocks({
|
|
2352
|
-
$type: "Table",
|
|
2353
|
-
columns: [{ label: "n" }],
|
|
2354
|
-
rows: Array.from({ length: 200 }, (_, r) => [String(r)]),
|
|
2355
|
-
});
|
|
2356
|
-
const table = blocks[0] as SlackDataTableBlock;
|
|
2357
|
-
expect(table.rows).toHaveLength(201);
|
|
2358
|
-
expect(warnings).toEqual([]);
|
|
2359
|
-
});
|
|
2360
|
-
|
|
2361
|
-
it("emits Slack's documented ceiling of 20,000 characters and clamps at 20,001", () => {
|
|
2362
|
-
const atBudget = toSlackBlocks({
|
|
2363
|
-
$type: "Table",
|
|
2364
|
-
columns: [{ label: "h" }],
|
|
2365
|
-
rows: [["c".repeat(19999)]],
|
|
2366
|
-
});
|
|
2367
|
-
expect((atBudget.blocks[0] as SlackDataTableBlock).rows).toHaveLength(2);
|
|
2368
|
-
expect(atBudget.warnings).toEqual([]);
|
|
2369
|
-
|
|
2370
|
-
const overBudget = toSlackBlocks({
|
|
2371
|
-
$type: "Table",
|
|
2372
|
-
columns: [{ label: "h" }],
|
|
2373
|
-
rows: [["c".repeat(20000)]],
|
|
2374
|
-
});
|
|
2375
|
-
expect((overBudget.blocks[0] as SlackDataTableBlock).rows).toHaveLength(
|
|
2376
|
-
1,
|
|
2377
|
-
);
|
|
2378
|
-
expect(overBudget.warnings).toContainEqual({
|
|
2379
|
-
code: "clamped",
|
|
2380
|
-
component: "Table",
|
|
2381
|
-
detail: "rows were clamped to fit the 20000-character table budget.",
|
|
2382
|
-
});
|
|
2383
|
-
});
|
|
2384
|
-
});
|
|
2385
|
-
|
|
2386
|
-
describe("Markdown", () => {
|
|
2387
|
-
it("converts to a markdown block", () => {
|
|
2388
|
-
const { blocks, warnings } = toSlackBlocks({
|
|
2389
|
-
$type: "Markdown",
|
|
2390
|
-
value: "# Title",
|
|
2391
|
-
});
|
|
2392
|
-
expect(blocks).toEqual([{ type: "markdown", text: "# Title" }]);
|
|
2393
|
-
expect(warnings).toEqual([]);
|
|
2394
|
-
});
|
|
2395
|
-
|
|
2396
|
-
it(`falls back to a section once the cumulative ${MARKDOWN_TEXT_BUDGET}-character budget is exceeded`, () => {
|
|
2397
|
-
const first = "a".repeat(MARKDOWN_TEXT_BUDGET - 1000);
|
|
2398
|
-
const second = "b".repeat(2000);
|
|
2399
|
-
const third = "c".repeat(10);
|
|
2400
|
-
const { blocks, warnings } = toSlackBlocks([
|
|
2401
|
-
{ $type: "Markdown", value: first },
|
|
2402
|
-
{ $type: "Markdown", value: second },
|
|
2403
|
-
{ $type: "Markdown", value: third },
|
|
2404
|
-
]);
|
|
2405
|
-
expect(blocks[0]).toEqual({ type: "markdown", text: first });
|
|
2406
|
-
expect(blocks[1]).toEqual({
|
|
2407
|
-
type: "section",
|
|
2408
|
-
text: { type: "mrkdwn", text: second },
|
|
2409
|
-
});
|
|
2410
|
-
expect(blocks[2]).toEqual({
|
|
2411
|
-
type: "section",
|
|
2412
|
-
text: { type: "mrkdwn", text: third },
|
|
2413
|
-
});
|
|
2414
|
-
expect(warnings.filter((w) => w.component === "Markdown")).toHaveLength(
|
|
2415
|
-
2,
|
|
2416
|
-
);
|
|
2417
|
-
expect(warnings).toContainEqual({
|
|
2418
|
-
code: "fallback",
|
|
2419
|
-
component: "Markdown",
|
|
2420
|
-
detail: `The ${MARKDOWN_TEXT_BUDGET}-character markdown budget was exceeded.`,
|
|
2421
|
-
});
|
|
2422
|
-
});
|
|
2423
|
-
});
|
|
2424
|
-
|
|
2425
|
-
describe("Chart", () => {
|
|
2426
|
-
it("is dropped and replaced by an omission note", () => {
|
|
2427
|
-
const { blocks, warnings } = toSlackBlocks({
|
|
2428
|
-
$type: "Chart",
|
|
2429
|
-
variant: "bar",
|
|
2430
|
-
data: [{ value: 1 }],
|
|
2431
|
-
});
|
|
2432
|
-
expect(blocks).toEqual([
|
|
2433
|
-
{
|
|
2434
|
-
type: "context",
|
|
2435
|
-
elements: [{ type: "mrkdwn", text: "Chart omitted on Slack." }],
|
|
2436
|
-
},
|
|
2437
|
-
]);
|
|
2438
|
-
expect(warnings).toEqual([
|
|
2439
|
-
{
|
|
2440
|
-
code: "dropped",
|
|
2441
|
-
component: "Chart",
|
|
2442
|
-
detail: "Chart was replaced by a Slack omission note.",
|
|
2443
|
-
},
|
|
2444
|
-
]);
|
|
2445
|
-
});
|
|
2446
|
-
});
|
|
2447
|
-
|
|
2448
|
-
describe("ListView and ListViewItem", () => {
|
|
2449
|
-
it("forwards a discarded child's own dropped warning and counts it", () => {
|
|
2450
|
-
const { warnings } = toSlackBlocks({
|
|
2451
|
-
$type: "ListView",
|
|
2452
|
-
children: [
|
|
2453
|
-
{ $type: "Mystery" },
|
|
2454
|
-
{ $type: "ListViewItem", children: { $type: "Text", value: "row" } },
|
|
2455
|
-
],
|
|
2456
|
-
});
|
|
2457
|
-
expect(warnings).toContainEqual({
|
|
2458
|
-
code: "dropped",
|
|
2459
|
-
component: "Mystery",
|
|
2460
|
-
detail: "Unknown component type was dropped.",
|
|
2461
|
-
});
|
|
2462
|
-
expect(warnings).toContainEqual({
|
|
2463
|
-
code: "dropped",
|
|
2464
|
-
component: "ListView",
|
|
2465
|
-
detail: "1 non-item child was dropped.",
|
|
2466
|
-
});
|
|
2467
|
-
});
|
|
2468
|
-
|
|
2469
|
-
it("does not let a discarded child spend the markdown budget", () => {
|
|
2470
|
-
const { blocks, warnings } = toSlackBlocks([
|
|
2471
|
-
{
|
|
2472
|
-
$type: "ListView",
|
|
2473
|
-
children: [
|
|
2474
|
-
{ $type: "Markdown", value: "x".repeat(MARKDOWN_TEXT_BUDGET) },
|
|
2475
|
-
{
|
|
2476
|
-
$type: "ListViewItem",
|
|
2477
|
-
children: { $type: "Text", value: "row" },
|
|
2478
|
-
},
|
|
2479
|
-
],
|
|
2480
|
-
},
|
|
2481
|
-
{ $type: "Markdown", value: "**real**" },
|
|
2482
|
-
]);
|
|
2483
|
-
expect(blocks[blocks.length - 1]).toEqual({
|
|
2484
|
-
type: "markdown",
|
|
2485
|
-
text: "**real**",
|
|
2486
|
-
});
|
|
2487
|
-
expect(warnings.some((warning) => warning.component === "Markdown")).toBe(
|
|
2488
|
-
false,
|
|
2489
|
-
);
|
|
2490
|
-
});
|
|
2491
|
-
|
|
2492
|
-
it("reports a non-item child it filters out", () => {
|
|
2493
|
-
const { blocks, warnings } = toSlackBlocks({
|
|
2494
|
-
$type: "ListView",
|
|
2495
|
-
children: [
|
|
2496
|
-
{ $type: "Text", value: "stray" },
|
|
2497
|
-
{ $type: "ListViewItem", children: { $type: "Text", value: "Kept" } },
|
|
2498
|
-
],
|
|
2499
|
-
});
|
|
2500
|
-
expect(blocks).toHaveLength(1);
|
|
2501
|
-
expect(warnings).toContainEqual({
|
|
2502
|
-
code: "dropped",
|
|
2503
|
-
component: "ListView",
|
|
2504
|
-
detail: "1 non-item child was dropped.",
|
|
2505
|
-
});
|
|
2506
|
-
});
|
|
2507
|
-
|
|
2508
|
-
it("renders each item as a section, separated by dividers", () => {
|
|
2509
|
-
const { blocks } = toSlackBlocks({
|
|
2510
|
-
$type: "ListView",
|
|
2511
|
-
children: [
|
|
2512
|
-
{
|
|
2513
|
-
$type: "ListViewItem",
|
|
2514
|
-
children: { $type: "Text", value: "Row 1" },
|
|
2515
|
-
},
|
|
2516
|
-
{
|
|
2517
|
-
$type: "ListViewItem",
|
|
2518
|
-
children: { $type: "Text", value: "Row 2" },
|
|
2519
|
-
},
|
|
2520
|
-
],
|
|
2521
|
-
});
|
|
2522
|
-
expect(blocks).toEqual([
|
|
2523
|
-
{ type: "section", text: { type: "mrkdwn", text: "Row 1" } },
|
|
2524
|
-
{ type: "divider" },
|
|
2525
|
-
{ type: "section", text: { type: "mrkdwn", text: "Row 2" } },
|
|
2526
|
-
]);
|
|
2527
|
-
});
|
|
2528
|
-
|
|
2529
|
-
it("turns an item's $action into a button accessory", () => {
|
|
2530
|
-
const { blocks } = toSlackBlocks({
|
|
2531
|
-
$type: "ListView",
|
|
2532
|
-
children: [
|
|
2533
|
-
{
|
|
2534
|
-
$type: "ListViewItem",
|
|
2535
|
-
$action: { type: "open_row" },
|
|
2536
|
-
children: { $type: "Text", value: "Clickable" },
|
|
2537
|
-
},
|
|
2538
|
-
],
|
|
2539
|
-
});
|
|
2540
|
-
expect(blocks).toEqual([
|
|
2541
|
-
{
|
|
2542
|
-
type: "section",
|
|
2543
|
-
text: { type: "mrkdwn", text: "Clickable" },
|
|
2544
|
-
accessory: {
|
|
2545
|
-
type: "button",
|
|
2546
|
-
text: { type: "plain_text", text: "Open" },
|
|
2547
|
-
action_id: "open_row",
|
|
2548
|
-
},
|
|
2549
|
-
},
|
|
2550
|
-
]);
|
|
2551
|
-
});
|
|
2552
|
-
});
|
|
2553
|
-
|
|
2554
|
-
describe("Form", () => {
|
|
2555
|
-
it("converts children normally and appends a submit actions block", () => {
|
|
2556
|
-
const { blocks } = toSlackBlocks({
|
|
2557
|
-
$type: "Form",
|
|
2558
|
-
$action: { type: "submit_signup" },
|
|
2559
|
-
children: [{ $type: "Input", label: "Name", name: "name" }],
|
|
2560
|
-
});
|
|
2561
|
-
expect(blocks).toEqual([
|
|
2562
|
-
{
|
|
2563
|
-
type: "input",
|
|
2564
|
-
block_id: `aui:0:${JSON.stringify([["action", "name"]])}`,
|
|
2565
|
-
label: { type: "plain_text", text: "Name" },
|
|
2566
|
-
element: { type: "plain_text_input", action_id: "action" },
|
|
2567
|
-
},
|
|
2568
|
-
{
|
|
2569
|
-
type: "actions",
|
|
2570
|
-
elements: [
|
|
2571
|
-
{
|
|
2572
|
-
type: "button",
|
|
2573
|
-
text: { type: "plain_text", text: "Submit" },
|
|
2574
|
-
action_id: "submit_signup",
|
|
2575
|
-
style: "primary",
|
|
2576
|
-
},
|
|
2577
|
-
],
|
|
2578
|
-
},
|
|
2579
|
-
]);
|
|
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
|
-
});
|
|
2711
|
-
});
|
|
2712
|
-
|
|
2713
|
-
describe("Root", () => {
|
|
2714
|
-
it("converts a root array of mixed components sequentially", () => {
|
|
2715
|
-
const { blocks } = toSlackBlocks([
|
|
2716
|
-
{ $type: "Header", text: "Title" },
|
|
2717
|
-
{ $type: "Divider" },
|
|
2718
|
-
{ $type: "Text", value: "Body" },
|
|
2719
|
-
]);
|
|
2720
|
-
expect(blocks).toEqual([
|
|
2721
|
-
{ type: "header", text: { type: "plain_text", text: "Title" } },
|
|
2722
|
-
{ type: "divider" },
|
|
2723
|
-
{ type: "section", text: { type: "mrkdwn", text: "Body" } },
|
|
2724
|
-
]);
|
|
2725
|
-
});
|
|
2726
|
-
|
|
2727
|
-
it("clamps to the surface cap and appends an omission note", () => {
|
|
2728
|
-
const count = MESSAGE_BLOCK_CAP + 5;
|
|
2729
|
-
const nodes = Array.from({ length: count }, (_, i) => ({
|
|
2730
|
-
$type: "Header",
|
|
2731
|
-
text: `H${i}`,
|
|
2732
|
-
}));
|
|
2733
|
-
const { blocks, warnings } = toSlackBlocks(nodes);
|
|
2734
|
-
expect(blocks).toHaveLength(MESSAGE_BLOCK_CAP);
|
|
2735
|
-
const omitted = count - (MESSAGE_BLOCK_CAP - 1);
|
|
2736
|
-
expect(blocks[blocks.length - 1]).toEqual({
|
|
2737
|
-
type: "context",
|
|
2738
|
-
elements: [{ type: "mrkdwn", text: `${omitted} blocks were omitted.` }],
|
|
2739
|
-
});
|
|
2740
|
-
expect(warnings).toContainEqual({
|
|
2741
|
-
code: "clamped",
|
|
2742
|
-
component: "Root",
|
|
2743
|
-
detail: `${omitted} blocks were omitted.`,
|
|
2744
|
-
});
|
|
2745
|
-
});
|
|
2746
|
-
|
|
2747
|
-
it("uses a 100-block cap for the modal surface", () => {
|
|
2748
|
-
const count = MODAL_BLOCK_CAP + 3;
|
|
2749
|
-
const nodes = Array.from({ length: count }, (_, i) => ({
|
|
2750
|
-
$type: "Header",
|
|
2751
|
-
text: `H${i}`,
|
|
2752
|
-
}));
|
|
2753
|
-
const { blocks } = toSlackBlocks(nodes, { surface: "modal" });
|
|
2754
|
-
expect(blocks).toHaveLength(MODAL_BLOCK_CAP);
|
|
2755
|
-
});
|
|
2756
|
-
|
|
2757
|
-
it("does not clamp when under budget", () => {
|
|
2758
|
-
const { blocks, warnings } = toSlackBlocks([
|
|
2759
|
-
{ $type: "Header", text: "One" },
|
|
2760
|
-
]);
|
|
2761
|
-
expect(blocks).toHaveLength(1);
|
|
2762
|
-
expect(warnings).toEqual([]);
|
|
2763
|
-
});
|
|
2764
|
-
});
|
|
2765
|
-
|
|
2766
|
-
describe("bare children", () => {
|
|
2767
|
-
it("strings and numbers inside a flattened container become section mrkdwn blocks", () => {
|
|
2768
|
-
const { blocks } = toSlackBlocks({
|
|
2769
|
-
$type: "Col",
|
|
2770
|
-
children: ["hello", 42],
|
|
2771
|
-
});
|
|
2772
|
-
expect(blocks).toEqual([
|
|
2773
|
-
{ type: "section", text: { type: "mrkdwn", text: "hello" } },
|
|
2774
|
-
{ type: "section", text: { type: "mrkdwn", text: "42" } },
|
|
2775
|
-
]);
|
|
2776
|
-
});
|
|
2777
|
-
});
|
|
2778
|
-
|
|
2779
|
-
describe("unknown component types", () => {
|
|
2780
|
-
it("are skipped with a dropped warning", () => {
|
|
2781
|
-
const { blocks, warnings } = toSlackBlocks({
|
|
2782
|
-
$type: "TotallyMadeUp",
|
|
2783
|
-
foo: "bar",
|
|
2784
|
-
});
|
|
2785
|
-
expect(blocks).toEqual([]);
|
|
2786
|
-
expect(warnings).toEqual([
|
|
2787
|
-
{
|
|
2788
|
-
code: "dropped",
|
|
2789
|
-
component: "TotallyMadeUp",
|
|
2790
|
-
detail: "Unknown component type was dropped.",
|
|
2791
|
-
},
|
|
2792
|
-
]);
|
|
2793
|
-
});
|
|
2794
|
-
});
|
|
2795
|
-
|
|
2796
|
-
describe("depth bound", () => {
|
|
2797
|
-
it("does not throw on 65 levels of nesting", () => {
|
|
2798
|
-
let node: unknown = { $type: "Text", value: "deep" };
|
|
2799
|
-
for (let i = 0; i < 65; i++) {
|
|
2800
|
-
node = { $type: "Col", children: node };
|
|
2801
|
-
}
|
|
2802
|
-
expect(() => toSlackBlocks(node)).not.toThrow();
|
|
2803
|
-
const { blocks } = toSlackBlocks(node);
|
|
2804
|
-
expect(Array.isArray(blocks)).toBe(true);
|
|
2805
|
-
});
|
|
2806
|
-
});
|
|
2807
|
-
|
|
2808
|
-
describe("never throws", () => {
|
|
2809
|
-
it("returns a well-formed result instead of throwing for degenerate input", () => {
|
|
2810
|
-
const degenerateInputs: unknown[] = [
|
|
2811
|
-
null,
|
|
2812
|
-
undefined,
|
|
2813
|
-
42,
|
|
2814
|
-
"just text",
|
|
2815
|
-
true,
|
|
2816
|
-
false,
|
|
2817
|
-
[],
|
|
2818
|
-
{},
|
|
2819
|
-
() => {},
|
|
2820
|
-
Symbol("x"),
|
|
2821
|
-
];
|
|
2822
|
-
for (const input of degenerateInputs) {
|
|
2823
|
-
const result = toSlackBlocks(input);
|
|
2824
|
-
expect(Array.isArray(result.blocks)).toBe(true);
|
|
2825
|
-
expect(Array.isArray(result.warnings)).toBe(true);
|
|
2826
|
-
}
|
|
2827
|
-
});
|
|
2828
|
-
|
|
2829
|
-
it("bounds a hostile root-level Proxy array with a fabricated length instead of hanging", () => {
|
|
2830
|
-
const hostileRoot = new Proxy([], {
|
|
2831
|
-
get(target, prop) {
|
|
2832
|
-
if (prop === "length") return 2 ** 31;
|
|
2833
|
-
if (typeof prop === "string" && /^\d+$/.test(prop)) {
|
|
2834
|
-
return { $type: "Text", value: "x" };
|
|
2835
|
-
}
|
|
2836
|
-
return Reflect.get(target, prop);
|
|
2837
|
-
},
|
|
2838
|
-
has(target, prop) {
|
|
2839
|
-
if (prop === "length") return true;
|
|
2840
|
-
if (typeof prop === "string" && /^\d+$/.test(prop)) return true;
|
|
2841
|
-
return Reflect.has(target, prop);
|
|
2842
|
-
},
|
|
2843
|
-
});
|
|
2844
|
-
const { blocks, warnings } = toSlackBlocks(hostileRoot);
|
|
2845
|
-
expect(Array.isArray(blocks)).toBe(true);
|
|
2846
|
-
expect(warnings).toContainEqual(
|
|
2847
|
-
expect.objectContaining({ code: "clamped", component: "Root" }),
|
|
2848
|
-
);
|
|
2849
|
-
});
|
|
2850
|
-
|
|
2851
|
-
it("omits the button value instead of throwing when the action payload is circular", () => {
|
|
2852
|
-
const circular: Record<string, unknown> = { type: "loop" };
|
|
2853
|
-
circular["self"] = circular;
|
|
2854
|
-
expect(() =>
|
|
2855
|
-
toSlackBlocks({ $type: "Button", label: "Go", $action: circular }),
|
|
2856
|
-
).not.toThrow();
|
|
2857
|
-
const { blocks, warnings } = toSlackBlocks({
|
|
2858
|
-
$type: "Button",
|
|
2859
|
-
label: "Go",
|
|
2860
|
-
$action: circular,
|
|
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
|
-
]);
|
|
2870
|
-
expect(blocks[0]).toEqual({
|
|
2871
|
-
type: "actions",
|
|
2872
|
-
elements: [
|
|
2873
|
-
{
|
|
2874
|
-
type: "button",
|
|
2875
|
-
text: { type: "plain_text", text: "Go" },
|
|
2876
|
-
action_id: "loop",
|
|
2877
|
-
},
|
|
2878
|
-
],
|
|
2879
|
-
});
|
|
2880
|
-
});
|
|
2881
|
-
});
|
|
2882
|
-
|
|
2883
|
-
describe("node budget and cycle guard", () => {
|
|
2884
|
-
it("bounds a self-referential children array instead of looping forever", () => {
|
|
2885
|
-
const arr: unknown[] = [];
|
|
2886
|
-
const el = { $type: "Card", children: arr };
|
|
2887
|
-
arr.push(el);
|
|
2888
|
-
const { warnings } = toSlackBlocks(el);
|
|
2889
|
-
expect(warnings).toContainEqual({
|
|
2890
|
-
code: "clamped",
|
|
2891
|
-
component: "Root",
|
|
2892
|
-
detail: "a self-referencing node was dropped.",
|
|
2893
|
-
});
|
|
2894
|
-
});
|
|
2895
|
-
|
|
2896
|
-
it("bounds a shared-reference fan-out across nested levels with a budget warning instead of doing exponential work", () => {
|
|
2897
|
-
let shared: unknown = { $type: "Text", value: "leaf" };
|
|
2898
|
-
for (let level = 0; level < 3; level++) {
|
|
2899
|
-
shared = {
|
|
2900
|
-
$type: "Card",
|
|
2901
|
-
children: Array.from({ length: 200 }, () => shared),
|
|
2902
|
-
};
|
|
2903
|
-
}
|
|
2904
|
-
const { warnings } = toSlackBlocks(shared);
|
|
2905
|
-
expect(warnings).toContainEqual({
|
|
2906
|
-
code: "clamped",
|
|
2907
|
-
component: "Root",
|
|
2908
|
-
detail: `the tree was truncated after ${NODE_BUDGET} nodes.`,
|
|
2909
|
-
});
|
|
2910
|
-
});
|
|
2911
|
-
|
|
2912
|
-
it("clamps a benign tree past the node budget with exactly one budget warning", () => {
|
|
2913
|
-
const rows = Array.from({ length: 60 }, (_, r) => ({
|
|
2914
|
-
$type: "Col",
|
|
2915
|
-
children: Array.from({ length: 100 }, (_, c) => ({
|
|
2916
|
-
$type: "Caption",
|
|
2917
|
-
value: `r${r}c${c}`,
|
|
2918
|
-
})),
|
|
2919
|
-
}));
|
|
2920
|
-
const { blocks, warnings } = toSlackBlocks(rows);
|
|
2921
|
-
expect(blocks.length).toBeLessThan(6000);
|
|
2922
|
-
const budgetWarnings = warnings.filter(
|
|
2923
|
-
(warning) =>
|
|
2924
|
-
warning.code === "clamped" &&
|
|
2925
|
-
warning.component === "Root" &&
|
|
2926
|
-
warning.detail ===
|
|
2927
|
-
`the tree was truncated after ${NODE_BUDGET} nodes.`,
|
|
2928
|
-
);
|
|
2929
|
-
expect(budgetWarnings).toHaveLength(1);
|
|
2930
|
-
});
|
|
2931
|
-
|
|
2932
|
-
it("clamps a shared-reference array past the children cap without treating the reuse as a cycle", () => {
|
|
2933
|
-
const card = { $type: "Card", title: "shared" };
|
|
2934
|
-
let node: unknown = Array.from({ length: 201 }, () => card);
|
|
2935
|
-
for (let level = 0; level < 3; level++) {
|
|
2936
|
-
node = { $type: "Col", children: node };
|
|
2937
|
-
}
|
|
2938
|
-
const { warnings } = toSlackBlocks(node);
|
|
2939
|
-
expect(warnings).toContainEqual({
|
|
2940
|
-
code: "clamped",
|
|
2941
|
-
component: "Root",
|
|
2942
|
-
detail: `children were clamped to ${CHILDREN_CAP} entries.`,
|
|
2943
|
-
});
|
|
2944
|
-
expect(
|
|
2945
|
-
warnings.some((warning) => warning.detail.includes("self-referencing")),
|
|
2946
|
-
).toBe(false);
|
|
2947
|
-
});
|
|
2948
|
-
|
|
2949
|
-
it("converts a 70-level-deep chain successfully and reports the depth detail", () => {
|
|
2950
|
-
let node: unknown = { $type: "Caption", value: "x" };
|
|
2951
|
-
for (let i = 0; i < 70; i++) {
|
|
2952
|
-
node = { $type: "Card", children: [node] };
|
|
2953
|
-
}
|
|
2954
|
-
expect(() => toSlackBlocks(node)).not.toThrow();
|
|
2955
|
-
const { warnings } = toSlackBlocks(node);
|
|
2956
|
-
expect(warnings).toContainEqual({
|
|
2957
|
-
code: "clamped",
|
|
2958
|
-
component: "Root",
|
|
2959
|
-
detail: "nodes deeper than 32 levels were dropped.",
|
|
2960
|
-
});
|
|
2961
|
-
});
|
|
2962
|
-
|
|
2963
|
-
it("reports the depth detail at the level it actually starts dropping", () => {
|
|
2964
|
-
const chain = (levels: number) => {
|
|
2965
|
-
let node: unknown = { $type: "Caption", value: "x" };
|
|
2966
|
-
for (let i = 0; i < levels; i++) {
|
|
2967
|
-
node = { $type: "Card", children: [node] };
|
|
2968
|
-
}
|
|
2969
|
-
return node;
|
|
2970
|
-
};
|
|
2971
|
-
const dropped = (node: unknown) =>
|
|
2972
|
-
toSlackBlocks(node).warnings.some((warning) =>
|
|
2973
|
-
warning.detail.includes("deeper than"),
|
|
2974
|
-
);
|
|
2975
|
-
expect(dropped(chain(32))).toBe(false);
|
|
2976
|
-
expect(dropped(chain(33))).toBe(true);
|
|
2977
|
-
expect(dropped([chain(31)])).toBe(false);
|
|
2978
|
-
expect(dropped([chain(32)])).toBe(true);
|
|
2979
|
-
});
|
|
2980
|
-
});
|
|
2981
|
-
});
|
|
2982
|
-
|
|
2983
|
-
describe("toSlackBlocks data_table integrity", () => {
|
|
2984
|
-
it("preserves cell positions when a value is unsupported", () => {
|
|
2985
|
-
const { blocks } = toSlackBlocks({
|
|
2986
|
-
$type: "Table",
|
|
2987
|
-
columns: [{ label: "A" }, { label: "B" }],
|
|
2988
|
-
rows: [[{ nested: true }, "kept"]],
|
|
2989
|
-
});
|
|
2990
|
-
const table = blocks[0];
|
|
2991
|
-
if (table?.type !== "data_table") {
|
|
2992
|
-
throw new Error("Expected a data table block.");
|
|
2993
|
-
}
|
|
2994
|
-
expect(table.rows[1]).toEqual([
|
|
2995
|
-
{ type: "raw_text", text: "" },
|
|
2996
|
-
{ type: "raw_text", text: "kept" },
|
|
2997
|
-
]);
|
|
2998
|
-
});
|
|
2999
|
-
|
|
3000
|
-
it("pads ragged rows to a uniform width", () => {
|
|
3001
|
-
const { blocks } = toSlackBlocks({
|
|
3002
|
-
$type: "Table",
|
|
3003
|
-
columns: [{ label: "A" }, { label: "B" }, { label: "C" }],
|
|
3004
|
-
rows: [["x"], ["x", "y", "z"]],
|
|
3005
|
-
});
|
|
3006
|
-
const table = blocks[0];
|
|
3007
|
-
if (table?.type !== "data_table") {
|
|
3008
|
-
throw new Error("Expected a data table block.");
|
|
3009
|
-
}
|
|
3010
|
-
expect(new Set(table.rows.map((row) => row.length))).toEqual(new Set([3]));
|
|
3011
|
-
});
|
|
3012
|
-
|
|
3013
|
-
it("drops a table whose header row alone exceeds the character budget", () => {
|
|
3014
|
-
const { blocks, warnings } = toSlackBlocks({
|
|
3015
|
-
$type: "Table",
|
|
3016
|
-
columns: [{ label: "h".repeat(DATA_TABLE_CHAR_BUDGET + 1) }],
|
|
3017
|
-
rows: [["x"]],
|
|
3018
|
-
});
|
|
3019
|
-
expect(blocks).toEqual([]);
|
|
3020
|
-
expect(
|
|
3021
|
-
warnings.some(
|
|
3022
|
-
(warning) =>
|
|
3023
|
-
warning.component === "Table" && warning.code === "dropped",
|
|
3024
|
-
),
|
|
3025
|
-
).toBe(true);
|
|
3026
|
-
});
|
|
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
|
-
});
|