Skip to content

Commit 30ae386

Browse files
committed
fix(tools): restore the example order, place the button last, add a pie
The `input` example was moved ahead of `actions` on a misreading: the product owner never asked for the two examples to be reordered. The agreed order is restored — `actions` at 11, `input` at 12 — and the reasoning recorded for the swap is removed from both files, since it was never a decision. The real complaint was the button's position *inside* the actions block. It came first, so it rendered to the left of the reviewer select and the date picker. It is now last of the three, and the row reads as choose, choose, act. Elements render in array order, so a test pins the order rather than the comment alone. The video example previewed Blender's "Big Buck Bunny" with a nature photograph, so the still promised something the video does not contain. The thumbnail is now YouTube's own frame for that same video — the URL YouTube's oEmbed response names as its `thumbnail_url`, verified reachable before use, and confirmed live since Slack fetches it at post time and would otherwise refuse the message whole. The title and alt text name the film. The `data_visualization` example gains a pie chart under the line chart, so it shows Slack drawing more than one kind: a trend over time and a share of a whole. The heading and description are rewritten around both. A pie payload is `segments` with no `axis_config`, which the header documentation now records. Page 1 measures 48 blocks against Slack's ceiling of 50, verified live rather than assumed, and the pinned count moves with it.
1 parent 653c528 commit 30ae386

2 files changed

Lines changed: 168 additions & 63 deletions

File tree

app/tools/__tests__/block-catalog.test.tsx

