Skip to content

Commit eb45d9e

Browse files
feat: add data_table block support and surface-rule drift check
Reconciles the validator's surface rules with Slack's canonical blocks.json (https://docs.slack.dev/blocks.json) per ENG-5108. - Model the `data_table` block (distinct from `table`): full schema with raw_text / raw_number / rich_text cells, row/column/page_size bounds, plus BLOCK_TYPE_TO_DEF entry, generated types, and tests. - Surface rules: `data_table` is forbidden on modal/home, mirroring `table`'s empirically confirmed messages-only render behavior (blocks.json lists Home tabs, but the table family drops there). - Add test/surface-drift.test.ts: diffs the forbidden-by-surface sets against a committed blocks.json snapshot so the rules can't silently diverge. The four intentional deviations (card/modal, file/message, table/home, data_table/home) are pinned in an explicit allowlist. - Document the card and file deviations inline against blocks.json. - Add a valid data_table demo preset and update README coverage. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent da2f691 commit eb45d9e

10 files changed

Lines changed: 1149 additions & 13 deletions

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -141,7 +141,7 @@ Each returns an array of human-readable error strings — empty when valid.
141141

142142
## Coverage
143143

144-
- **20 blocks**: actions, alert, card, carousel, container, context, context_actions, data_visualization, divider, file, header, image, input, markdown, plan, rich_text, section, table, task_card, video.
144+
- **21 blocks**: actions, alert, card, carousel, container, context, context_actions, data_table, data_visualization, divider, file, header, image, input, markdown, plan, rich_text, section, table, task_card, video.
145145
- **All block elements**: button, icon_button, workflow_button, feedback_buttons, plain_text / email / url / number inputs, datepicker, datetimepicker, timepicker, file_input, rich_text_input, checkboxes, radio_buttons, image, overflow, url source, and all 5 single + 5 multi-select menu variants.
146146
- **All 9 composition objects**: text (plain_text + mrkdwn), confirm, option (3 contextual variants), option_group, slack_file, dispatch_action_config, conversation_filter, trigger, workflow.
147147
- **Rich text**: 4 container kinds (section, list, preformatted, quote) + 10 leaf kinds (text, link, user, usergroup, team, channel, emoji, broadcast, color, date) with style flags.

demo/src/presets.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -237,6 +237,38 @@ const twoTables = stringify([
237237
},
238238
]);
239239

