Emdash source with visual editor image upload fix

Fixes:
1. media.ts: wrap placeholder generation in try-catch
2. toolbar.ts: check r.ok, display error message in popover
This commit is contained in:
2026-05-03 10:44:54 +07:00
parent 78f81bebb6
commit 2d1be52177
2352 changed files with 662964 additions and 0 deletions

View File

@@ -0,0 +1,403 @@
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import React from "react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { BlockRenderer } from "../src/renderer.js";
import type { BlockInteraction, FormBlock } from "../src/types.js";
// ── Mocks ────────────────────────────────────────────────────────────────────
vi.mock("@cloudflare/kumo", () => ({
Button: ({ children, onClick, variant, type }: any) => (
<button onClick={onClick} data-variant={variant} type={type || "button"}>
{children}
</button>
),
Badge: ({ children }: any) => <span data-testid="badge">{children}</span>,
Input: ({ label, value, defaultValue, onChange, onBlur, placeholder, type, min, max }: any) => (
<div>
<label>{label}</label>
<input
type={type || "text"}
defaultValue={defaultValue}
value={value}
placeholder={placeholder}
min={min}
max={max}
onChange={onChange}
onBlur={onBlur}
/>
</div>
),
InputArea: ({ label, defaultValue, onChange, onBlur, placeholder }: any) => (
<div>
<label>{label}</label>
<textarea
defaultValue={defaultValue}
placeholder={placeholder}
onChange={onChange}
onBlur={onBlur}
/>
</div>
),
Select: Object.assign(
({ children, label, defaultValue, onValueChange }: any) => (
<div>
<label>{label}</label>
<select defaultValue={defaultValue} onChange={(e: any) => onValueChange?.(e.target.value)}>
{children}
</select>
</div>
),
{
Option: ({ children, value }: any) => <option value={value}>{children}</option>,
},
),
Switch: ({ label, checked, onCheckedChange }: any) => (
<div>
<label>
<input
type="checkbox"
checked={checked}
onChange={(e: any) => onCheckedChange?.(e.target.checked)}
/>
{label}
</label>
</div>
),
SensitiveInput: ({
label,
value,
onValueChange,
readOnly,
onFocus,
onBlur,
placeholder,
}: any) => (
<div>
<label>{label}</label>
<input
type="password"
value={value}
readOnly={readOnly}
onFocus={onFocus}
onBlur={onBlur}
placeholder={placeholder}
onChange={(e: any) => onValueChange?.(e.target.value)}
/>
</div>
),
Dialog: ({ children }: any) => <div data-testid="dialog">{children}</div>,
DialogRoot: ({ children, open }: any) =>
open ? <div data-testid="dialog-root">{children}</div> : null,
Banner: ({ title, description, variant, icon }: any) => (
<div data-testid="banner" data-variant={variant}>
{icon}
{title && <strong>{title}</strong>}
{description && <p>{description}</p>}
</div>
),
Meter: ({ label, value, max, min, customValue }: any) => (
<div data-testid="meter" data-value={value} data-max={max} data-min={min}>
<span>{label}</span>
{customValue && <span>{customValue}</span>}
</div>
),
CodeBlock: ({ code, lang }: any) => (
<pre data-testid="code-block" data-lang={lang}>
<code>{code}</code>
</pre>
),
Checkbox: {
Group: ({ children, legend }: any) => (
<fieldset data-testid="checkbox-group">
<legend>{legend}</legend>
{children}
</fieldset>
),
Item: ({ label, value }: any) => (
<label>
<input type="checkbox" value={value} />
{label}
</label>
),
},
Radio: {
Group: ({ children, legend }: any) => (
<fieldset data-testid="radio-group">
<legend>{legend}</legend>
{children}
</fieldset>
),
Item: ({ label, value }: any) => (
<label>
<input type="radio" value={value} />
{label}
</label>
),
},
Combobox: Object.assign(
({ children, label }: any) => (
<div data-testid="combobox">
<label>{label}</label>
{children}
</div>
),
{
TriggerInput: ({ placeholder }: any) => <input placeholder={placeholder} />,
Content: ({ children }: any) => <div>{children}</div>,
List: ({ children }: any) => <div>{typeof children === "function" ? null : children}</div>,
Item: ({ children, value }: any) => <div data-value={value}>{children}</div>,
Empty: ({ children }: any) => <div>{children}</div>,
},
),
}));
vi.mock("@cloudflare/kumo/components/chart", () => ({
TimeseriesChart: (props: any) => (
<div data-testid="timeseries-chart" data-height={props.height} />
),
Chart: (props: any) => <div data-testid="custom-chart" data-height={props.height} />,
ChartPalette: { color: (i: number) => `#color${i}` },
}));
// eslint-disable-next-line unicorn/consistent-function-scoping -- vi.mock is hoisted; cannot reference outer scope
vi.mock("echarts/core", () => {
const noop = () => {};
return { __esModule: true, default: { use: noop }, use: noop };
});
vi.mock("echarts/charts", () => ({
BarChart: {},
LineChart: {},
PieChart: {},
}));
vi.mock("echarts/components", () => ({
AriaComponent: {},
AxisPointerComponent: {},
GridComponent: {},
TooltipComponent: {},
}));
vi.mock("echarts/renderers", () => ({
CanvasRenderer: {},
}));
vi.mock("@phosphor-icons/react", () => ({
ArrowUp: () => <span data-testid="arrow-up" />,
ArrowDown: () => <span data-testid="arrow-down" />,
Minus: () => <span data-testid="minus" />,
Info: () => <span data-testid="icon-info" />,
Warning: () => <span data-testid="icon-warning" />,
WarningCircle: () => <span data-testid="icon-warning-circle" />,
}));
afterEach(cleanup);
// ── Helpers ──────────────────────────────────────────────────────────────────
function renderForm(form: FormBlock, onAction?: (i: BlockInteraction) => void) {
const handler = onAction ?? vi.fn();
return {
...render(<BlockRenderer blocks={[form]} onAction={handler} />),
onAction: handler,
};
}
// ── Tests ────────────────────────────────────────────────────────────────────
describe("form conditional fields", () => {
it("condition eq: field shown when condition met", () => {
renderForm({
type: "form",
fields: [
{
type: "toggle",
action_id: "enable",
label: "Enable",
initial_value: true,
},
{
type: "text_input",
action_id: "name",
label: "Name",
condition: { field: "enable", eq: true },
},
],
submit: { label: "Save", action_id: "save" },
});
// Toggle is true → Name field should be visible
expect(screen.getByText("Name")).toBeTruthy();
});
it("condition eq: field hidden when condition not met", () => {
renderForm({
type: "form",
fields: [
{
type: "toggle",
action_id: "enable",
label: "Enable",
initial_value: false,
},
{
type: "text_input",
action_id: "name",
label: "Name",
condition: { field: "enable", eq: true },
},
],
submit: { label: "Save", action_id: "save" },
});
// Toggle is false → Name field should not be rendered
expect(screen.queryByText("Name")).toBeNull();
});
it("condition neq: field shown when value differs", () => {
renderForm({
type: "form",
fields: [
{
type: "select",
action_id: "status",
label: "Status",
options: [
{ label: "Active", value: "active" },
{ label: "Disabled", value: "disabled" },
],
initial_value: "active",
},
{
type: "text_input",
action_id: "reason",
label: "Reason",
condition: { field: "status", neq: "disabled" },
},
],
submit: { label: "Save", action_id: "save" },
});
// Status is "active" which is != "disabled" → Reason field visible
expect(screen.getByText("Reason")).toBeTruthy();
});
it("condition reacts to changes", () => {
renderForm({
type: "form",
fields: [
{
type: "toggle",
action_id: "show_extra",
label: "Show extra",
initial_value: false,
},
{
type: "text_input",
action_id: "extra",
label: "Extra field",
condition: { field: "show_extra", eq: true },
},
],
submit: { label: "Save", action_id: "save" },
});
// Initially hidden
expect(screen.queryByText("Extra field")).toBeNull();
// Click toggle to enable
const toggle = screen.getByRole("checkbox");
fireEvent.click(toggle);
// Now visible
expect(screen.getByText("Extra field")).toBeTruthy();
});
it("hidden field values are included in submit payload", () => {
const onAction = vi.fn();
renderForm(
{
type: "form",
fields: [
{
type: "toggle",
action_id: "show_name",
label: "Show name",
initial_value: true,
},
{
type: "text_input",
action_id: "name",
label: "Name",
initial_value: "Alice",
condition: { field: "show_name", eq: true },
},
],
submit: { label: "Save", action_id: "save" },
},
onAction,
);
// Field is visible, then hide it by toggling off
const toggle = screen.getByRole("checkbox");
fireEvent.click(toggle);
// Name field is now hidden
expect(screen.queryByText("Name")).toBeNull();
// Submit — hidden field's last known value should still be in payload
fireEvent.click(screen.getByText("Save"));
expect(onAction).toHaveBeenCalledWith(
expect.objectContaining({
type: "form_submit",
action_id: "save",
values: expect.objectContaining({
show_name: false,
name: "Alice",
}),
}),
);
});
it("multiple conditional fields evaluate independently", () => {
renderForm({
type: "form",
fields: [
{
type: "toggle",
action_id: "toggle_a",
label: "Toggle A",
initial_value: true,
},
{
type: "toggle",
action_id: "toggle_b",
label: "Toggle B",
initial_value: false,
},
{
type: "text_input",
action_id: "field_a",
label: "Field A",
condition: { field: "toggle_a", eq: true },
},
{
type: "text_input",
action_id: "field_b",
label: "Field B",
condition: { field: "toggle_b", eq: true },
},
],
submit: { label: "Save", action_id: "save" },
});
// Toggle A is true → Field A visible
expect(screen.getByText("Field A")).toBeTruthy();
// Toggle B is false → Field B hidden
expect(screen.queryByText("Field B")).toBeNull();
});
});

