From 7f1a357625b845fbc466036011a24fe4eb134326 Mon Sep 17 00:00:00 2001 From: Mason James Date: Sun, 3 May 2026 18:14:17 -0400 Subject: [PATCH] Add checklist block kit block --- .changeset/blockkit-checklist.md | 5 + .../plugins/creating-plugins/block-kit.mdx | 1 + packages/blocks/src/blocks/checklist.tsx | 96 +++++++++++++++++++ packages/blocks/src/builders.ts | 18 ++++ packages/blocks/src/index.ts | 2 + packages/blocks/src/renderer.tsx | 3 + packages/blocks/src/types.ts | 17 +++- packages/blocks/src/validation.ts | 66 +++++++++++++ .../blocks/tests/form-conditions.test.tsx | 3 + packages/blocks/tests/renderer.test.tsx | 43 +++++++++ packages/blocks/tests/validation.test.ts | 61 ++++++++++++ 11 files changed, 314 insertions(+), 1 deletion(-) create mode 100644 .changeset/blockkit-checklist.md create mode 100644 packages/blocks/src/blocks/checklist.tsx diff --git a/.changeset/blockkit-checklist.md b/.changeset/blockkit-checklist.md new file mode 100644 index 000000000..8ffba75ee --- /dev/null +++ b/.changeset/blockkit-checklist.md @@ -0,0 +1,5 @@ +--- +"@emdash-cms/blocks": patch +--- + +Adds the checklist Block Kit block for read-only setup and readiness tasks. diff --git a/docs/src/content/docs/plugins/creating-plugins/block-kit.mdx b/docs/src/content/docs/plugins/creating-plugins/block-kit.mdx index dd03f112e..9b419645b 100644 --- a/docs/src/content/docs/plugins/creating-plugins/block-kit.mdx +++ b/docs/src/content/docs/plugins/creating-plugins/block-kit.mdx @@ -92,6 +92,7 @@ The standard-format route handler takes two arguments: `routeCtx` (with `input`, | `columns` | 2–3 column layout with nested blocks | | `empty` | Empty-state placeholder with icon, title, description, optional command line, and action buttons | | `accordion` | Collapsible section wrapping nested blocks | +| `checklist` | Read-only setup/readiness checklist with per-item status and optional action buttons | ## Element types diff --git a/packages/blocks/src/blocks/checklist.tsx b/packages/blocks/src/blocks/checklist.tsx new file mode 100644 index 000000000..d08452ca2 --- /dev/null +++ b/packages/blocks/src/blocks/checklist.tsx @@ -0,0 +1,96 @@ +import { CheckCircle, Circle, WarningCircle, XCircle } from "@phosphor-icons/react"; + +import { renderElement } from "../render-element.js"; +import type { BlockInteraction, ChecklistBlock, ChecklistItem } from "../types.js"; +import { cn } from "../utils.js"; + +const statusConfig = { + pending: { + icon: Circle, + color: "text-kumo-subtle", + marker: "border-kumo-line bg-kumo-tint", + }, + complete: { + icon: CheckCircle, + color: "text-kumo-success", + marker: "border-kumo-success/30 bg-kumo-success/10", + }, + warning: { + icon: WarningCircle, + color: "text-kumo-warning", + marker: "border-kumo-warning/30 bg-kumo-warning/10", + }, + error: { + icon: XCircle, + color: "text-kumo-danger", + marker: "border-kumo-danger/30 bg-kumo-danger/10", + }, +} as const; + +function getChecklistItemKey(item: ChecklistItem, index: number): string { + return [item.label, item.status, item.action?.action_id ?? "", index].join(":"); +} + +function ChecklistRow({ + item, + onAction, +}: { + item: ChecklistItem; + onAction: (interaction: BlockInteraction) => void; +}) { + const status = statusConfig[item.status]; + const StatusIcon = status.icon; + + return ( +
  • +
    +
    +
    +
    {item.label}
    + {item.description && ( +
    {item.description}
    + )} + {item.action &&
    {renderElement(item.action, onAction)}
    } +
    +
  • + ); +} + +export function ChecklistBlockComponent({ + block, + onAction, +}: { + block: ChecklistBlock; + onAction: (interaction: BlockInteraction) => void; +}) { + return ( +
    + {(block.title || block.description) && ( +
    + {block.title &&

    {block.title}

    } + {block.description && ( +

    {block.description}

    + )} +
    + )} + +
    + ); +} diff --git a/packages/blocks/src/builders.ts b/packages/blocks/src/builders.ts index ce71b2d02..488a0e3a2 100644 --- a/packages/blocks/src/builders.ts +++ b/packages/blocks/src/builders.ts @@ -7,6 +7,8 @@ import type { CheckboxElement, ChartBlock, ChartSeries, + ChecklistBlock, + ChecklistItem, CodeBlock, ComboboxElement, ColumnsBlock, @@ -495,6 +497,21 @@ function accordion(opts: { }; } +function checklist(opts: { + blockId?: string; + items: ChecklistItem[]; + title?: string; + description?: string; +}): ChecklistBlock { + return { + type: "checklist", + items: opts.items, + ...(opts.title !== undefined && { title: opts.title }), + ...(opts.description !== undefined && { description: opts.description }), + ...(opts.blockId !== undefined && { block_id: opts.blockId }), + }; +} + // ── Exports ────────────────────────────────────────────────────────────────── export const blocks = { @@ -517,6 +534,7 @@ export const blocks = { tab: tabBlock, empty, accordion, + checklist, }; export const elements = { diff --git a/packages/blocks/src/index.ts b/packages/blocks/src/index.ts index dfd4910ab..954576ae1 100644 --- a/packages/blocks/src/index.ts +++ b/packages/blocks/src/index.ts @@ -32,6 +32,7 @@ export type { // Block sub-types TableColumn, StatItem, + ChecklistItem, ChartSeries, ChartConfig, TimeseriesChartConfig, @@ -56,6 +57,7 @@ export type { MeterBlock, EmptyBlock, AccordionBlock, + ChecklistBlock, Block, // Interactions BlockAction, diff --git a/packages/blocks/src/renderer.tsx b/packages/blocks/src/renderer.tsx index 2473c3794..bc96b89a9 100644 --- a/packages/blocks/src/renderer.tsx +++ b/packages/blocks/src/renderer.tsx @@ -2,6 +2,7 @@ import { AccordionBlockComponent } from "./blocks/accordion.js"; import { ActionsBlockComponent } from "./blocks/actions.js"; import { BannerBlockComponent } from "./blocks/banner.js"; import { ChartBlockComponent } from "./blocks/chart.js"; +import { ChecklistBlockComponent } from "./blocks/checklist.js"; import { CodeBlockComponent } from "./blocks/code.js"; import { ColumnsBlockComponent } from "./blocks/columns.js"; import { ContextBlockComponent } from "./blocks/context.js"; @@ -59,6 +60,8 @@ function renderBlock( return ; case "accordion": return ; + case "checklist": + return ; default: { const _exhaustive: never = block; return null; diff --git a/packages/blocks/src/types.ts b/packages/blocks/src/types.ts index 5105a9734..1a35221ab 100644 --- a/packages/blocks/src/types.ts +++ b/packages/blocks/src/types.ts @@ -201,6 +201,13 @@ export interface StatItem { trend?: "up" | "down" | "neutral"; } +export interface ChecklistItem { + label: string; + status: "pending" | "complete" | "warning" | "error"; + description?: string; + action?: ButtonElement; +} + /** A single data series for a timeseries chart. */ export interface ChartSeries { /** Display name shown in tooltips and legends */ @@ -364,6 +371,13 @@ export interface AccordionBlock extends BlockBase { default_open?: boolean; } +export interface ChecklistBlock extends BlockBase { + type: "checklist"; + items: ChecklistItem[]; + title?: string; + description?: string; +} + export type Block = | HeaderBlock | SectionBlock @@ -382,7 +396,8 @@ export type Block = | CodeBlock | TabBlock | EmptyBlock - | AccordionBlock; + | AccordionBlock + | ChecklistBlock; // ── Interactions ───────────────────────────────────────────────────────────── diff --git a/packages/blocks/src/validation.ts b/packages/blocks/src/validation.ts index 9f39163b7..da5b3bfa4 100644 --- a/packages/blocks/src/validation.ts +++ b/packages/blocks/src/validation.ts @@ -16,6 +16,7 @@ const BLOCK_TYPES = new Set([ "code", "empty", "accordion", + "checklist", ]); const EMPTY_SIZES = new Set(["sm", "base", "lg"]); @@ -44,6 +45,7 @@ const CODE_LANGUAGES = new Set(["ts", "tsx", "jsonc", "bash", "css"]); const BUTTON_STYLES = new Set(["primary", "danger", "secondary"]); const TREND_VALUES = new Set(["up", "down", "neutral"]); const BANNER_VARIANTS = new Set(["default", "alert", "error"]); +const CHECKLIST_STATUSES = new Set(["pending", "complete", "warning", "error"]); /** * RFC 6838-style image MIME type or image-prefix. @@ -586,6 +588,17 @@ function validateElement(value: unknown, path: string, errors: ValidationError[] } } +function validateButtonElementOnly(value: unknown, path: string, errors: ValidationError[]): void { + validateElement(value, path, errors); + + if (isRecord(value) && value.type !== undefined && value.type !== "button") { + errors.push({ + path: `${path}.type`, + message: "Checklist item action must be a button element", + }); + } +} + function validateFormField(value: unknown, path: string, errors: ValidationError[]): void { validateElement(value, path, errors); @@ -1197,6 +1210,59 @@ function validateBlock(value: unknown, path: string, errors: ValidationError[]): } break; } + case "checklist": { + if (value.title !== undefined && typeof value.title !== "string") { + errors.push({ + path: `${path}.title`, + message: "Field 'title' must be a string if provided", + }); + } + if (value.description !== undefined && typeof value.description !== "string") { + errors.push({ + path: `${path}.description`, + message: "Field 'description' must be a string if provided", + }); + } + if (!Array.isArray(value.items)) { + errors.push({ + path: `${path}.items`, + message: "Required field 'items' must be an array", + }); + } else { + for (let i = 0; i < value.items.length; i++) { + const item = value.items[i] as unknown; + if (!isRecord(item)) { + errors.push({ + path: `${path}.items[${i}]`, + message: "Checklist item must be an object", + }); + continue; + } + if (typeof item.label !== "string") { + errors.push({ + path: `${path}.items[${i}].label`, + message: "Required field 'label' must be a string", + }); + } + if (typeof item.status !== "string" || !CHECKLIST_STATUSES.has(item.status)) { + errors.push({ + path: `${path}.items[${i}].status`, + message: `Required field 'status' must be one of: ${[...CHECKLIST_STATUSES].join(", ")}`, + }); + } + if (item.description !== undefined && typeof item.description !== "string") { + errors.push({ + path: `${path}.items[${i}].description`, + message: "Field 'description' must be a string if provided", + }); + } + if (item.action !== undefined) { + validateButtonElementOnly(item.action, `${path}.items[${i}].action`, errors); + } + } + } + break; + } } } diff --git a/packages/blocks/tests/form-conditions.test.tsx b/packages/blocks/tests/form-conditions.test.tsx index 1b3704d81..c1efff74d 100644 --- a/packages/blocks/tests/form-conditions.test.tsx +++ b/packages/blocks/tests/form-conditions.test.tsx @@ -188,6 +188,9 @@ vi.mock("@phosphor-icons/react", () => ({ ArrowUp: () => , ArrowDown: () => , Minus: () => , + Circle: () => , + CheckCircle: () => , + XCircle: () => , Info: () => , Warning: () => , WarningCircle: () => , diff --git a/packages/blocks/tests/renderer.test.tsx b/packages/blocks/tests/renderer.test.tsx index 4c06d65d9..ede672338 100644 --- a/packages/blocks/tests/renderer.test.tsx +++ b/packages/blocks/tests/renderer.test.tsx @@ -219,6 +219,9 @@ vi.mock("@phosphor-icons/react", () => ({ ArrowUp: () => , ArrowDown: () => , Minus: () => , + Circle: () => , + CheckCircle: () => , + XCircle: () => , Info: () => , Warning: () => , WarningCircle: () => , @@ -533,6 +536,46 @@ describe("BlockRenderer", () => { expect(onAction).toHaveBeenCalledWith({ type: "block_action", action_id: "ping" }); }); + it("checklist block renders items, statuses, and item action", () => { + const onAction = vi.fn(); + renderBlocks( + [ + { + type: "checklist", + title: "Setup checklist", + description: "Finish these tasks before launch.", + items: [ + { label: "Create admin", status: "complete" }, + { + label: "Configure storage", + status: "warning", + description: "R2 credentials are missing.", + action: { type: "button", action_id: "open_storage", label: "Configure" }, + }, + { label: "Publish", status: "pending" }, + { label: "DNS", status: "error" }, + ], + }, + ], + onAction, + ); + + expect(screen.getByText("Setup checklist")).toBeTruthy(); + expect(screen.getByText("Finish these tasks before launch.")).toBeTruthy(); + expect(screen.getByText("Create admin")).toBeTruthy(); + expect(screen.getByText("R2 credentials are missing.")).toBeTruthy(); + expect(screen.getByTestId("icon-check-circle")).toBeTruthy(); + expect(screen.getByTestId("icon-warning-circle")).toBeTruthy(); + expect(screen.getByTestId("icon-circle")).toBeTruthy(); + expect(screen.getByTestId("icon-x-circle")).toBeTruthy(); + + fireEvent.click(screen.getByText("Configure")); + expect(onAction).toHaveBeenCalledWith({ + type: "block_action", + action_id: "open_storage", + }); + }); + it("columns block renders blocks in columns", () => { renderBlocks([ { diff --git a/packages/blocks/tests/validation.test.ts b/packages/blocks/tests/validation.test.ts index e5ac96e16..2d9be83cd 100644 --- a/packages/blocks/tests/validation.test.ts +++ b/packages/blocks/tests/validation.test.ts @@ -135,6 +135,28 @@ describe("validateBlocks", () => { expect(result).toEqual({ valid: true, errors: [] }); }); + it("checklist", () => { + const result = validateBlocks([ + { + type: "checklist", + title: "Setup checklist", + description: "Finish these tasks before launch.", + items: [ + { label: "Create admin", status: "complete" }, + { + label: "Configure storage", + status: "warning", + description: "R2 credentials are missing.", + action: { type: "button", action_id: "storage", label: "Configure" }, + }, + { label: "Publish", status: "pending" }, + { label: "DNS", status: "error" }, + ], + }, + ]); + expect(result).toEqual({ valid: true, errors: [] }); + }); + it("repeater", () => { const result = validateBlocks([ { @@ -501,6 +523,45 @@ describe("validateBlocks", () => { expect(result.errors[0]!.path).toBe("blocks[0].default_open"); }); + it("checklist missing required fields", () => { + const result = validateBlocks([{ type: "checklist" }]); + expect(result.valid).toBe(false); + expect(result.errors.map((e) => e.path)).toContain("blocks[0].items"); + }); + + it("checklist item missing label or status", () => { + const result = validateBlocks([{ type: "checklist", items: [{}] }]); + expect(result.valid).toBe(false); + const paths = result.errors.map((e) => e.path); + expect(paths).toContain("blocks[0].items[0].label"); + expect(paths).toContain("blocks[0].items[0].status"); + }); + + it("checklist rejects invalid status", () => { + const result = validateBlocks([ + { type: "checklist", items: [{ label: "DNS", status: "blocked" }] }, + ]); + expect(result.valid).toBe(false); + expect(result.errors[0]!.path).toBe("blocks[0].items[0].status"); + }); + + it("checklist action must be a button element", () => { + const result = validateBlocks([ + { + type: "checklist", + items: [ + { + label: "Storage", + status: "warning", + action: { type: "select", action_id: "storage", label: "Storage", options: [] }, + }, + ], + }, + ]); + expect(result.valid).toBe(false); + expect(result.errors.map((e) => e.path)).toContain("blocks[0].items[0].action.type"); + }); + it("stats item missing label or value", () => { const result = validateBlocks([ {