Lines changed: 76 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -104,24 +104,22 @@ function fakeInteraction(stored: Partial<CatalogState> = {}) {
104104
const HEADING_LINE = /^(\P{ASCII}) \*(.+?)\* \(`/u;
105105

106106
/**
107-
* The fourteen examples, in the order the message presents them. The input
108-
* example precedes the actions one deliberately, and against the order first
109-
* agreed: with the button above the fields the two read as a single form whose
110-
* submit control has slipped to the top.
107+
* The fourteen examples, in the order the message presents them — the order
108+
* agreed with the product owner, `actions` before `input`.
111109
*/
112110
const THE_14_HEADINGS = [
113111
"A status update the team can skim",
114112
"Formatted writing with lists, a quote and code",
115113
"Collapse the long stuff so it does not flood the channel",
116114
"Numbers side by side",
117115
"A long list you can page through",
118-
"A trend at a glance",
116+
"A trend over time, then a share of the whole",
119117
"An image in the post",
120118
"A video that plays in place",
121119
"A card with a title, image and text",
122120
"Several cards to browse sideways",
123-
"A small input right in the channel",
124121
"Act without leaving Slack",
122+
"A small input right in the channel",
125123
"Give feedback on a result",
126124
"The agent at work",
127125
];
@@ -301,16 +299,28 @@ describe("showcase_all_slack_blocks_catalog", () => {
301299
]);
302300
});
303301

304-
it("gives the line chart enough points to show a shape", () => {
305-
const chart = catalogBlocks().find(
302+
/** The line chart and then the pie, in the order the example renders them. */
303+
const charts = () =>
304+
catalogBlocks().filter(
306305
(block) => block.type === "data_visualization",
307-
) as { title?: string; chart?: Record<string, never> };
308-
const { series, axis_config } = chart.chart as unknown as {
306+
) as unknown as { title: string; chart: Record<string, unknown> }[];
307+
308+
it("draws two kinds of chart, a line and then a pie", () => {
309+
// The point of the example is that Slack draws more than one kind, so the
310+
// pair is pinned rather than just the presence of a chart.
311+
expect(charts().map(({ chart }) => chart.type)).toEqual(["line", "pie"]);
312+
for (const { title } of charts()) {
313+
expect(title.length).toBeLessThanOrEqual(50);
314+
}
315+
});
316+
317+
it("gives the line chart enough points to show a shape", () => {
318+
const [line] = charts();
319+
const { series, axis_config } = line!.chart as unknown as {
309320
series: { name: string; data: { label: string; value: number }[] }[];
310321
axis_config: { categories: string[] };
311322
};
312323

313-
expect(chart.title?.length).toBeLessThanOrEqual(50);
314324
expect(axis_config.categories.length).toBeGreaterThanOrEqual(10);
315325
expect(new Set(axis_config.categories).size).toBe(
316326
axis_config.categories.length,
@@ -325,6 +335,60 @@ describe("showcase_all_slack_blocks_catalog", () => {
325335
}
326336
});
327337

338+
it("gives the pie segments and no axis config", () => {
339+
const pie = charts()[1]!.chart as unknown as {
340+
segments: { label: string; value: number }[];
341+
axis_config?: unknown;
342+
};
343+
344+
// A pie payload is `segments` alone; the categories rule that governs the
345+
// line chart has nothing to match against here.
346+
expect(pie.axis_config).toBeUndefined();
347+
expect(pie.segments.length).toBeGreaterThan(1);
348+
expect(new Set(pie.segments.map(({ label }) => label)).size).toBe(
349+
pie.segments.length,
350+
);
351+
for (const { label, value } of pie.segments) {
352+
expect(label.length).toBeLessThanOrEqual(20);
353+
expect(value).toBeGreaterThan(0);
354+
}
355+
});
356+
357+
it("puts the button after the two controls it acts on", () => {
358+
// The example's `actions` block, not the navigator's: the navigator holds a
359+
// single button, so the row with three elements is the one under test.
360+
const row = catalogBlocks().find(
361+
(block) =>
362+
block.type === "actions" &&
363+
((block as { elements?: unknown[] }).elements ?? []).length === 3,
364+
) as { elements: { type: string }[] };
365+
366+
// Elements render in array order, so this is what places the button to the
367+
// right of the select and the date picker: choose, choose, then act.
368+
expect(row.elements.map(({ type }) => type)).toEqual([
369+
"static_select",
370+
"datepicker",
371+
"button",
372+
]);
373+
});
374+
375+
it("previews the video with a still of that same video", () => {
376+
const video = catalogBlocks().find(({ type }) => type === "video") as {
377+
title_url: string;
378+
video_url: string;
379+
thumbnail_url: string;
380+
title: { text: string };
381+
};
382+
383+
// A thumbnail of something else makes the preview lie about the content.
384+
// All three URLs name one video id, so they cannot drift apart.
385+
const id = "aqz-KE-bpKQ";
386+
expect(video.title_url).toContain(id);
387+
expect(video.video_url).toContain(id);
388+
expect(video.thumbnail_url).toContain(id);
389+
expect(video.title.text).toContain("Big Buck Bunny");
390+
});
391+
328392
it("keeps table cells literal, with no markdown or links", () => {
329393
const rows = catalogBlocks()
330394
.filter((block) => block.type === "table" || block.type === "data_table")
@@ -373,7 +437,7 @@ describe("the two pages", () => {
373437

374438
expect(tail).toEqual(["divider", "actions", "context"]);
375439
// Page 1 is the fourteen examples plus the navigator, nothing more.
376-
expect(blocks).toHaveLength(44 + NAVIGATOR_BLOCK_COUNT);
440+
expect(blocks).toHaveLength(45 + NAVIGATOR_BLOCK_COUNT);
377441
expect(textOf(blocks.slice(-NAVIGATOR_BLOCK_COUNT))).toContain("Next");
378442
expect(textOf(blocks.slice(-NAVIGATOR_BLOCK_COUNT))).toContain(
379443
"Page 1 of 2",

app/tools/block-catalog.tsx

Lines changed: 92 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,9 @@
77
* real thing rendered live. The order runs everyday → text → data → media →
88
* interaction → agent, so it reads as a demonstration rather than an inventory.
99
*
10-
* Inside the interaction stretch the order is `input` and then `actions`, on the
11-
* product owner's instruction and against the order first agreed. With the
12-
* button above the fields a reader takes the two examples for one form whose
13-
* submit control has slipped to the top; with the fields first the button reads
14-
* as what follows them.
10+
* Inside the interaction stretch the order is `actions` and then `input`: the
11+
* order agreed with the product owner, and the one the pinned heading test
12+
* holds.
1513
*
1614
* Page 2 is one situation carried through: booking a meeting. A form — channel,
1715
* time, length, email — a Confirm button, and a confirmation that reports the
@@ -67,8 +65,8 @@
6765
* date pickers. A multi-select belongs in an `input` block.
6866
* 5. An `input` block in a *message* only dispatches to a handler when
6967
* `dispatch_action` is true.
70-
* 6. Slack fetches image and video URLs at post time; a dead URL refuses the
71-
* whole message. The URL used here is verified.
68+
* 6. Slack fetches image, thumbnail and video URLs at post time; a dead URL
69+
* refuses the whole message. Every URL used here is verified reachable.
7270
* 7. `icon_button` accepts only a few icon names; `trash` is verified.
7371
* 8. `plan.title` is a bare string, not a text object — the opposite of every
7472
* other titled block, and contrary to Slack's own reference.
@@ -82,7 +80,8 @@
8280
* here lets colour carry meaning.
8381
* 13. A `data_visualization` title is capped at 50 characters, series and
8482
* category labels at 20, and every series' point labels must match
85-
* `axis_config.categories` exactly.
83+
* `axis_config.categories` exactly. A `pie` chart is the exception: it
84+
* carries `segments` and no `axis_config`, so there is nothing to match.
8685
* 14. `card` holds a title, a subtitle, a hero image, a body and actions — it
8786
* has no children slot, so it cannot frame arbitrary blocks. `container` is
8887
* the block that groups other blocks, and it is what frames the examples
@@ -127,6 +126,17 @@ const RadioButtons = Slack.Element.RadioButtons as unknown as UntypedNative;
127126
/** Verified reachable at post time; Slack refuses the message if a fetch fails. */
128127
const IMAGE_URL = "https://picsum.photos/id/1015/800/400";
129128

129+
/**
130+
* The video example embeds Blender's "Big Buck Bunny" short film — the clip the
131+
* SDK's own `catalog-fixtures.ts` uses, and the one URL pair proven to survive
132+
* Slack's post-time fetch. Its thumbnail is YouTube's own still for that same
133+
* video, which YouTube's oEmbed response names as the video's `thumbnail_url`,
134+
* so the preview shows the film that plays rather than an unrelated photograph.
135+
* Verified reachable: 200 image/jpeg.
136+
*/
137+
const VIDEO_ID = "aqz-KE-bpKQ";
138+
const VIDEO_THUMBNAIL_URL = `https://i.ytimg.com/vi/${VIDEO_ID}/hqdefault.jpg`;
139+
130140
// ── local text helpers ─────────────────────────────────────────────────────
131141

132142
/** A `mrkdwn` section. `text`, never children — see constraint 9. */
@@ -232,6 +242,19 @@ const RENEWAL_RATE_BY_MONTH: ReadonlyArray<readonly [string, number]> = [
232242
["Dec", 97],
233243
];
234244

245+
/**
246+
* The pie beneath the line, so the example shows Slack drawing more than one
247+
* kind of chart. A pie payload is `segments`, with no `axis_config` — the
248+
* categories rule applies only to bar, area and line. The counts are the ones
249+
* the table example already shows, so the two do not contradict each other, and
250+
* every label is inside the 20-character cap.
251+
*/
252+
const RENEWALS_BY_REGION: ReadonlyArray<readonly [string, number]> = [
253+
["Americas", 214],
254+
["EMEA", 128],
255+
["APAC", 76],
256+
];
257+
235258
const CAROUSEL_CARDS: ReadonlyArray<[string, string, string]> = [
236259
["card-americas", "Americas", "214 renewals · 96% on time"],
237260
["card-emea", "EMEA", "128 renewals · 91% on time"],
@@ -413,13 +436,15 @@ const EXAMPLES: readonly Example[] = [
413436
},
414437
{
415438
emoji: "📈",
416-
heading: "A trend at a glance",
439+
heading: "A trend over time, then a share of the whole",
417440
blocks: ["data_visualization"],
418441
description:
419-
"Slack draws the chart itself, so a run of numbers reads as a shape " +
420-
"instead of a list.",
442+
"Slack draws the charts itself, and more than one kind: a line for how " +
443+
"the renewal rate moved through the year, and a pie underneath it for " +
444+
"how this quarter's renewals divide between the regions.",
421445
specimen: [
422446
<Slack.Block.DataVisualization
447+
key="trend"
423448
title="Renewal rate by month"
424449
chart={{
425450
type: "line",
@@ -439,6 +464,20 @@ const EXAMPLES: readonly Example[] = [
439464
},
440465
}}
441466
/>,
467+
// A second `data_visualization`, a sibling of the first and of every
468+
// container — a chart is never a container child (constraint 2). This is
469+
// the block page 1 grew by, from 47 to 48 against the ceiling of 50.
470+
<Slack.Block.DataVisualization
471+
key="share"
472+
title="Renewals by region this quarter"
473+
chart={{
474+
type: "pie",
475+
segments: RENEWALS_BY_REGION.map(([label, value]) => ({
476+
label,
477+
value,
478+
})),
479+
}}
480+
/>,
442481
],
443482
},
444483
{
@@ -461,14 +500,15 @@ const EXAMPLES: readonly Example[] = [
461500
blocks: ["video"],
462501
description:
463502
"A recording plays in the channel itself, so nobody has to open " +
464-
"another tab to watch it.",
503+
"another tab to watch it. The still above it is the video's own frame, " +
504+
"so the preview shows what will play.",
465505
specimen: [
466506
<Video
467-
alt_text="Recorded walkthrough"
468-
title={Plain("Walkthrough · the renewals workflow")}
469-
title_url="https://www.youtube.com/watch?v=aqz-KE-bpKQ"
470-
thumbnail_url={IMAGE_URL}
471-
video_url="https://www.youtube.com/embed/aqz-KE-bpKQ"
507+
alt_text="Still from Blender's Big Buck Bunny short film"
508+
title={Plain("Big Buck Bunny · Blender Foundation short film")}
509+
title_url={`https://www.youtube.com/watch?v=${VIDEO_ID}`}
510+
thumbnail_url={VIDEO_THUMBNAIL_URL}
511+
video_url={`https://www.youtube.com/embed/${VIDEO_ID}`}
472512
/>,
473513
],
474514
},
@@ -521,47 +561,19 @@ const EXAMPLES: readonly Example[] = [
521561
/>,
522562
],
523563
},
524-
// The fields come before the button on the product owner's instruction — see
525-
// the note at the top of the file. Read the other way round, the two examples
526-
// look like one form with its submit control above the things it submits.
527-
{
528-
emoji: "📥",
529-
heading: "A small input right in the channel",
530-
blocks: ["input"],
531-
description:
532-
"A field in the message itself, so an answer can be given on the spot " +
533-
"instead of in a dialog somewhere else.",
534-
specimen: [
535-
<Input
536-
label={Plain("Teams to include in the review")}
537-
dispatch_action={true}
538-
element={
539-
<Slack.Element.MultiStaticSelect
540-
placeholder={Plain("Pick one or more teams")}
541-
options={TEAMS.map(([label, value]) => option(label, value))}
542-
onSelect={reply("input · multi-select")}
543-
/>
544-
}
545-
/>,
546-
],
547-
},
548564
{
549565
emoji: "🔘",
550566
heading: "Act without leaving Slack",
551567
blocks: ["actions"],
552568
description:
553569
"Buttons, a menu and a date picker sitting under the message, each one " +
554570
"wired to something that actually runs. Try them.",
571+
// The button is last of the three, so the row reads left to right as choose
572+
// a reviewer, choose a date, then act on both. Elements render in array
573+
// order, so this ordering is the only thing that places it.
555574
specimen: [
556575
<Slack.Block.Actions
557576
elements={[
558-
<Slack.Element.Button
559-
key="approve"
560-
style="primary"
561-
text={Plain("Approve renewal")}
562-
value="approve_renewal"
563-
onClick={reply("actions · button")}
564-
/>,
565577
<Slack.Element.StaticSelect
566578
key="reviewer"
567579
placeholder={Plain("Choose a reviewer")}
@@ -574,10 +586,38 @@ const EXAMPLES: readonly Example[] = [
574586
initial_date="2026-09-12"
575587
onSelect={reply("actions · date picker")}
576588
/>,
589+
<Slack.Element.Button
590+
key="approve"
591+
style="primary"
592+
text={Plain("Approve renewal")}
593+
value="approve_renewal"
594+
onClick={reply("actions · button")}
595+
/>,
577596
]}
578597
/>,
579598
],
580599
},
600+
{
601+
emoji: "📥",
602+
heading: "A small input right in the channel",
603+
blocks: ["input"],
604+
description:
605+
"A field in the message itself, so an answer can be given on the spot " +
606+
"instead of in a dialog somewhere else.",
607+
specimen: [
608+
<Input
609+
label={Plain("Teams to include in the review")}
610+
dispatch_action={true}
611+
element={
612+
<Slack.Element.MultiStaticSelect
613+
placeholder={Plain("Pick one or more teams")}
614+
options={TEAMS.map(([label, value]) => option(label, value))}
615+
onSelect={reply("input · multi-select")}
616+
/>
617+
}
618+
/>,
619+
],
620+
},
581621
{
582622
emoji: "👍",
583623
heading: "Give feedback on a result",
@@ -996,11 +1036,12 @@ export function BlockCatalog(state: CatalogState = INITIAL_STATE): ChannelNode {
9961036

9971037
/**
9981038
* The measured size of page 1 with the navigator on it: the fourteen examples'
999-
* 44 blocks plus the rule, the Next button and the page counter. Pinned by a
1039+
* 45 blocks plus the rule, the Next button and the page counter. Pinned by a
10001040
* test against Slack's ceiling of 50, because exceeding it loses the whole
1001-
* message rather than the excess blocks.
1041+
* message rather than the excess blocks. Two blocks of headroom is all that is
1042+
* left, so an example that needs a new block has to trade for one.
10021043
*/
1003-
export const PAGE_1_BLOCK_COUNT = 47;
1044+
export const PAGE_1_BLOCK_COUNT = 48;
10041045

10051046
// Printed at load, not through the logger: it is the one line that answers "is
10061047
// the running process the code I just edited?" before anything is triggered —

0 commit comments

Comments
 (0)