240+
const dataTableLeaderboard = stringify([
241+
{
242+
type: "header",
243+
text: { type: "plain_text", text: "Contributor leaderboard" },
244+
},
245+
{
246+
type: "data_table",
247+
caption: "Merged PRs by contributor this sprint",
248+
page_size: 10,
249+
rows: [
250+
[
251+
{ type: "raw_text", text: "Contributor" },
252+
{ type: "raw_text", text: "Merged PRs" },
253+
{ type: "raw_text", text: "Status" },
254+
],
255+
[
256+
{ type: "raw_text", text: "ana" },
257+
{ type: "raw_number", value: 12, text: "12" },
258+
{
259+
type: "rich_text",
260+
elements: [{ type: "rich_text_section", elements: [{ type: "text", text: "on track", style: { bold: true } }] }],
261+
},
262+
],
263+
[
264+
{ type: "raw_text", text: "zach" },
265+
{ type: "raw_number", value: 9, text: "9" },
266+
{ type: "raw_text", text: "on track" },
267+
],
268+
],
269+
},
270+
]);
271+
240272
const activeUsersChart = stringify([
241273
{
242274
type: "header",
@@ -1013,6 +1045,14 @@ export const presets: Preset[] = [
10131045
tone: "invalid",
10141046
json: threeCharts,
10151047
},
1048+
{
1049+
id: "data-table",
1050+
label: "Data table block",
1051+
surface: "message",
1052+
blurb: "Interactive `data_table` with raw_text, raw_number, and rich_text cells.",
1053+
tone: "valid",
1054+
json: dataTableLeaderboard,
1055+
},
10161056
{
10171057
id: "markdown-overflow",
10181058
label: "Cumulative markdown > 12k",

src/helpers/check-surface-compatibility.ts

Lines changed: 17 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -8,25 +8,29 @@ export type Surface = "message" | "modal" | "home";
88
// that powers the "Surfaces" column on each block's reference page. Every
99
// block entry there has an `available-in-surfaces` array valued with some
1010
// subset of ["Modals", "Messages", "Home tabs"]. Whatever's missing from
11-
// that array is what we forbid here.
12-
// `file` is only produced by Slack when retrieving messages that contain
13-
// remote files; apps cannot send it outbound on any surface
14-
// (https://docs.slack.dev/reference/block-kit/blocks/file-block:
15-
// "You can't add this block to app surfaces directly...").
11+
// that array is what we forbid here. `test/surface-drift.test.ts` diffs these
12+
// sets against a committed snapshot of blocks.json so this can't silently rot.
13+
//
14+
// Four entries below intentionally deviate from blocks.json — Slack's canonical
15+
// data lists a surface where the block doesn't actually render. Each is
16+
// empirically grounded and kept in the drift test's KNOWN_DEVIATIONS allowlist:
17+
// • card / modals — blocks.json lists Modals; fails to render there.
18+
// • file / messages — inbound-only; apps can't send it outbound at all
19+
// (https://docs.slack.dev/reference/block-kit/blocks/file-block:
20+
// "You can't add this block to app surfaces directly...").
21+
// • table / home tabs — blocks.json lists Home tabs; Tightknit testing shows
22+
// the table block renders on messages only (modals and home drop it).
23+
// • data_table / home tabs — same: blocks.json lists Home tabs, but as a
24+
// table-family block it inherits table's messages-only behavior (mirrored
25+
// for consistency, pending independent confirmation).
1626
const BLOCKS_NOT_ALLOWED_IN_MESSAGE = new Set(["alert", "file"]);
1727

18-
// carousel/card are listed as modal-available in Slack's blocks.json but
19-
// empirically fail to render inside modal views — confirmed by the PR author
20-
// against the live API. They work in messages and home tabs.
21-
// `data_visualization` is a messages-only block — its reference page lists
22-
// "Available in Surfaces: Messages" only
23-
// (https://docs.slack.dev/reference/block-kit/blocks/data-visualization-block).
24-
// Forbidden in both modals and home tabs.
2528
const BLOCKS_NOT_ALLOWED_IN_MODAL = new Set([
2629
"card",
2730
"carousel",
2831
"container",
2932
"context_actions",
33+
"data_table",
3034
"data_visualization",
3135
"file",
3236
"markdown",
@@ -39,6 +43,7 @@ const BLOCKS_NOT_ALLOWED_IN_HOME = new Set([
3943
"alert",
4044
"container",
4145
"context_actions",
46+
"data_table",
4247
"data_visualization",
4348
"file",
4449
"markdown",

src/slack-block-kit.schema.json

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1352,6 +1352,69 @@
13521352
]
13531353
},
13541354

1355+
"data_table_cell": {
1356+
"description": "A cell inside a data_table_block row. `raw_text` / `raw_number` for simple values, `rich_text` for formatted content. Per https://docs.slack.dev/reference/block-kit/blocks/data-table-block. The docs' per-cell schemas list `properties` without a `required` array; we mirror `table_cell` and require the value-bearing field(s). The rule 'the first (header) row cannot contain rich_text cells' is a row-position constraint JSON Schema can't express and is not enforced here.",
1357+
"oneOf": [
1358+
{
1359+
"type": "object",
1360+
"additionalProperties": false,
1361+
"required": ["type", "text"],
1362+
"properties": {
1363+
"type": { "const": "raw_text" },
1364+
"text": { "type": "string", "minLength": 1 }
1365+
}
1366+
},
1367+
{
1368+
"type": "object",
1369+
"additionalProperties": false,
1370+
"required": ["type", "value"],
1371+
"properties": {
1372+
"type": { "const": "raw_number" },
1373+
"value": { "type": "number" },
1374+
"text": { "type": "string", "minLength": 1 }
1375+
}
1376+
},
1377+
{
1378+
"type": "object",
1379+
"additionalProperties": false,
1380+
"required": ["type", "elements"],
1381+
"properties": {
1382+
"type": { "const": "rich_text" },
1383+
"elements": {
1384+
"type": "array",
1385+
"items": { "$ref": "#/$defs/rich_text_container_element" }
1386+
}
1387+
}
1388+
}
1389+
]
1390+
},
1391+
1392+
"data_table_block": {
1393+
"description": "Rich, interactive table with pagination, sorting, and filtering (https://docs.slack.dev/reference/block-kit/blocks/data-table-block). Distinct from `table_block`, which is a simpler static table. The single-table and aggregate 10,000-character limits are payload-level rules not enforced here. `row_header_column_index` must point at an existing column, a cross-field rule JSON Schema can't check (bounded to 0–19 since a row has at most 20 cells).",
1394+
"type": "object",
1395+
"additionalProperties": false,
1396+
"required": ["type", "caption", "rows"],
1397+
"properties": {
1398+
"type": { "const": "data_table" },
1399+
"block_id": { "$ref": "#/$defs/block_id" },
1400+
"caption": { "type": "string", "minLength": 1 },
1401+
"page_size": { "type": "integer", "minimum": 1, "maximum": 100 },
1402+
"row_header_column_index": { "type": "integer", "minimum": 0, "maximum": 19 },
1403+
"rows": {
1404+
"description": "First row is the header; 2–101 rows total (1 header + 1–100 data rows), 1–20 cells per row.",
1405+
"type": "array",
1406+
"minItems": 2,
1407+
"maxItems": 101,
1408+
"items": {
1409+
"type": "array",
1410+
"minItems": 1,
1411+
"maxItems": 20,
1412+
"items": { "$ref": "#/$defs/data_table_cell" }
1413+
}
1414+
}
1415+
}
1416+
},
1417+
13551418
"data_visualization_block": {
13561419
"description": "Renders data as a line, bar, area, or pie chart. Messages only (https://docs.slack.dev/reference/block-kit/blocks/data-visualization-block). Slack renders at most two data_visualization blocks per message (enforced via the checkDataVisualizationMax helper, since JSON Schema can't count sibling blocks). Two further runtime rules are enforced via checkDataVisualizationConsistency, since they depend on sibling-field values JSON Schema can't compare: series names must be unique within a chart, and each series must contain exactly one data point per axis_config.categories label.",
13571420
"type": "object",
@@ -1678,6 +1741,7 @@
16781741
{ "$ref": "#/$defs/container_block" },
16791742
{ "$ref": "#/$defs/context_actions_block" },
16801743
{ "$ref": "#/$defs/context_block" },
1744+
{ "$ref": "#/$defs/data_table_block" },
16811745
{ "$ref": "#/$defs/data_visualization_block" },
16821746
{ "$ref": "#/$defs/divider_block" },
16831747
{ "$ref": "#/$defs/file_block" },

0 commit comments

Comments
 (0)