View File

@@ -0,0 +1,664 @@
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import React from "react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { BlockRenderer } from "../src/renderer.js";
import type { Block, BlockInteraction } from "../src/types.js";
// ── Mocks ────────────────────────────────────────────────────────────────────
vi.mock("@cloudflare/kumo", () => ({
Button: ({ children, onClick, variant, type }: any) => (
<button onClick={onClick} data-variant={variant} type={type || "button"}>
{children}
</button>
),
Badge: ({ children }: any) => <span data-testid="badge">{children}</span>,
Input: ({ label, value, defaultValue, onChange, onBlur, placeholder, type, min, max }: any) => (
<div>
<label>{label}</label>
<input
type={type || "text"}
defaultValue={defaultValue}
value={value}
placeholder={placeholder}
min={min}
max={max}
onChange={onChange}
onBlur={onBlur}
/>
</div>
),
InputArea: ({ label, defaultValue, onChange, onBlur, placeholder }: any) => (
<div>
<label>{label}</label>
<textarea
defaultValue={defaultValue}
placeholder={placeholder}
onChange={onChange}
onBlur={onBlur}
/>
</div>
),
Select: Object.assign(
({ children, label, defaultValue, onValueChange }: any) => (
<div>
<label>{label}</label>
<select defaultValue={defaultValue} onChange={(e: any) => onValueChange?.(e.target.value)}>
{children}
</select>
</div>
),
{
Option: ({ children, value }: any) => <option value={value}>{children}</option>,
},
),
Switch: ({ label, checked, onCheckedChange }: any) => (
<div>
<label>
<input
type="checkbox"
checked={checked}
onChange={(e: any) => onCheckedChange?.(e.target.checked)}
/>
{label}
</label>
</div>
),
SensitiveInput: ({
label,
value,
onValueChange,
readOnly,
onFocus,
onBlur,
placeholder,
}: any) => (
<div>
<label>{label}</label>
<input
type="password"
value={value}
readOnly={readOnly}
onFocus={onFocus}
onBlur={onBlur}
placeholder={placeholder}
onChange={(e: any) => onValueChange?.(e.target.value)}
/>
</div>
),
Dialog: ({ children }: any) => <div data-testid="dialog">{children}</div>,
DialogRoot: ({ children, open }: any) =>
open ? <div data-testid="dialog-root">{children}</div> : null,
Banner: ({ title, description, variant, icon }: any) => (
<div data-testid="banner" data-variant={variant}>
{icon}
{title && <strong>{title}</strong>}
{description && <p>{description}</p>}
</div>
),
Meter: ({ label, value, max, min, customValue }: any) => (
<div data-testid="meter" data-value={value} data-max={max} data-min={min}>
<span>{label}</span>
{customValue && <span>{customValue}</span>}
</div>
),
CodeBlock: ({ code, lang }: any) => (
<pre data-testid="code-block" data-lang={lang}>
<code>{code}</code>
</pre>
),
Empty: ({ title, description, commandLine, size, contents, icon }: any) => (
<div data-testid="empty" data-size={size ?? "base"}>
{icon}
<strong>{title}</strong>
{description && <p>{description}</p>}
{commandLine && <pre data-testid="empty-command">{commandLine}</pre>}
{contents}
</div>
),
Tabs: ({ tabs, value, onValueChange }: any) => (
<div role="tablist">
{tabs.map((tab: any) => (
<button
key={tab.value}
role="tab"
aria-selected={value === tab.value}
onClick={() => onValueChange?.(tab.value)}
>
{tab.label}
</button>
))}
</div>
),
Checkbox: {
Group: ({ children, legend }: any) => (
<fieldset data-testid="checkbox-group">
<legend>{legend}</legend>
{children}
</fieldset>
),
Item: ({ label, value }: any) => (
<label>
<input type="checkbox" value={value} />
{label}
</label>
),
},
Radio: {
Group: ({ children, legend }: any) => (
<fieldset data-testid="radio-group">
<legend>{legend}</legend>
{children}
</fieldset>
),
Item: ({ label, value }: any) => (
<label>
<input type="radio" value={value} />
{label}
</label>
),
},
Collapsible: ({ children, label, open, onOpenChange }: any) => (
<div data-testid="collapsible" data-open={open ? "true" : "false"}>
<button type="button" onClick={() => onOpenChange?.(!open)}>
{label}
</button>
{open && <div data-testid="collapsible-content">{children}</div>}
</div>
),
Combobox: Object.assign(
({ children, label }: any) => (
<div data-testid="combobox">
<label>{label}</label>
{children}
</div>
),
{
TriggerInput: ({ placeholder }: any) => <input placeholder={placeholder} />,
Content: ({ children }: any) => <div>{children}</div>,
List: ({ children }: any) => <div>{typeof children === "function" ? null : children}</div>,
Item: ({ children, value }: any) => <div data-value={value}>{children}</div>,
Empty: ({ children }: any) => <div>{children}</div>,
},
),
}));
vi.mock("@cloudflare/kumo/components/chart", () => ({
TimeseriesChart: (props: any) => (
<div data-testid="timeseries-chart" data-height={props.height} />
),
Chart: (props: any) => <div data-testid="custom-chart" data-height={props.height} />,
ChartPalette: { color: (i: number) => `#color${i}` },
}));
// eslint-disable-next-line unicorn/consistent-function-scoping -- vi.mock is hoisted; cannot reference outer scope
vi.mock("echarts/core", () => {
const noop = () => {};
return { __esModule: true, default: { use: noop }, use: noop };
});
vi.mock("echarts/charts", () => ({
BarChart: {},
LineChart: {},
PieChart: {},
}));
vi.mock("echarts/components", () => ({
AriaComponent: {},
AxisPointerComponent: {},
GridComponent: {},
TooltipComponent: {},
}));
vi.mock("echarts/renderers", () => ({
CanvasRenderer: {},
}));
vi.mock("@phosphor-icons/react", () => ({
ArrowUp: () => <span data-testid="arrow-up" />,
ArrowDown: () => <span data-testid="arrow-down" />,
Minus: () => <span data-testid="minus" />,
Info: () => <span data-testid="icon-info" />,
Warning: () => <span data-testid="icon-warning" />,
WarningCircle: () => <span data-testid="icon-warning-circle" />,
Package: () => <span data-testid="icon-package" />,
}));
afterEach(cleanup);
// ── Helpers ──────────────────────────────────────────────────────────────────
function renderBlocks(blocks: Block[], onAction?: (i: BlockInteraction) => void) {
const handler = onAction ?? vi.fn();
return { ...render(<BlockRenderer blocks={blocks} onAction={handler} />), onAction: handler };
}
// ── Tests ────────────────────────────────────────────────────────────────────
describe("BlockRenderer", () => {
it("header block renders h2 with text", () => {
renderBlocks([{ type: "header", text: "Settings" }]);
const heading = screen.getByText("Settings");
expect(heading.tagName).toBe("H2");
});
it("section block renders text", () => {
renderBlocks([{ type: "section", text: "Configure your integration." }]);
expect(screen.getByText("Configure your integration.")).toBeTruthy();
});
it("section block renders accessory button", () => {
renderBlocks([
{
type: "section",
text: "Webhook endpoint",
accessory: { type: "button", action_id: "edit", label: "Edit" },
},
]);
expect(screen.getByText("Webhook endpoint")).toBeTruthy();
expect(screen.getByText("Edit")).toBeTruthy();
});
it("divider block renders hr", () => {
const { container } = renderBlocks([{ type: "divider" }]);
expect(container.querySelector("hr")).toBeTruthy();
});
it("fields block renders labels and values in grid", () => {
renderBlocks([
{
type: "fields",
fields: [
{ label: "Status", value: "Active" },
{ label: "Plan", value: "Pro" },
],
},
]);
expect(screen.getByText("Status")).toBeTruthy();
expect(screen.getByText("Active")).toBeTruthy();
expect(screen.getByText("Plan")).toBeTruthy();
expect(screen.getByText("Pro")).toBeTruthy();
});
it("fields block sets title attribute on value for overflow tooltip", () => {
const { container } = renderBlocks([
{
type: "fields",
fields: [{ label: "Status", value: "Active" }],
},
]);
const valueEl = container.querySelector('[title="Active"]');
expect(valueEl).toBeTruthy();
expect(valueEl?.textContent).toBe("Active");
});
it("table block renders column headers and row data", () => {
renderBlocks([
{
type: "table",
columns: [
{ key: "name", label: "Name" },
{ key: "role", label: "Role" },
],
rows: [{ name: "Alice", role: "Admin" }],
page_action_id: "page",
},
]);
expect(screen.getByText("Name")).toBeTruthy();
expect(screen.getByText("Role")).toBeTruthy();
expect(screen.getByText("Alice")).toBeTruthy();
expect(screen.getByText("Admin")).toBeTruthy();
});
it("table block shows empty_text when rows empty", () => {
renderBlocks([
{
type: "table",
columns: [{ key: "name", label: "Name" }],
rows: [],
page_action_id: "page",
empty_text: "No items found",
},
]);
expect(screen.getByText("No items found")).toBeTruthy();
});
it("table badge format renders Badge component", () => {
renderBlocks([
{
type: "table",
columns: [{ key: "status", label: "Status", format: "badge" as const }],
rows: [{ status: "Active" }],
page_action_id: "page",
},
]);
expect(screen.getByTestId("badge")).toBeTruthy();
expect(screen.getByTestId("badge").textContent).toBe("Active");
});
it("actions block renders buttons horizontally", () => {
renderBlocks([
{
type: "actions",
elements: [
{ type: "button", action_id: "a1", label: "Save" },
{ type: "button", action_id: "a2", label: "Cancel" },
],
},
]);
expect(screen.getByText("Save")).toBeTruthy();
expect(screen.getByText("Cancel")).toBeTruthy();
});
it("stats block renders stat cards with values", () => {
renderBlocks([
{
type: "stats",
items: [
{ label: "Posts", value: 120 },
{ label: "Users", value: "5k" },
],
},
]);
expect(screen.getByText("Posts")).toBeTruthy();
expect(screen.getByText("120")).toBeTruthy();
expect(screen.getByText("Users")).toBeTruthy();
expect(screen.getByText("5k")).toBeTruthy();
});
it("stats block renders trend arrows", () => {
renderBlocks([
{
type: "stats",
items: [
{ label: "Revenue", value: 100, trend: "up" },
{ label: "Errors", value: 3, trend: "down" },
{ label: "Latency", value: "50ms", trend: "neutral" },
],
},
]);
expect(screen.getByTestId("arrow-up")).toBeTruthy();
expect(screen.getByTestId("arrow-down")).toBeTruthy();
expect(screen.getByTestId("minus")).toBeTruthy();
});
it("form block renders fields and submit button", () => {
renderBlocks([
{
type: "form",
fields: [{ type: "text_input", action_id: "title", label: "Title" }],
submit: { label: "Create", action_id: "create" },
},
]);
expect(screen.getByText("Title")).toBeTruthy();
expect(screen.getByText("Create")).toBeTruthy();
});
it("form onAction fires form_submit with collected values", () => {
const onAction = vi.fn();
renderBlocks(
[
{
type: "form",
block_id: "my_form",
fields: [
{
type: "text_input",
action_id: "title",
label: "Title",
initial_value: "Hello",
},
{
type: "toggle",
action_id: "published",
label: "Published",
initial_value: true,
},
],
submit: { label: "Save", action_id: "save_post" },
},
],
onAction,
);
// Submit the form
const submitBtn = screen.getByText("Save");
fireEvent.click(submitBtn);
expect(onAction).toHaveBeenCalledWith({
type: "form_submit",
action_id: "save_post",
block_id: "my_form",
values: { title: "Hello", published: true },
});
});
it("image block renders img with src and alt", () => {
renderBlocks([
{
type: "image",
url: "https://example.com/photo.jpg",
alt: "A photo",
},
]);
const img = screen.getByAltText("A photo") as HTMLImageElement;
expect(img.src).toBe("https://example.com/photo.jpg");
});
it("context block renders small muted text", () => {
renderBlocks([{ type: "context", text: "Updated just now" }]);
const el = screen.getByText("Updated just now");
expect(el.tagName).toBe("P");
expect(el.className).toContain("text-sm");
});
it("empty block renders title and default icon", () => {
renderBlocks([{ type: "empty", title: "No items" }]);
expect(screen.getByText("No items")).toBeTruthy();
expect(screen.getByTestId("icon-package")).toBeTruthy();
expect(screen.getByTestId("empty").getAttribute("data-size")).toBe("base");
});
it("empty block renders description, command line, size, and action buttons", () => {
const onAction = vi.fn();
renderBlocks(
[
{
type: "empty",
title: "No webhooks yet",
description: "Create your first webhook.",
command_line: "emdash webhooks create",
size: "lg",
actions: [
{ type: "button", action_id: "create", label: "Create webhook", style: "primary" },
],
},
],
onAction,
);
expect(screen.getByText("Create your first webhook.")).toBeTruthy();
expect(screen.getByTestId("empty-command").textContent).toBe("emdash webhooks create");
expect(screen.getByTestId("empty").getAttribute("data-size")).toBe("lg");
fireEvent.click(screen.getByText("Create webhook"));
expect(onAction).toHaveBeenCalledWith({ type: "block_action", action_id: "create" });
});
it("empty block omits contents when actions array is empty", () => {
const { container } = renderBlocks([{ type: "empty", title: "X", actions: [] }]);
expect(container.querySelectorAll("button").length).toBe(0);
});
it("accordion block renders label closed by default and reveals nested blocks on open", () => {
const { container } = renderBlocks([
{
type: "accordion",
label: "Advanced",
blocks: [{ type: "header", text: "Hidden heading" }],
},
]);
expect(screen.getByText("Advanced")).toBeTruthy();
expect(container.querySelector('[data-testid="collapsible"]')?.getAttribute("data-open")).toBe(
"false",
);
expect(screen.queryByText("Hidden heading")).toBeNull();
fireEvent.click(screen.getByText("Advanced"));
expect(screen.getByText("Hidden heading")).toBeTruthy();
});
it("accordion block respects default_open and forwards onAction from nested blocks", () => {
const onAction = vi.fn();
renderBlocks(
[
{
type: "accordion",
label: "Tools",
default_open: true,
blocks: [
{
type: "actions",
elements: [{ type: "button", action_id: "ping", label: "Ping" }],
},
],
},
],
onAction,
);
fireEvent.click(screen.getByText("Ping"));
expect(onAction).toHaveBeenCalledWith({ type: "block_action", action_id: "ping" });
});
it("columns block renders blocks in columns", () => {
renderBlocks([
{
type: "columns",
columns: [[{ type: "header", text: "Left" }], [{ type: "header", text: "Right" }]],
},
]);
expect(screen.getByText("Left")).toBeTruthy();
expect(screen.getByText("Right")).toBeTruthy();
});
it("tab block renders panel labels and shows first panel by default", () => {
renderBlocks([
{
type: "tab",
panels: [
{ label: "General", blocks: [{ type: "header", text: "General Settings" }] },
{ label: "Advanced", blocks: [{ type: "header", text: "Advanced Settings" }] },
],
},
]);
expect(screen.getByText("General")).toBeTruthy();
expect(screen.getByText("Advanced")).toBeTruthy();
expect(screen.getByText("General Settings")).toBeTruthy();
expect(screen.queryByText("Advanced Settings")).toBeNull();
});
it("tab block switches panel on tab click", () => {
renderBlocks([
{
type: "tab",
panels: [
{ label: "General", blocks: [{ type: "header", text: "General Settings" }] },
{ label: "Advanced", blocks: [{ type: "header", text: "Advanced Settings" }] },
],
},
]);
fireEvent.click(screen.getByText("Advanced"));
expect(screen.queryByText("General Settings")).toBeNull();
expect(screen.getByText("Advanced Settings")).toBeTruthy();
});
it("tab block respects default_tab", () => {
renderBlocks([
{
type: "tab",
default_tab: 1,
panels: [
{ label: "General", blocks: [{ type: "header", text: "General Settings" }] },
{ label: "Advanced", blocks: [{ type: "header", text: "Advanced Settings" }] },
],
},
]);
expect(screen.queryByText("General Settings")).toBeNull();
expect(screen.getByText("Advanced Settings")).toBeTruthy();
});
it("button click fires onAction with block_action", () => {
const onAction = vi.fn();
renderBlocks(
[
{
type: "actions",
elements: [
{
type: "button",
action_id: "do_thing",
label: "Do thing",
value: { id: 42 },
},
],
},
],
onAction,
);
fireEvent.click(screen.getByText("Do thing"));
expect(onAction).toHaveBeenCalledWith({
type: "block_action",
action_id: "do_thing",
value: { id: 42 },
});
});
it("button with confirm shows dialog, confirm fires action", () => {
const onAction = vi.fn();
renderBlocks(
[
{
type: "actions",
elements: [
{
type: "button",
action_id: "delete_item",
label: "Delete",
style: "danger",
value: "item_1",
confirm: {
title: "Delete item?",
text: "This cannot be undone.",
confirm: "Yes, delete",
deny: "Cancel",
},
},
],
},
],
onAction,
);
// Initially no dialog
expect(screen.queryByTestId("dialog-root")).toBeNull();
// Click button — dialog appears
fireEvent.click(screen.getByText("Delete"));
expect(screen.getByTestId("dialog-root")).toBeTruthy();
expect(screen.getByText("Delete item?")).toBeTruthy();
expect(screen.getByText("This cannot be undone.")).toBeTruthy();
// Click confirm — fires action
fireEvent.click(screen.getByText("Yes, delete"));
expect(onAction).toHaveBeenCalledWith({
type: "block_action",
action_id: "delete_item",
value: "item_1",
});
});
});

