Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/blockkit-checklist.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@emdash-cms/blocks": patch
---

Adds the checklist Block Kit block for read-only setup and readiness tasks.
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
96 changes: 96 additions & 0 deletions packages/blocks/src/blocks/checklist.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<li className="flex gap-3 rounded-lg border border-kumo-line p-3">
<div
role="img"
aria-label={item.status}
className={cn(
"mt-0.5 flex size-7 shrink-0 items-center justify-center rounded-full border",
status.marker,
status.color,
)}
>
<StatusIcon
aria-hidden="true"
size={18}
weight={item.status === "pending" ? "regular" : "fill"}
/>
</div>
<div className="min-w-0 flex-1">
<div className="font-medium text-kumo-default">{item.label}</div>
{item.description && (
<div className="mt-1 text-sm text-kumo-subtle">{item.description}</div>
)}
{item.action && <div className="mt-3">{renderElement(item.action, onAction)}</div>}
</div>
</li>
);
}

export function ChecklistBlockComponent({
block,
onAction,
}: {
block: ChecklistBlock;
onAction: (interaction: BlockInteraction) => void;
}) {
return (
<div className="rounded-lg border border-kumo-line p-4">
{(block.title || block.description) && (
<div className="mb-4">
{block.title && <h3 className="font-semibold text-kumo-default">{block.title}</h3>}
{block.description && (
<p className="mt-1 text-sm text-kumo-subtle">{block.description}</p>
)}
</div>
)}
<ul className="flex flex-col gap-2">
{block.items.map((item, i) => (
<ChecklistRow key={getChecklistItemKey(item, i)} item={item} onAction={onAction} />
))}
</ul>
</div>
);
}
18 changes: 18 additions & 0 deletions packages/blocks/src/builders.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ import type {
CheckboxElement,
ChartBlock,
ChartSeries,
ChecklistBlock,
ChecklistItem,
CodeBlock,
ComboboxElement,
ColumnsBlock,
Expand Down Expand Up @@ -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 = {
Expand All @@ -517,6 +534,7 @@ export const blocks = {
tab: tabBlock,
empty,
accordion,
checklist,
};

export const elements = {
Expand Down
2 changes: 2 additions & 0 deletions packages/blocks/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ export type {
// Block sub-types
TableColumn,
StatItem,
ChecklistItem,
ChartSeries,
ChartConfig,
TimeseriesChartConfig,
Expand All @@ -56,6 +57,7 @@ export type {
MeterBlock,
EmptyBlock,
AccordionBlock,
ChecklistBlock,
Block,
// Interactions
BlockAction,
Expand Down
3 changes: 3 additions & 0 deletions packages/blocks/src/renderer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -59,6 +60,8 @@ function renderBlock(
return <EmptyBlockComponent block={block} onAction={onAction} />;
case "accordion":
return <AccordionBlockComponent block={block} onAction={onAction} />;
case "checklist":
return <ChecklistBlockComponent block={block} onAction={onAction} />;
default: {
const _exhaustive: never = block;
return null;
Expand Down
17 changes: 16 additions & 1 deletion packages/blocks/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 */
Expand Down Expand Up @@ -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
Expand All @@ -382,7 +396,8 @@ export type Block =
| CodeBlock
| TabBlock
| EmptyBlock
| AccordionBlock;
| AccordionBlock
| ChecklistBlock;

// ── Interactions ─────────────────────────────────────────────────────────────

Expand Down
66 changes: 66 additions & 0 deletions packages/blocks/src/validation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ const BLOCK_TYPES = new Set([
"code",
"empty",
"accordion",
"checklist",
]);

const EMPTY_SIZES = new Set(["sm", "base", "lg"]);
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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);

Expand Down Expand Up @@ -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;
}
}
}

Expand Down
3 changes: 3 additions & 0 deletions packages/blocks/tests/form-conditions.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,9 @@ vi.mock("@phosphor-icons/react", () => ({
ArrowUp: () => <span data-testid="arrow-up" />,
ArrowDown: () => <span data-testid="arrow-down" />,
Minus: () => <span data-testid="minus" />,
Circle: () => <span data-testid="icon-circle" />,
CheckCircle: () => <span data-testid="icon-check-circle" />,
XCircle: () => <span data-testid="icon-x-circle" />,
Info: () => <span data-testid="icon-info" />,
Warning: () => <span data-testid="icon-warning" />,
WarningCircle: () => <span data-testid="icon-warning-circle" />,
Expand Down
43 changes: 43 additions & 0 deletions packages/blocks/tests/renderer.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -219,6 +219,9 @@ vi.mock("@phosphor-icons/react", () => ({
ArrowUp: () => <span data-testid="arrow-up" />,
ArrowDown: () => <span data-testid="arrow-down" />,
Minus: () => <span data-testid="minus" />,
Circle: () => <span data-testid="icon-circle" />,
CheckCircle: () => <span data-testid="icon-check-circle" />,
XCircle: () => <span data-testid="icon-x-circle" />,
Info: () => <span data-testid="icon-info" />,
Warning: () => <span data-testid="icon-warning" />,
WarningCircle: () => <span data-testid="icon-warning-circle" />,
Expand Down Expand Up @@ -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([
{
Expand Down
Loading
Loading