View File

@@ -0,0 +1,821 @@
import { describe, expect, it } from "vitest";
import { validateBlocks } from "../src/validation.js";
describe("validateBlocks", () => {
// ── Valid blocks ─────────────────────────────────────────────────────────
describe("valid blocks", () => {
it("header", () => {
const result = validateBlocks([{ type: "header", text: "Hello" }]);
expect(result).toEqual({ valid: true, errors: [] });
});
it("section", () => {
const result = validateBlocks([{ type: "section", text: "Body text" }]);
expect(result).toEqual({ valid: true, errors: [] });
});
it("divider", () => {
const result = validateBlocks([{ type: "divider" }]);
expect(result).toEqual({ valid: true, errors: [] });
});
it("fields", () => {
const result = validateBlocks([
{
type: "fields",
fields: [{ label: "Status", value: "Active" }],
},
]);
expect(result).toEqual({ valid: true, errors: [] });
});
it("table", () => {
const result = validateBlocks([
{
type: "table",
columns: [{ key: "name", label: "Name" }],
rows: [{ name: "Alice" }],
page_action_id: "load_page",
},
]);
expect(result).toEqual({ valid: true, errors: [] });
});
it("actions", () => {
const result = validateBlocks([
{
type: "actions",
elements: [{ type: "button", action_id: "btn1", label: "Click me" }],
},
]);
expect(result).toEqual({ valid: true, errors: [] });
});
it("stats", () => {
const result = validateBlocks([
{
type: "stats",
items: [{ label: "Users", value: 42 }],
},
]);
expect(result).toEqual({ valid: true, errors: [] });
});
it("form", () => {
const result = validateBlocks([
{
type: "form",
fields: [{ type: "text_input", action_id: "name", label: "Name" }],
submit: { label: "Save", action_id: "save" },
},
]);
expect(result).toEqual({ valid: true, errors: [] });
});
it("image", () => {
const result = validateBlocks([
{ type: "image", url: "https://example.com/img.png", alt: "Photo" },
]);
expect(result).toEqual({ valid: true, errors: [] });
});
it("context", () => {
const result = validateBlocks([{ type: "context", text: "Last updated 5m ago" }]);
expect(result).toEqual({ valid: true, errors: [] });
});
it("columns", () => {
const result = validateBlocks([
{
type: "columns",
columns: [[{ type: "header", text: "Left" }], [{ type: "header", text: "Right" }]],
},
]);
expect(result).toEqual({ valid: true, errors: [] });
});
it("empty (minimal)", () => {
const result = validateBlocks([{ type: "empty", title: "No items" }]);
expect(result).toEqual({ valid: true, errors: [] });
});
it("empty (full)", () => {
const result = validateBlocks([
{
type: "empty",
title: "No webhooks yet",
description: "Create your first webhook to receive notifications.",
command_line: "emdash webhooks create",
size: "lg",
actions: [
{ type: "button", action_id: "create", label: "Create webhook", style: "primary" },
{ type: "button", action_id: "import", label: "Import" },
],
},
]);
expect(result).toEqual({ valid: true, errors: [] });
});
it("accordion", () => {
const result = validateBlocks([
{
type: "accordion",
label: "Advanced settings",
default_open: false,
blocks: [{ type: "section", text: "Hidden content" }, { type: "divider" }],
},
]);
expect(result).toEqual({ valid: true, errors: [] });
});
it("accordion with empty blocks array", () => {
const result = validateBlocks([{ type: "accordion", label: "Empty", blocks: [] }]);
expect(result).toEqual({ valid: true, errors: [] });
});
it("repeater", () => {
const result = validateBlocks([
{
type: "actions",
elements: [
{
type: "repeater",
action_id: "faqs",
label: "FAQs",
item_label: "FAQ",
min_items: 1,
max_items: 5,
fields: [
{ type: "text_input", action_id: "question", label: "Question" },
{ type: "text_input", action_id: "answer", label: "Answer", multiline: true },
],
initial_value: [{ question: "Q1", answer: "A1" }],
},
],
},
]);
expect(result).toEqual({ valid: true, errors: [] });
});
it("media_picker (minimal)", () => {
const result = validateBlocks([
{
type: "actions",
elements: [{ type: "media_picker", action_id: "hero", label: "Hero" }],
},
]);
expect(result).toEqual({ valid: true, errors: [] });
});
it("media_picker (with options)", () => {
const result = validateBlocks([
{
type: "actions",
elements: [
{
type: "media_picker",
action_id: "hero",
label: "Hero",
mime_type_filter: "image/",
initial_value: "/_emdash/api/media/file/abc.png",
placeholder: "Pick a hero image",
},
],
},
]);
expect(result).toEqual({ valid: true, errors: [] });
});
it("media_picker (specific subtype)", () => {
const result = validateBlocks([
{
type: "actions",
elements: [
{
type: "media_picker",
action_id: "logo",
label: "Logo",
mime_type_filter: "image/svg+xml",
},
],
},
]);
expect(result).toEqual({ valid: true, errors: [] });
});
});
// ── Invalid blocks ───────────────────────────────────────────────────────
describe("invalid blocks", () => {
it("not an array", () => {
const result = validateBlocks("not an array");
expect(result.valid).toBe(false);
expect(result.errors).toEqual([{ path: "blocks", message: "Blocks must be an array" }]);
});
it("block without type", () => {
const result = validateBlocks([{ text: "hello" }]);
expect(result.valid).toBe(false);
expect(result.errors[0]!.path).toBe("blocks[0].type");
expect(result.errors[0]!.message).toContain("Unknown block type");
});
it("block with unknown type", () => {
const result = validateBlocks([{ type: "foobar" }]);
expect(result.valid).toBe(false);
expect(result.errors[0]!.path).toBe("blocks[0].type");
expect(result.errors[0]!.message).toContain("Unknown block type 'foobar'");
});
it("header missing text", () => {
const result = validateBlocks([{ type: "header" }]);
expect(result.valid).toBe(false);
expect(result.errors).toEqual([
{
path: "blocks[0].text",
message: "Required field 'text' must be a string",
},
]);
});
it("section missing text", () => {
const result = validateBlocks([{ type: "section" }]);
expect(result.valid).toBe(false);
expect(result.errors[0]!.path).toBe("blocks[0].text");
});
it("table missing required fields", () => {
const result = validateBlocks([{ type: "table" }]);
expect(result.valid).toBe(false);
const paths = result.errors.map((e) => e.path);
expect(paths).toContain("blocks[0].columns");
expect(paths).toContain("blocks[0].rows");
expect(paths).toContain("blocks[0].page_action_id");
});
it("table column missing key or label", () => {
const result = validateBlocks([
{
type: "table",
columns: [{ format: "text" }],
rows: [],
page_action_id: "p",
},
]);
expect(result.valid).toBe(false);
const paths = result.errors.map((e) => e.path);
expect(paths).toContain("blocks[0].columns[0].key");
expect(paths).toContain("blocks[0].columns[0].label");
});
it("table column with invalid format", () => {
const result = validateBlocks([
{
type: "table",
columns: [{ key: "k", label: "K", format: "html" }],
rows: [],
page_action_id: "p",
},
]);
expect(result.valid).toBe(false);
expect(result.errors[0]!.path).toBe("blocks[0].columns[0].format");
expect(result.errors[0]!.message).toContain("format");
});
it("form missing fields or submit", () => {
const result = validateBlocks([{ type: "form" }]);
expect(result.valid).toBe(false);
const paths = result.errors.map((e) => e.path);
expect(paths).toContain("blocks[0].fields");
expect(paths).toContain("blocks[0].submit");
});
it("form submit missing action_id", () => {
const result = validateBlocks([
{
type: "form",
fields: [],
submit: { label: "Save" },
},
]);
expect(result.valid).toBe(false);
expect(result.errors[0]!.path).toBe("blocks[0].submit.action_id");
});
it("actions with invalid elements", () => {
const result = validateBlocks([
{
type: "actions",
elements: [{ type: "invalid_type" }],
},
]);
expect(result.valid).toBe(false);
expect(result.errors[0]!.path).toBe("blocks[0].elements[0].type");
expect(result.errors[0]!.message).toContain("Unknown element type");
});
it("select with empty options array", () => {
const result = validateBlocks([
{
type: "actions",
elements: [
{
type: "select",
action_id: "sel",
label: "Pick",
options: [],
},
],
},
]);
expect(result.valid).toBe(false);
expect(result.errors[0]!.path).toBe("blocks[0].elements[0].options");
expect(result.errors[0]!.message).toContain("must not be empty");
});
it("select option missing label/value", () => {
const result = validateBlocks([
{
type: "actions",
elements: [
{
type: "select",
action_id: "sel",
label: "Pick",
options: [{ foo: "bar" }],
},
],
},
]);
expect(result.valid).toBe(false);
const paths = result.errors.map((e) => e.path);
expect(paths).toContain("blocks[0].elements[0].options[0].label");
expect(paths).toContain("blocks[0].elements[0].options[0].value");
});
it("button with invalid style", () => {
const result = validateBlocks([
{
type: "actions",
elements: [
{
type: "button",
action_id: "btn",
label: "Go",
style: "bold",
},
],
},
]);
expect(result.valid).toBe(false);
expect(result.errors[0]!.path).toBe("blocks[0].elements[0].style");
});
it("confirm dialog missing required fields", () => {
const result = validateBlocks([
{
type: "actions",
elements: [
{
type: "button",
action_id: "btn",
label: "Delete",
confirm: {},
},
],
},
]);
expect(result.valid).toBe(false);
const paths = result.errors.map((e) => e.path);
expect(paths).toContain("blocks[0].elements[0].confirm.title");
expect(paths).toContain("blocks[0].elements[0].confirm.text");
expect(paths).toContain("blocks[0].elements[0].confirm.confirm");
expect(paths).toContain("blocks[0].elements[0].confirm.deny");
});
it("image missing url or alt", () => {
const result = validateBlocks([{ type: "image" }]);
expect(result.valid).toBe(false);
const paths = result.errors.map((e) => e.path);
expect(paths).toContain("blocks[0].url");
expect(paths).toContain("blocks[0].alt");
});
it("columns with less than 2 arrays", () => {
const result = validateBlocks([
{
type: "columns",
columns: [[{ type: "divider" }]],
},
]);
expect(result.valid).toBe(false);
expect(result.errors[0]!.path).toBe("blocks[0].columns");
expect(result.errors[0]!.message).toContain("2-3 column arrays");
});
it("columns with more than 3 arrays", () => {
const result = validateBlocks([
{
type: "columns",
columns: [
[{ type: "divider" }],
[{ type: "divider" }],
[{ type: "divider" }],
[{ type: "divider" }],
],
},
]);
expect(result.valid).toBe(false);
expect(result.errors[0]!.message).toContain("2-3 column arrays");
});
it("columns with invalid nested blocks reports correct path", () => {
const result = validateBlocks([
{
type: "columns",
columns: [
[{ type: "header", text: "OK" }],
[{ type: "header" }], // missing text
],
},
]);
expect(result.valid).toBe(false);
expect(result.errors[0]!.path).toBe("blocks[0].columns[1][0].text");
});
it("empty missing title", () => {
const result = validateBlocks([{ type: "empty" }]);
expect(result.valid).toBe(false);
expect(result.errors[0]!.path).toBe("blocks[0].title");
});
it("empty with invalid size", () => {
const result = validateBlocks([{ type: "empty", title: "X", size: "huge" }]);
expect(result.valid).toBe(false);
expect(result.errors[0]!.path).toBe("blocks[0].size");
});
it("empty with non-array actions", () => {
const result = validateBlocks([{ type: "empty", title: "X", actions: "nope" }]);
expect(result.valid).toBe(false);
expect(result.errors[0]!.path).toBe("blocks[0].actions");
});
it("empty with invalid action element reports correct path", () => {
const result = validateBlocks([
{
type: "empty",
title: "X",
actions: [{ type: "button", action_id: "go", label: "Go", style: "neon" }],
},
]);
expect(result.valid).toBe(false);
expect(result.errors[0]!.path).toBe("blocks[0].actions[0].style");
});
it("accordion missing label", () => {
const result = validateBlocks([{ type: "accordion", blocks: [] }]);
expect(result.valid).toBe(false);
expect(result.errors.map((e) => e.path)).toContain("blocks[0].label");
});
it("accordion with invalid nested blocks reports correct path", () => {
const result = validateBlocks([
{
type: "accordion",
label: "Wrap",
blocks: [{ type: "header" }],
},
]);
expect(result.valid).toBe(false);
expect(result.errors[0]!.path).toBe("blocks[0].blocks[0].text");
});
it("accordion with non-boolean default_open", () => {
const result = validateBlocks([
{ type: "accordion", label: "Wrap", blocks: [], default_open: "yes" },
]);
expect(result.valid).toBe(false);
expect(result.errors[0]!.path).toBe("blocks[0].default_open");
});
it("stats item missing label or value", () => {
const result = validateBlocks([
{
type: "stats",
items: [{ description: "desc" }],
},
]);
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].value");
});
it("stats item with invalid trend", () => {
const result = validateBlocks([
{
type: "stats",
items: [{ label: "Users", value: 10, trend: "sideways" }],
},
]);
expect(result.valid).toBe(false);
expect(result.errors[0]!.path).toBe("blocks[0].items[0].trend");
});
it("repeater with empty fields array", () => {
const result = validateBlocks([
{
type: "actions",
elements: [{ type: "repeater", action_id: "items", label: "Items", fields: [] }],
},
]);
expect(result.valid).toBe(false);
expect(result.errors[0]!.path).toBe("blocks[0].elements[0].fields");
expect(result.errors[0]!.message).toContain("must not be empty");
});
it("repeater with disallowed sub-field type", () => {
const result = validateBlocks([
{
type: "actions",
elements: [
{
type: "repeater",
action_id: "items",
label: "Items",
fields: [
{
type: "checkbox",
action_id: "opts",
label: "Opts",
options: [{ label: "A", value: "a" }],
},
],
},
],
},
]);
expect(result.valid).toBe(false);
const paths = result.errors.map((e) => e.path);
expect(paths).toContain("blocks[0].elements[0].fields[0].type");
expect(
result.errors.find((e) => e.path === "blocks[0].elements[0].fields[0].type")!.message,
).toContain("not allowed");
});
it("repeater with non-integer min_items / max_items", () => {
const result = validateBlocks([
{
type: "actions",
elements: [
{
type: "repeater",
action_id: "items",
label: "Items",
fields: [{ type: "text_input", action_id: "q", label: "Q" }],
min_items: 1.5,
max_items: -2,
},
],
},
]);
expect(result.valid).toBe(false);
const paths = result.errors.map((e) => e.path);
expect(paths).toContain("blocks[0].elements[0].min_items");
expect(paths).toContain("blocks[0].elements[0].max_items");
});
it("repeater with min_items greater than max_items", () => {
const result = validateBlocks([
{
type: "actions",
elements: [
{
type: "repeater",
action_id: "items",
label: "Items",
fields: [{ type: "text_input", action_id: "q", label: "Q" }],
min_items: 5,
max_items: 2,
},
],
},
]);
expect(result.valid).toBe(false);
expect(result.errors[0]!.path).toBe("blocks[0].elements[0].min_items");
expect(result.errors[0]!.message).toContain("less than or equal to 'max_items'");
});
it("repeater initial_value not an array", () => {
const result = validateBlocks([
{
type: "actions",
elements: [
{
type: "repeater",
action_id: "items",
label: "Items",
fields: [{ type: "text_input", action_id: "q", label: "Q" }],
initial_value: "not-an-array",
},
],
},
]);
expect(result.valid).toBe(false);
expect(result.errors[0]!.path).toBe("blocks[0].elements[0].initial_value");
expect(result.errors[0]!.message).toContain("must be an array");
});
it("repeater initial_value entry not an object", () => {
const result = validateBlocks([
{
type: "actions",
elements: [
{
type: "repeater",
action_id: "items",
label: "Items",
fields: [{ type: "text_input", action_id: "q", label: "Q" }],
initial_value: [{ q: "ok" }, "bad", null],
},
],
},
]);
expect(result.valid).toBe(false);
const paths = result.errors.map((e) => e.path);
expect(paths).toContain("blocks[0].elements[0].initial_value[1]");
expect(paths).toContain("blocks[0].elements[0].initial_value[2]");
});
it("form field with invalid condition (no eq/neq)", () => {
const result = validateBlocks([
{
type: "form",
fields: [
{
type: "text_input",
action_id: "name",
label: "Name",
condition: { field: "toggle" },
},
],
submit: { label: "Save", action_id: "save" },
},
]);
expect(result.valid).toBe(false);
expect(result.errors[0]!.path).toBe("blocks[0].fields[0].condition");
expect(result.errors[0]!.message).toContain("either 'eq' or 'neq'");
});
it("media_picker mime_type_filter must be a string", () => {
const result = validateBlocks([
{
type: "actions",
elements: [
{ type: "media_picker", action_id: "hero", label: "Hero", mime_type_filter: 42 },
],
},
]);
expect(result.valid).toBe(false);
expect(result.errors[0]!.path).toBe("blocks[0].elements[0].mime_type_filter");
expect(result.errors[0]!.message).toContain("must be a string");
});
it("media_picker mime_type_filter rejects missing slash", () => {
const result = validateBlocks([
{
type: "actions",
elements: [
{ type: "media_picker", action_id: "hero", label: "Hero", mime_type_filter: "image" },
],
},
]);
expect(result.valid).toBe(false);
expect(result.errors[0]!.path).toBe("blocks[0].elements[0].mime_type_filter");
expect(result.errors[0]!.message).toContain("image MIME type or prefix");
});
it("media_picker mime_type_filter rejects non-image type", () => {
const result = validateBlocks([
{
type: "actions",
elements: [
{ type: "media_picker", action_id: "v", label: "Video", mime_type_filter: "video/" },
],
},
]);
expect(result.valid).toBe(false);
expect(result.errors[0]!.path).toBe("blocks[0].elements[0].mime_type_filter");
});
it("media_picker mime_type_filter rejects wildcard", () => {
const result = validateBlocks([
{
type: "actions",
elements: [
{
type: "media_picker",
action_id: "hero",
label: "Hero",
mime_type_filter: "image/*",
},
],
},
]);
expect(result.valid).toBe(false);
expect(result.errors[0]!.path).toBe("blocks[0].elements[0].mime_type_filter");
});
it("media_picker initial_value must be a string", () => {
const result = validateBlocks([
{
type: "actions",
elements: [
{
type: "media_picker",
action_id: "hero",
label: "Hero",
initial_value: 42,
},
],
},
]);
expect(result.valid).toBe(false);
expect(result.errors[0]!.path).toBe("blocks[0].elements[0].initial_value");
expect(result.errors[0]!.message).toContain("must be a string");
});
it("media_picker placeholder must be a string", () => {
const result = validateBlocks([
{
type: "actions",
elements: [
{
type: "media_picker",
action_id: "hero",
label: "Hero",
placeholder: false,
},
],
},
]);
expect(result.valid).toBe(false);
expect(result.errors[0]!.path).toBe("blocks[0].elements[0].placeholder");
expect(result.errors[0]!.message).toContain("must be a string");
});
});
// ── Edge cases ───────────────────────────────────────────────────────────
describe("edge cases", () => {
it("empty blocks array is valid", () => {
const result = validateBlocks([]);
expect(result).toEqual({ valid: true, errors: [] });
});
it("deeply nested columns validate recursively", () => {
const result = validateBlocks([
{
type: "columns",
columns: [
[
{
type: "columns",
columns: [
[{ type: "header", text: "Deep left" }],
[{ type: "header" }], // missing text
],
},
],
[{ type: "divider" }],
],
},
]);
expect(result.valid).toBe(false);
expect(result.errors[0]!.path).toBe("blocks[0].columns[0][0].columns[1][0].text");
});
it("multiple errors in one block are all reported", () => {
const result = validateBlocks([
{
type: "table",
columns: [{ format: "invalid" }], // missing key, label, bad format
rows: "not an array",
// missing page_action_id
},
]);
expect(result.valid).toBe(false);
// Should have errors for key, label, format, rows, and page_action_id
expect(result.errors.length).toBeGreaterThanOrEqual(4);
const paths = result.errors.map((e) => e.path);
expect(paths).toContain("blocks[0].columns[0].key");
expect(paths).toContain("blocks[0].columns[0].label");
expect(paths).toContain("blocks[0].columns[0].format");
expect(paths).toContain("blocks[0].rows");
expect(paths).toContain("blocks[0].page_action_id");
});
});
});