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:
41
packages/blocks/CHANGELOG.md
Normal file
41
packages/blocks/CHANGELOG.md
Normal file
@@ -0,0 +1,41 @@
|
||||
# @emdash-cms/blocks
|
||||
|
||||
## 0.9.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- [#790](https://github.com/emdash-cms/emdash/pull/790) [`7b8d496`](https://github.com/emdash-cms/emdash/commit/7b8d4964c619821937d1a738cbd6f81e98095a91) Thanks [@all3f0r1](https://github.com/all3f0r1)! - Adds an `accordion` Block Kit block: a collapsible container that wraps nested blocks under a labeled trigger. Open/closed state is local to the rendered component (with optional `default_open`), so plugin admin pages can hide advanced settings, FAQs, or auxiliary panels without paginating or round-tripping through `block_action`.
|
||||
|
||||
- [#731](https://github.com/emdash-cms/emdash/pull/731) [`9dfc65c`](https://github.com/emdash-cms/emdash/commit/9dfc65c42c04c41088e0c8f5a8ca4347643e2fea) Thanks [@drudge](https://github.com/drudge)! - Adds a `media_picker` Block Kit element: a thumbnail preview with a modal library picker and mime-type filter. Usable in plugin block forms and in Block Kit field widgets. The stored value is the selected asset's URL string, so it is value-compatible with a plain `text_input` — existing content continues to work after swapping. The `mime_type_filter` is restricted to image MIME types (`image/` or `image/<subtype>`); wildcards and non-image types are rejected.
|
||||
|
||||
- [#814](https://github.com/emdash-cms/emdash/pull/814) [`a838000`](https://github.com/emdash-cms/emdash/commit/a83800068678daf6391e02bba8acf27ff4db0e19) Thanks [@arashackdev](https://github.com/arashackdev)! - rtl srtyle improvements and LTR/RTL compatible arrow/caret icons
|
||||
|
||||
## 0.8.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- [#792](https://github.com/emdash-cms/emdash/pull/792) [`6e0e921`](https://github.com/emdash-cms/emdash/commit/6e0e9215e00f6f2e84ade30447e4c30b1812dbf5) Thanks [@all3f0r1](https://github.com/all3f0r1)! - Adds an `empty` Block Kit block: a styled empty-state placeholder with title, optional description, copyable shell command, size variant (`sm`/`base`/`lg`), and an optional list of action elements (CTAs). Plugin admin pages can now render proper empty states for lists, tables, and onboarding flows without rolling their own layout.
|
||||
|
||||
- [#679](https://github.com/emdash-cms/emdash/pull/679) [`493e317`](https://github.com/emdash-cms/emdash/commit/493e3172d4539d8e041e6d2bf2d7d2dc89b2a10d) Thanks [@drudge](https://github.com/drudge)! - Adds a `repeater` Block Kit element: array-of-objects with scalar sub-fields, drag-to-reorder, and collapsible item cards. Plugin block forms can now capture repeating data (FAQ rows, carousel slides, card grids) inline in the portable-text editor.
|
||||
|
||||
## 0.7.0
|
||||
|
||||
## 0.6.0
|
||||
|
||||
## 0.5.0
|
||||
|
||||
## 0.4.0
|
||||
|
||||
## 0.3.0
|
||||
|
||||
## 0.2.0
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- [#246](https://github.com/emdash-cms/emdash/pull/246) [`e1349e3`](https://github.com/emdash-cms/emdash/commit/e1349e342f90227c50f253cc2c1fbda0bc288a39) Thanks [@estepanov](https://github.com/estepanov)! - Adds overflow-hidden and text-ellipsis to field value elements, with a title attribute for full-text tooltip on hover
|
||||
|
||||
## 0.1.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- [#14](https://github.com/emdash-cms/emdash/pull/14) [`755b501`](https://github.com/emdash-cms/emdash/commit/755b5017906811f97f78f4c0b5a0b62e67b52ec4) Thanks [@ascorbic](https://github.com/ascorbic)! - First beta release
|
||||
68
packages/blocks/package.json
Normal file
68
packages/blocks/package.json
Normal file
@@ -0,0 +1,68 @@
|
||||
{
|
||||
"name": "@emdash-cms/blocks",
|
||||
"version": "0.9.0",
|
||||
"description": "Declarative plugin UI blocks for EmDash CMS",
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"default": "./dist/index.js"
|
||||
},
|
||||
"./server": {
|
||||
"types": "./dist/server.d.ts",
|
||||
"default": "./dist/server.js"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsdown",
|
||||
"dev": "tsdown --watch",
|
||||
"prepublishOnly": "node --run build",
|
||||
"check": "publint && attw --pack --ignore-rules=cjs-resolves-to-esm --ignore-rules=no-resolution",
|
||||
"test": "vitest",
|
||||
"typecheck": "tsgo --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@cloudflare/kumo": "^1.10.0",
|
||||
"@phosphor-icons/react": "catalog:",
|
||||
"clsx": "^2.1.1",
|
||||
"echarts": "^6.0.0",
|
||||
"tailwind-merge": "^3.3.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@arethetypeswrong/cli": "catalog:",
|
||||
"@testing-library/react": "^16.3.0",
|
||||
"@types/react": "catalog:",
|
||||
"@types/react-dom": "catalog:",
|
||||
"@vitejs/plugin-react": "^4.6.0",
|
||||
"jsdom": "^26.1.0",
|
||||
"publint": "catalog:",
|
||||
"react": "catalog:",
|
||||
"react-dom": "catalog:",
|
||||
"tsdown": "catalog:",
|
||||
"typescript": "catalog:",
|
||||
"vitest": "catalog:"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^18.0.0 || ^19.0.0",
|
||||
"react-dom": "^18.0.0 || ^19.0.0"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/emdash-cms/emdash.git",
|
||||
"directory": "packages/blocks"
|
||||
},
|
||||
"homepage": "https://github.com/emdash-cms/emdash",
|
||||
"keywords": [
|
||||
"emdash",
|
||||
"cms",
|
||||
"blocks",
|
||||
"plugin-ui",
|
||||
"react"
|
||||
],
|
||||
"author": "Matt Kane",
|
||||
"license": "MIT"
|
||||
}
|
||||
12
packages/blocks/playground/index.html
Normal file
12
packages/blocks/playground/index.html
Normal file
@@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>EmDash Block Kit Playground</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
30
packages/blocks/playground/package.json
Normal file
30
packages/blocks/playground/package.json
Normal file
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"name": "@emdash-cms/blocks-playground",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@emdash-cms/blocks": "workspace:*",
|
||||
"@cloudflare/kumo": "^1.1.0",
|
||||
"@phosphor-icons/react": "catalog:",
|
||||
"react": "catalog:",
|
||||
"react-dom": "catalog:"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/vite": "^4.1.11",
|
||||
"@types/react": "catalog:",
|
||||
"@types/react-dom": "catalog:",
|
||||
"@vitejs/plugin-react": "^4.6.0",
|
||||
"tailwindcss": "^4.1.11",
|
||||
"typescript": "catalog:",
|
||||
"vite": "^6.3.5",
|
||||
"wrangler": "^4.63.0"
|
||||
},
|
||||
"peerDependencies": {},
|
||||
"optionalDependencies": {}
|
||||
}
|
||||
419
packages/blocks/playground/src/Playground.tsx
Normal file
419
packages/blocks/playground/src/Playground.tsx
Normal file
@@ -0,0 +1,419 @@
|
||||
import { BlockRenderer, validateBlocks } from "@emdash-cms/blocks";
|
||||
import type { Block, BlockInteraction } from "@emdash-cms/blocks";
|
||||
import { Sun, Moon, Share, Check, Trash, CaretDown, Warning, Plus } from "@phosphor-icons/react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
import { blockCatalog } from "./block-defaults";
|
||||
import { templates } from "./templates";
|
||||
import { useResizable } from "./useResizable";
|
||||
|
||||
// ── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface ActionLogEntry {
|
||||
id: number;
|
||||
timestamp: Date;
|
||||
interaction: BlockInteraction;
|
||||
}
|
||||
|
||||
// ── Hash sharing ─────────────────────────────────────────────────────────────
|
||||
|
||||
function encodeToHash(blocks: Block[]): string {
|
||||
try {
|
||||
const json = JSON.stringify(blocks);
|
||||
return btoa(encodeURIComponent(json));
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
function decodeFromHash(hash: string): Block[] | null {
|
||||
try {
|
||||
const json = decodeURIComponent(atob(hash));
|
||||
const parsed: unknown = JSON.parse(json);
|
||||
if (!Array.isArray(parsed)) return null;
|
||||
const result = validateBlocks(parsed);
|
||||
if (!result.valid) return null;
|
||||
return parsed as Block[];
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Drag handle ──────────────────────────────────────────────────────────────
|
||||
|
||||
function DragHandle({
|
||||
onMouseDown,
|
||||
isDragging,
|
||||
}: {
|
||||
onMouseDown: (e: React.MouseEvent) => void;
|
||||
isDragging: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={`group relative w-[5px] shrink-0 cursor-col-resize ${isDragging ? "bg-kumo-info/40" : ""}`}
|
||||
onMouseDown={onMouseDown}
|
||||
>
|
||||
{/* Visible border line */}
|
||||
<div className="absolute inset-y-0 left-1/2 w-px -translate-x-1/2 bg-kumo-line group-hover:bg-kumo-info/50" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Component ────────────────────────────────────────────────────────────────
|
||||
|
||||
export function Playground() {
|
||||
const [theme, setTheme] = useState<"light" | "dark">(() => {
|
||||
if (
|
||||
typeof window !== "undefined" &&
|
||||
window.matchMedia("(prefers-color-scheme: dark)").matches
|
||||
) {
|
||||
return "dark";
|
||||
}
|
||||
return "light";
|
||||
});
|
||||
|
||||
// Load initial blocks from hash or first template
|
||||
const [blocks, setBlocks] = useState<Block[]>(() => {
|
||||
if (typeof window !== "undefined" && window.location.hash.length > 1) {
|
||||
const decoded = decodeFromHash(window.location.hash.slice(1));
|
||||
if (decoded) return decoded;
|
||||
}
|
||||
return templates[0]?.blocks ?? [];
|
||||
});
|
||||
|
||||
const [editorText, setEditorText] = useState(() => JSON.stringify(blocks, null, 2));
|
||||
const [parseError, setParseError] = useState<string | null>(null);
|
||||
const [validationErrors, setValidationErrors] = useState<string[]>([]);
|
||||
const [actionLog, setActionLog] = useState<ActionLogEntry[]>([]);
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [templateMenuOpen, setTemplateMenuOpen] = useState(false);
|
||||
const logEndRef = useRef<HTMLDivElement>(null);
|
||||
const nextId = useRef(0);
|
||||
const templateMenuRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Resizable panels
|
||||
const catalog = useResizable({ initial: 220, min: 160, max: 320 });
|
||||
const editor = useResizable({ initial: 480, min: 300, max: 800 });
|
||||
|
||||
// Apply theme
|
||||
useEffect(() => {
|
||||
document.documentElement.setAttribute("data-theme", theme);
|
||||
}, [theme]);
|
||||
|
||||
// Close template menu on outside click
|
||||
useEffect(() => {
|
||||
function handleClick(e: MouseEvent) {
|
||||
if (templateMenuRef.current && !templateMenuRef.current.contains(e.target as Node)) {
|
||||
setTemplateMenuOpen(false);
|
||||
}
|
||||
}
|
||||
if (templateMenuOpen) {
|
||||
document.addEventListener("mousedown", handleClick);
|
||||
return () => document.removeEventListener("mousedown", handleClick);
|
||||
}
|
||||
}, [templateMenuOpen]);
|
||||
|
||||
// Auto-scroll log
|
||||
useEffect(() => {
|
||||
logEndRef.current?.scrollIntoView({ behavior: "smooth" });
|
||||
}, [actionLog]);
|
||||
|
||||
// Parse editor text into blocks
|
||||
const updateFromText = useCallback((text: string) => {
|
||||
setEditorText(text);
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(text);
|
||||
setParseError(null);
|
||||
if (!Array.isArray(parsed)) {
|
||||
setParseError("Root must be an array of blocks");
|
||||
return;
|
||||
}
|
||||
const result = validateBlocks(parsed);
|
||||
const validated = parsed as Block[];
|
||||
if (!result.valid) {
|
||||
setValidationErrors(result.errors.map((e) => `${e.path}: ${e.message}`));
|
||||
// Still render what we can
|
||||
setBlocks(validated);
|
||||
} else {
|
||||
setValidationErrors([]);
|
||||
setBlocks(validated);
|
||||
}
|
||||
} catch (err) {
|
||||
setParseError(err instanceof Error ? err.message : "Invalid JSON");
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Handle block interactions
|
||||
const handleAction = useCallback((interaction: BlockInteraction) => {
|
||||
setActionLog((prev) => [...prev, { id: nextId.current++, timestamp: new Date(), interaction }]);
|
||||
}, []);
|
||||
|
||||
// Load a template
|
||||
const loadTemplate = useCallback((index: number) => {
|
||||
const template = templates[index];
|
||||
if (!template) return;
|
||||
const text = JSON.stringify(template.blocks, null, 2);
|
||||
setEditorText(text);
|
||||
setBlocks(template.blocks);
|
||||
setParseError(null);
|
||||
setValidationErrors([]);
|
||||
setTemplateMenuOpen(false);
|
||||
}, []);
|
||||
|
||||
// Insert a block from the catalog
|
||||
const insertBlock = useCallback(
|
||||
(catalogIndex: number) => {
|
||||
const entry = blockCatalog[catalogIndex];
|
||||
if (!entry) return;
|
||||
const newBlock = entry.create();
|
||||
const updated = [...blocks, newBlock];
|
||||
const text = JSON.stringify(updated, null, 2);
|
||||
setBlocks(updated);
|
||||
setEditorText(text);
|
||||
setParseError(null);
|
||||
setValidationErrors([]);
|
||||
},
|
||||
[blocks],
|
||||
);
|
||||
|
||||
// Share URL
|
||||
const shareUrl = useCallback(async () => {
|
||||
const hash = encodeToHash(blocks);
|
||||
const url = `${window.location.origin}${window.location.pathname}#${hash}`;
|
||||
window.history.replaceState(null, "", `#${hash}`);
|
||||
try {
|
||||
await navigator.clipboard.writeText(url);
|
||||
setCopied(true);
|
||||
setTimeout(setCopied, 2000, false);
|
||||
} catch {
|
||||
// Fallback: just update the URL
|
||||
}
|
||||
}, [blocks]);
|
||||
|
||||
// Error count for status bar
|
||||
const errorCount = useMemo(() => {
|
||||
let count = 0;
|
||||
if (parseError) count++;
|
||||
count += validationErrors.length;
|
||||
return count;
|
||||
}, [parseError, validationErrors]);
|
||||
|
||||
return (
|
||||
<div className="flex h-screen flex-col" style={{ colorScheme: theme }}>
|
||||
{/* ── Toolbar ─────────────────────────────────────── */}
|
||||
<header className="flex h-11 shrink-0 items-center gap-2 border-b border-kumo-line bg-kumo-bg px-3">
|
||||
<span className="text-sm font-semibold text-kumo-text">Block Kit Playground</span>
|
||||
|
||||
<div className="ms-auto flex items-center gap-1.5">
|
||||
{/* Template picker */}
|
||||
<div className="relative" ref={templateMenuRef}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setTemplateMenuOpen((v) => !v)}
|
||||
className="flex items-center gap-1 rounded-md px-2.5 py-1.5 text-xs font-medium text-kumo-text-secondary hover:bg-kumo-tint"
|
||||
>
|
||||
Templates
|
||||
<CaretDown size={12} weight="bold" />
|
||||
</button>
|
||||
{templateMenuOpen && (
|
||||
<div
|
||||
className="absolute end-0 top-full z-50 mt-1 w-64 rounded-lg border border-kumo-line p-1 shadow-lg"
|
||||
style={{ backgroundColor: "var(--kumo-bg, Canvas)" }}
|
||||
>
|
||||
{templates.map((t, i) => (
|
||||
<button
|
||||
key={t.name}
|
||||
type="button"
|
||||
onClick={() => loadTemplate(i)}
|
||||
className="flex w-full flex-col items-start rounded-md px-3 py-2 text-start hover:bg-kumo-tint"
|
||||
>
|
||||
<span className="text-sm font-medium text-kumo-text">{t.name}</span>
|
||||
<span className="text-xs text-kumo-text-secondary">{t.description}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Share */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={shareUrl}
|
||||
className="flex items-center gap-1 rounded-md px-2.5 py-1.5 text-xs font-medium text-kumo-text-secondary hover:bg-kumo-tint"
|
||||
>
|
||||
{copied ? <Check size={14} /> : <Share size={14} />}
|
||||
{copied ? "Copied!" : "Share"}
|
||||
</button>
|
||||
|
||||
{/* Theme toggle */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setTheme((t) => (t === "light" ? "dark" : "light"))}
|
||||
className="rounded-md p-1.5 text-kumo-text-secondary hover:bg-kumo-tint"
|
||||
aria-label="Toggle theme"
|
||||
>
|
||||
{theme === "light" ? <Moon size={16} /> : <Sun size={16} />}
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* ── Three-column layout ─────────────────────────── */}
|
||||
<div className="flex min-h-0 flex-1">
|
||||
{/* ── Left: Block catalog ──────────────────────── */}
|
||||
<div className="flex shrink-0 flex-col" style={{ width: catalog.width }}>
|
||||
<div className="flex h-8 items-center border-b border-kumo-line bg-kumo-tint/50 px-3">
|
||||
<span className="text-[11px] font-medium uppercase tracking-wide text-kumo-text-secondary">
|
||||
Add Block
|
||||
</span>
|
||||
</div>
|
||||
<div className="min-h-0 flex-1 overflow-auto p-1.5">
|
||||
{blockCatalog.map((entry, i) => (
|
||||
<button
|
||||
key={entry.type}
|
||||
type="button"
|
||||
onClick={() => insertBlock(i)}
|
||||
className="flex w-full items-start gap-2 rounded-md px-2.5 py-2 text-start hover:bg-kumo-tint"
|
||||
>
|
||||
<Plus
|
||||
size={14}
|
||||
weight="bold"
|
||||
className="mt-0.5 shrink-0 text-kumo-text-secondary"
|
||||
/>
|
||||
<div className="min-w-0">
|
||||
<div className="text-xs font-medium text-kumo-text">{entry.label}</div>
|
||||
<div className="text-[11px] leading-tight text-kumo-text-secondary">
|
||||
{entry.description}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DragHandle onMouseDown={catalog.handleMouseDown} isDragging={catalog.isDragging} />
|
||||
|
||||
{/* ── Center: JSON editor ──────────────────────── */}
|
||||
<div className="flex shrink-0 flex-col" style={{ width: editor.width }}>
|
||||
<div className="flex h-8 items-center border-b border-kumo-line bg-kumo-tint/50 px-3">
|
||||
<span className="text-[11px] font-medium uppercase tracking-wide text-kumo-text-secondary">
|
||||
JSON Editor
|
||||
</span>
|
||||
{errorCount > 0 && (
|
||||
<span className="ms-auto flex items-center gap-1 text-[11px] font-medium text-kumo-warning">
|
||||
<Warning size={12} weight="fill" />
|
||||
{errorCount} {errorCount === 1 ? "error" : "errors"}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<textarea
|
||||
className="editor-textarea min-h-0 flex-1 border-none bg-kumo-bg p-3 text-kumo-text outline-none"
|
||||
value={editorText}
|
||||
onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) => updateFromText(e.target.value)}
|
||||
spellCheck={false}
|
||||
autoCapitalize="off"
|
||||
autoCorrect="off"
|
||||
/>
|
||||
{/* Error display */}
|
||||
{(parseError ?? validationErrors.length > 0) && (
|
||||
<div className="max-h-32 shrink-0 overflow-auto border-t border-kumo-line bg-kumo-danger/5 p-2">
|
||||
{parseError && <p className="text-xs text-kumo-danger">{parseError}</p>}
|
||||
{validationErrors.map((err, i) => (
|
||||
<p key={i} className="text-xs text-kumo-warning">
|
||||
{err}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DragHandle onMouseDown={editor.handleMouseDown} isDragging={editor.isDragging} />
|
||||
|
||||
{/* ── Right: Preview + Action log ──────────────── */}
|
||||
<div className="flex min-w-0 flex-1 flex-col">
|
||||
{/* Preview */}
|
||||
<div className="flex h-8 items-center border-b border-kumo-line bg-kumo-tint/50 px-3">
|
||||
<span className="text-[11px] font-medium uppercase tracking-wide text-kumo-text-secondary">
|
||||
Preview
|
||||
</span>
|
||||
</div>
|
||||
<div className="min-h-0 flex-1 overflow-auto p-4">
|
||||
<div className="mx-auto max-w-2xl">
|
||||
{!parseError && blocks.length > 0 ? (
|
||||
<BlockRenderer blocks={blocks} onAction={handleAction} />
|
||||
) : (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<p className="text-sm text-kumo-text-secondary">
|
||||
{parseError
|
||||
? "Fix JSON errors to see preview"
|
||||
: "Enter block JSON to see preview"}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Action log */}
|
||||
<div className="flex h-48 shrink-0 flex-col border-t border-kumo-line">
|
||||
<div className="flex h-8 items-center border-b border-kumo-line bg-kumo-tint/50 px-3">
|
||||
<span className="text-[11px] font-medium uppercase tracking-wide text-kumo-text-secondary">
|
||||
Action Log
|
||||
</span>
|
||||
<span className="ms-1.5 rounded-full bg-kumo-tint px-1.5 py-0.5 text-[10px] font-medium text-kumo-text-secondary">
|
||||
{actionLog.length}
|
||||
</span>
|
||||
{actionLog.length > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setActionLog([])}
|
||||
className="ms-auto rounded-md p-1 text-kumo-text-secondary hover:bg-kumo-tint"
|
||||
aria-label="Clear log"
|
||||
>
|
||||
<Trash size={12} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="min-h-0 flex-1 overflow-auto p-2">
|
||||
{actionLog.length === 0 ? (
|
||||
<p className="p-2 text-xs text-kumo-text-secondary">
|
||||
Interact with the preview to see actions logged here.
|
||||
</p>
|
||||
) : (
|
||||
actionLog.map((entry) => (
|
||||
<div
|
||||
key={entry.id}
|
||||
className="action-log-entry border-b border-kumo-line/50 px-2 py-1.5 last:border-b-0"
|
||||
>
|
||||
<div className="flex items-baseline gap-2">
|
||||
<span className="shrink-0 text-kumo-text-secondary">
|
||||
{entry.timestamp.toLocaleTimeString()}
|
||||
</span>
|
||||
<span className="rounded bg-kumo-tint px-1 py-0.5 text-[10px] font-semibold uppercase text-kumo-text-secondary">
|
||||
{entry.interaction.type}
|
||||
</span>
|
||||
{"action_id" in entry.interaction && (
|
||||
<span className="font-medium text-kumo-text">
|
||||
{entry.interaction.action_id}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{"values" in entry.interaction && (
|
||||
<pre className="mt-1 text-[11px] text-kumo-text-secondary">
|
||||
{JSON.stringify(entry.interaction.values, null, 2)}
|
||||
</pre>
|
||||
)}
|
||||
{"value" in entry.interaction && entry.interaction.value !== undefined && (
|
||||
<pre className="mt-1 text-[11px] text-kumo-text-secondary">
|
||||
{JSON.stringify(entry.interaction.value)}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
<div ref={logEndRef} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
214
packages/blocks/playground/src/block-defaults.ts
Normal file
214
packages/blocks/playground/src/block-defaults.ts
Normal file
@@ -0,0 +1,214 @@
|
||||
import type { Block } from "@emdash-cms/blocks";
|
||||
|
||||
interface BlockCatalogEntry {
|
||||
type: Block["type"];
|
||||
label: string;
|
||||
description: string;
|
||||
create: () => Block;
|
||||
}
|
||||
|
||||
let counter = 0;
|
||||
function nextId(prefix: string): string {
|
||||
return `${prefix}_${++counter}`;
|
||||
}
|
||||
|
||||
export const blockCatalog: BlockCatalogEntry[] = [
|
||||
{
|
||||
type: "header",
|
||||
label: "Header",
|
||||
description: "Page or section title",
|
||||
create: () => ({
|
||||
type: "header",
|
||||
text: "New Header",
|
||||
}),
|
||||
},
|
||||
{
|
||||
type: "section",
|
||||
label: "Section",
|
||||
description: "Text paragraph with optional accessory",
|
||||
create: () => ({
|
||||
type: "section",
|
||||
text: "Section text goes here. You can add an accessory element like a button.",
|
||||
}),
|
||||
},
|
||||
{
|
||||
type: "divider",
|
||||
label: "Divider",
|
||||
description: "Horizontal rule between blocks",
|
||||
create: () => ({
|
||||
type: "divider",
|
||||
}),
|
||||
},
|
||||
{
|
||||
type: "fields",
|
||||
label: "Fields",
|
||||
description: "Key-value pairs in a grid",
|
||||
create: () => ({
|
||||
type: "fields",
|
||||
fields: [
|
||||
{ label: "Label", value: "Value" },
|
||||
{ label: "Another", value: "Value" },
|
||||
],
|
||||
}),
|
||||
},
|
||||
{
|
||||
type: "stats",
|
||||
label: "Stats",
|
||||
description: "Metric cards with optional trends",
|
||||
create: () => ({
|
||||
type: "stats",
|
||||
items: [
|
||||
{ label: "Total", value: 100, trend: "up" as const },
|
||||
{ label: "Active", value: 42 },
|
||||
],
|
||||
}),
|
||||
},
|
||||
{
|
||||
type: "table",
|
||||
label: "Table",
|
||||
description: "Sortable data grid with pagination",
|
||||
create: () => ({
|
||||
type: "table",
|
||||
columns: [
|
||||
{ key: "name", label: "Name", sortable: true },
|
||||
{ key: "status", label: "Status", format: "badge" as const },
|
||||
],
|
||||
rows: [
|
||||
{ name: "Item 1", status: "active" },
|
||||
{ name: "Item 2", status: "draft" },
|
||||
],
|
||||
page_action_id: nextId("page"),
|
||||
}),
|
||||
},
|
||||
{
|
||||
type: "form",
|
||||
label: "Form",
|
||||
description: "Input fields with submit button",
|
||||
create: () => ({
|
||||
type: "form",
|
||||
fields: [
|
||||
{
|
||||
type: "text_input" as const,
|
||||
action_id: nextId("field"),
|
||||
label: "Text Field",
|
||||
placeholder: "Enter text...",
|
||||
},
|
||||
],
|
||||
submit: { label: "Submit", action_id: nextId("submit") },
|
||||
}),
|
||||
},
|
||||
{
|
||||
type: "actions",
|
||||
label: "Actions",
|
||||
description: "Row of buttons",
|
||||
create: () => ({
|
||||
type: "actions",
|
||||
elements: [
|
||||
{
|
||||
type: "button" as const,
|
||||
action_id: nextId("btn"),
|
||||
label: "Click Me",
|
||||
style: "primary" as const,
|
||||
},
|
||||
],
|
||||
}),
|
||||
},
|
||||
{
|
||||
type: "image",
|
||||
label: "Image",
|
||||
description: "Image with alt text and optional title",
|
||||
create: () => ({
|
||||
type: "image",
|
||||
url: "https://images.unsplash.com/photo-1618005182384-a83a8bd57fbe?w=600&h=300&fit=crop",
|
||||
alt: "Placeholder image",
|
||||
title: "Image title",
|
||||
}),
|
||||
},
|
||||
{
|
||||
type: "context",
|
||||
label: "Context",
|
||||
description: "Muted supplementary text",
|
||||
create: () => ({
|
||||
type: "context",
|
||||
text: "Supplementary information goes here.",
|
||||
}),
|
||||
},
|
||||
{
|
||||
type: "banner",
|
||||
label: "Banner",
|
||||
description: "Info, warning, or error message",
|
||||
create: () => ({
|
||||
type: "banner" as const,
|
||||
title: "Notice",
|
||||
description: "This is an informational banner message.",
|
||||
variant: "default" as const,
|
||||
}),
|
||||
},
|
||||
{
|
||||
type: "columns",
|
||||
label: "Columns",
|
||||
description: "Multi-column layout",
|
||||
create: () => ({
|
||||
type: "columns",
|
||||
columns: [
|
||||
[{ type: "section" as const, text: "Left column content" }],
|
||||
[{ type: "section" as const, text: "Right column content" }],
|
||||
],
|
||||
}),
|
||||
},
|
||||
{
|
||||
type: "meter",
|
||||
label: "Meter",
|
||||
description: "Progress/quota meter bar",
|
||||
create: () => ({
|
||||
type: "meter" as const,
|
||||
label: "Storage used",
|
||||
value: 65,
|
||||
custom_value: "6.5 GB / 10 GB",
|
||||
}),
|
||||
},
|
||||
{
|
||||
type: "code",
|
||||
label: "Code",
|
||||
description: "Syntax-highlighted code block",
|
||||
create: () => ({
|
||||
type: "code" as const,
|
||||
code: 'const greeting = "Hello, World!";\nconsole.log(greeting);',
|
||||
language: "ts" as const,
|
||||
}),
|
||||
},
|
||||
{
|
||||
type: "chart",
|
||||
label: "Chart",
|
||||
description: "Line, bar, or pie chart (ECharts)",
|
||||
create: () => {
|
||||
const now = Date.now();
|
||||
const hour = 3_600_000;
|
||||
return {
|
||||
type: "chart" as const,
|
||||
config: {
|
||||
chart_type: "timeseries" as const,
|
||||
series: [
|
||||
{
|
||||
name: "Requests",
|
||||
data: Array.from({ length: 24 }, (_, i) => [
|
||||
now - (23 - i) * hour,
|
||||
Math.floor(200 + Math.random() * 300),
|
||||
]),
|
||||
},
|
||||
{
|
||||
name: "Errors",
|
||||
data: Array.from({ length: 24 }, (_, i) => [
|
||||
now - (23 - i) * hour,
|
||||
Math.floor(Math.random() * 20),
|
||||
]),
|
||||
},
|
||||
],
|
||||
x_axis_name: "Time",
|
||||
y_axis_name: "Count",
|
||||
gradient: true,
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
];
|
||||
11
packages/blocks/playground/src/main.tsx
Normal file
11
packages/blocks/playground/src/main.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
|
||||
import "./styles.css";
|
||||
import { Playground } from "./Playground";
|
||||
|
||||
createRoot(document.getElementById("root")!).render(
|
||||
<StrictMode>
|
||||
<Playground />
|
||||
</StrictMode>,
|
||||
);
|
||||
47
packages/blocks/playground/src/styles.css
Normal file
47
packages/blocks/playground/src/styles.css
Normal file
@@ -0,0 +1,47 @@
|
||||
@import "tailwindcss";
|
||||
@import "@cloudflare/kumo/styles/tailwind";
|
||||
|
||||
/* Scan kumo components for utility classes */
|
||||
@source "../../node_modules/@cloudflare/kumo/dist";
|
||||
|
||||
/* Scan blocks source for utility classes */
|
||||
@source "../../src";
|
||||
|
||||
:root {
|
||||
color-scheme: light dark;
|
||||
}
|
||||
|
||||
:root[data-theme="light"] {
|
||||
color-scheme: light;
|
||||
}
|
||||
|
||||
:root[data-theme="dark"] {
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family:
|
||||
system-ui,
|
||||
-apple-system,
|
||||
BlinkMacSystemFont,
|
||||
"Segoe UI",
|
||||
sans-serif;
|
||||
background-color: light-dark(#f5f5f5, #111);
|
||||
}
|
||||
|
||||
/* Editor textarea */
|
||||
.editor-textarea {
|
||||
font-family: "SF Mono", "Fira Code", "JetBrains Mono", ui-monospace, monospace;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
tab-size: 2;
|
||||
resize: none;
|
||||
}
|
||||
|
||||
/* Action log entries */
|
||||
.action-log-entry {
|
||||
font-family: "SF Mono", "Fira Code", "JetBrains Mono", ui-monospace, monospace;
|
||||
font-size: 12px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
560
packages/blocks/playground/src/templates.ts
Normal file
560
packages/blocks/playground/src/templates.ts
Normal file
@@ -0,0 +1,560 @@
|
||||
import type { Block, ChartSeries } from "@emdash-cms/blocks";
|
||||
|
||||
export interface Template {
|
||||
name: string;
|
||||
description: string;
|
||||
blocks: Block[];
|
||||
}
|
||||
|
||||
// ── Sample data generators ───────────────────────────────────────────────────
|
||||
|
||||
const HOUR = 3_600_000;
|
||||
|
||||
function generateTrafficSeries(): ChartSeries[] {
|
||||
const now = Date.now();
|
||||
return [
|
||||
{
|
||||
name: "Page Views",
|
||||
data: Array.from({ length: 24 }, (_, i) => [
|
||||
now - (23 - i) * HOUR,
|
||||
Math.floor(400 + Math.sin(i / 4) * 200 + Math.random() * 80),
|
||||
]),
|
||||
color: "#086FFF",
|
||||
},
|
||||
{
|
||||
name: "Unique Visitors",
|
||||
data: Array.from({ length: 24 }, (_, i) => [
|
||||
now - (23 - i) * HOUR,
|
||||
Math.floor(150 + Math.sin(i / 4) * 80 + Math.random() * 40),
|
||||
]),
|
||||
color: "#CF7EE9",
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
function generateErrorSeries(): ChartSeries[] {
|
||||
const now = Date.now();
|
||||
return [
|
||||
{
|
||||
name: "4xx",
|
||||
data: Array.from({ length: 12 }, (_, i) => [
|
||||
now - (11 - i) * HOUR * 2,
|
||||
Math.floor(Math.random() * 15),
|
||||
]),
|
||||
color: "#F8A054",
|
||||
},
|
||||
{
|
||||
name: "5xx",
|
||||
data: Array.from({ length: 12 }, (_, i) => [
|
||||
now - (11 - i) * HOUR * 2,
|
||||
Math.floor(Math.random() * 5),
|
||||
]),
|
||||
color: "#FC574A",
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export const templates: Template[] = [
|
||||
{
|
||||
name: "Plugin Settings",
|
||||
description: "Form with conditional fields and text inputs",
|
||||
blocks: [
|
||||
{
|
||||
type: "header",
|
||||
text: "SEO Plugin Settings",
|
||||
block_id: "settings-header",
|
||||
},
|
||||
{
|
||||
type: "section",
|
||||
text: "Configure how your site appears in search results. Enable auto-generation to let the plugin create meta tags from your content.",
|
||||
},
|
||||
{ type: "divider" },
|
||||
{
|
||||
type: "form",
|
||||
block_id: "seo-settings",
|
||||
fields: [
|
||||
{
|
||||
type: "text_input",
|
||||
action_id: "site_title",
|
||||
label: "Site Title",
|
||||
placeholder: "My Awesome Site",
|
||||
initial_value: "EmDash CMS",
|
||||
},
|
||||
{
|
||||
type: "text_input",
|
||||
action_id: "meta_description",
|
||||
label: "Default Meta Description",
|
||||
placeholder: "A brief description of your site...",
|
||||
multiline: true,
|
||||
},
|
||||
{
|
||||
type: "toggle",
|
||||
action_id: "auto_generate",
|
||||
label: "Auto-generate meta tags",
|
||||
description: "Generate meta descriptions from content when not manually set",
|
||||
initial_value: true,
|
||||
},
|
||||
{
|
||||
type: "number_input",
|
||||
action_id: "max_length",
|
||||
label: "Max description length",
|
||||
initial_value: 160,
|
||||
min: 50,
|
||||
max: 300,
|
||||
condition: { field: "auto_generate", eq: true },
|
||||
},
|
||||
{
|
||||
type: "select",
|
||||
action_id: "ai_model",
|
||||
label: "AI Model",
|
||||
options: [
|
||||
{ label: "GPT-4o Mini", value: "gpt-4o-mini" },
|
||||
{ label: "Claude Haiku", value: "claude-haiku" },
|
||||
{ label: "None (extractive)", value: "none" },
|
||||
],
|
||||
initial_value: "none",
|
||||
condition: { field: "auto_generate", eq: true },
|
||||
},
|
||||
{
|
||||
type: "radio",
|
||||
action_id: "default_status",
|
||||
label: "Default publish status",
|
||||
options: [
|
||||
{ label: "Draft", value: "draft" },
|
||||
{ label: "Published", value: "published" },
|
||||
{ label: "Scheduled", value: "scheduled" },
|
||||
],
|
||||
initial_value: "draft",
|
||||
},
|
||||
{
|
||||
type: "checkbox",
|
||||
action_id: "collections",
|
||||
label: "Apply to collections",
|
||||
options: [
|
||||
{ label: "Posts", value: "posts" },
|
||||
{ label: "Pages", value: "pages" },
|
||||
{ label: "Products", value: "products" },
|
||||
],
|
||||
initial_value: ["posts", "pages"],
|
||||
},
|
||||
{
|
||||
type: "secret_input",
|
||||
action_id: "api_key",
|
||||
label: "API Key",
|
||||
placeholder: "sk-...",
|
||||
condition: { field: "ai_model", neq: "none" },
|
||||
},
|
||||
{
|
||||
type: "date_input",
|
||||
action_id: "embargo_date",
|
||||
label: "Embargo date",
|
||||
placeholder: "Select a date",
|
||||
},
|
||||
{
|
||||
type: "combobox",
|
||||
action_id: "timezone",
|
||||
label: "Timezone",
|
||||
placeholder: "Search timezones...",
|
||||
options: [
|
||||
{ label: "UTC", value: "UTC" },
|
||||
{ label: "US/Eastern", value: "US/Eastern" },
|
||||
{ label: "US/Central", value: "US/Central" },
|
||||
{ label: "US/Mountain", value: "US/Mountain" },
|
||||
{ label: "US/Pacific", value: "US/Pacific" },
|
||||
{ label: "Europe/London", value: "Europe/London" },
|
||||
{ label: "Europe/Paris", value: "Europe/Paris" },
|
||||
{ label: "Europe/Berlin", value: "Europe/Berlin" },
|
||||
{ label: "Asia/Tokyo", value: "Asia/Tokyo" },
|
||||
{ label: "Asia/Shanghai", value: "Asia/Shanghai" },
|
||||
{ label: "Australia/Sydney", value: "Australia/Sydney" },
|
||||
],
|
||||
initial_value: "UTC",
|
||||
},
|
||||
],
|
||||
submit: { label: "Save Settings", action_id: "save_seo_settings" },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "Analytics Dashboard",
|
||||
description: "Charts, stats, and data table",
|
||||
blocks: [
|
||||
{
|
||||
type: "header",
|
||||
text: "Content Analytics",
|
||||
},
|
||||
{
|
||||
type: "stats",
|
||||
items: [
|
||||
{ label: "Total Views", value: "12,847", trend: "up", description: "+14% vs last week" },
|
||||
{
|
||||
label: "Unique Visitors",
|
||||
value: "3,291",
|
||||
trend: "up",
|
||||
description: "+8% vs last week",
|
||||
},
|
||||
{
|
||||
label: "Bounce Rate",
|
||||
value: "34.2%",
|
||||
trend: "down",
|
||||
description: "-2.1% vs last week",
|
||||
},
|
||||
{ label: "Avg. Time on Page", value: "2m 48s", trend: "neutral" },
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "chart",
|
||||
block_id: "traffic-chart",
|
||||
config: {
|
||||
chart_type: "timeseries",
|
||||
series: generateTrafficSeries(),
|
||||
x_axis_name: "Time",
|
||||
y_axis_name: "Requests",
|
||||
gradient: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "columns",
|
||||
columns: [
|
||||
[
|
||||
{
|
||||
type: "chart",
|
||||
block_id: "content-breakdown",
|
||||
config: {
|
||||
chart_type: "custom",
|
||||
options: {
|
||||
series: [
|
||||
{
|
||||
type: "pie",
|
||||
radius: ["40%", "70%"],
|
||||
data: [
|
||||
{ value: 42, name: "Published" },
|
||||
{ value: 7, name: "Draft" },
|
||||
{ value: 3, name: "Scheduled" },
|
||||
{ value: 2, name: "Archived" },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
height: 250,
|
||||
},
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
type: "chart",
|
||||
block_id: "errors-chart",
|
||||
config: {
|
||||
chart_type: "timeseries",
|
||||
series: generateErrorSeries(),
|
||||
y_axis_name: "Errors",
|
||||
style: "bar",
|
||||
height: 250,
|
||||
},
|
||||
},
|
||||
],
|
||||
],
|
||||
},
|
||||
{ type: "divider" },
|
||||
{
|
||||
type: "table",
|
||||
block_id: "content-table",
|
||||
columns: [
|
||||
{ key: "title", label: "Title", sortable: true },
|
||||
{ key: "status", label: "Status", format: "badge" },
|
||||
{ key: "views", label: "Views", format: "number", sortable: true },
|
||||
{ key: "updated", label: "Last Updated", format: "relative_time", sortable: true },
|
||||
],
|
||||
rows: [
|
||||
{
|
||||
title: "Getting Started with EmDash",
|
||||
status: "published",
|
||||
views: 4521,
|
||||
updated: new Date(Date.now() - 2 * 60 * 60 * 1000).toISOString(),
|
||||
},
|
||||
{
|
||||
title: "Advanced Content Modeling",
|
||||
status: "published",
|
||||
views: 2103,
|
||||
updated: new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString(),
|
||||
},
|
||||
{
|
||||
title: "Plugin Development Guide",
|
||||
status: "draft",
|
||||
views: 891,
|
||||
updated: new Date(Date.now() - 3 * 24 * 60 * 60 * 1000).toISOString(),
|
||||
},
|
||||
{
|
||||
title: "Deployment to Cloudflare",
|
||||
status: "published",
|
||||
views: 3187,
|
||||
updated: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString(),
|
||||
},
|
||||
{
|
||||
title: "Media Management",
|
||||
status: "scheduled",
|
||||
views: 0,
|
||||
updated: new Date(Date.now() - 30 * 60 * 1000).toISOString(),
|
||||
},
|
||||
],
|
||||
page_action_id: "load_more_content",
|
||||
next_cursor: "eyJpZCI6IjUifQ",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "Dashboard Widget",
|
||||
description: "Compact stats with context line",
|
||||
blocks: [
|
||||
{
|
||||
type: "stats",
|
||||
items: [
|
||||
{ label: "Published", value: 42 },
|
||||
{ label: "Drafts", value: 7 },
|
||||
{ label: "Scheduled", value: 3 },
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "context",
|
||||
text: "Last published 2 hours ago \u2022 Next scheduled in 4 hours",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "Admin Page",
|
||||
description: "Two-column layout with sidebar and main content",
|
||||
blocks: [
|
||||
{
|
||||
type: "header",
|
||||
text: "Site Configuration",
|
||||
},
|
||||
{
|
||||
type: "columns",
|
||||
columns: [
|
||||
[
|
||||
{
|
||||
type: "section",
|
||||
text: "General settings for your site. These values are used across all pages unless overridden at the content level.",
|
||||
},
|
||||
{
|
||||
type: "form",
|
||||
block_id: "general-settings",
|
||||
fields: [
|
||||
{
|
||||
type: "text_input",
|
||||
action_id: "site_name",
|
||||
label: "Site Name",
|
||||
initial_value: "My Site",
|
||||
},
|
||||
{
|
||||
type: "text_input",
|
||||
action_id: "tagline",
|
||||
label: "Tagline",
|
||||
placeholder: "A short description of your site",
|
||||
},
|
||||
{
|
||||
type: "select",
|
||||
action_id: "timezone",
|
||||
label: "Timezone",
|
||||
options: [
|
||||
{ label: "UTC", value: "UTC" },
|
||||
{ label: "US/Eastern", value: "US/Eastern" },
|
||||
{ label: "US/Pacific", value: "US/Pacific" },
|
||||
{ label: "Europe/London", value: "Europe/London" },
|
||||
],
|
||||
initial_value: "UTC",
|
||||
},
|
||||
],
|
||||
submit: { label: "Save", action_id: "save_general" },
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
type: "fields",
|
||||
fields: [
|
||||
{ label: "Plan", value: "Pro" },
|
||||
{ label: "Storage", value: "2.4 GB / 10 GB" },
|
||||
{ label: "API Calls", value: "12,847 / 100,000" },
|
||||
{ label: "Next Billing", value: "Mar 15, 2026" },
|
||||
],
|
||||
},
|
||||
{ type: "divider" },
|
||||
{
|
||||
type: "actions",
|
||||
elements: [
|
||||
{
|
||||
type: "button",
|
||||
action_id: "export_data",
|
||||
label: "Export Data",
|
||||
style: "secondary",
|
||||
},
|
||||
{
|
||||
type: "button",
|
||||
action_id: "danger_zone",
|
||||
label: "Delete Site",
|
||||
style: "danger",
|
||||
confirm: {
|
||||
title: "Delete Site?",
|
||||
text: "This action cannot be undone. All content and media will be permanently deleted.",
|
||||
confirm: "Delete Everything",
|
||||
deny: "Cancel",
|
||||
style: "danger",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "All Blocks",
|
||||
description: "Showcase of every block type",
|
||||
blocks: [
|
||||
{ type: "header", text: "Block Kit Showcase" },
|
||||
{
|
||||
type: "section",
|
||||
text: "This template demonstrates every block type available in the Block Kit. Each block maps to a Kumo component.",
|
||||
accessory: {
|
||||
type: "button",
|
||||
action_id: "learn_more",
|
||||
label: "Learn More",
|
||||
style: "primary",
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "banner",
|
||||
title: "Information",
|
||||
description: "This is a default informational banner.",
|
||||
variant: "default",
|
||||
},
|
||||
{
|
||||
type: "banner",
|
||||
title: "Warning",
|
||||
description: "Something requires your attention.",
|
||||
variant: "alert",
|
||||
},
|
||||
{
|
||||
type: "banner",
|
||||
title: "Error",
|
||||
description: "An error occurred while processing your request.",
|
||||
variant: "error",
|
||||
},
|
||||
{ type: "divider" },
|
||||
{
|
||||
type: "fields",
|
||||
fields: [
|
||||
{ label: "Version", value: "0.1.0" },
|
||||
{ label: "Blocks", value: "15 types" },
|
||||
{ label: "Elements", value: "10 types" },
|
||||
{ label: "License", value: "MIT" },
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "stats",
|
||||
items: [
|
||||
{ label: "Components", value: 26, trend: "up" },
|
||||
{ label: "Tests", value: 60, trend: "up" },
|
||||
{ label: "Bundle Size", value: "6.7 KB", description: "gzipped" },
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "meter",
|
||||
label: "Storage",
|
||||
value: 65,
|
||||
custom_value: "6.5 GB / 10 GB",
|
||||
},
|
||||
{ type: "divider" },
|
||||
{
|
||||
type: "chart",
|
||||
block_id: "demo-timeseries",
|
||||
config: {
|
||||
chart_type: "timeseries",
|
||||
series: generateTrafficSeries(),
|
||||
x_axis_name: "Time",
|
||||
y_axis_name: "Views",
|
||||
gradient: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "chart",
|
||||
block_id: "demo-pie",
|
||||
config: {
|
||||
chart_type: "custom",
|
||||
options: {
|
||||
series: [
|
||||
{
|
||||
type: "pie",
|
||||
radius: ["40%", "70%"],
|
||||
data: [
|
||||
{ value: 335, name: "Published" },
|
||||
{ value: 234, name: "Draft" },
|
||||
{ value: 120, name: "Scheduled" },
|
||||
{ value: 48, name: "Archived" },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
height: 280,
|
||||
},
|
||||
},
|
||||
{ type: "divider" },
|
||||
{
|
||||
type: "image",
|
||||
url: "https://images.unsplash.com/photo-1618005182384-a83a8bd57fbe?w=800&h=400&fit=crop",
|
||||
alt: "Abstract colorful gradient",
|
||||
title: "Image blocks support URLs, alt text, and optional titles",
|
||||
},
|
||||
{
|
||||
type: "code",
|
||||
code: 'import { blocks } from "@emdash-cms/blocks";\n\nconst page = [\n\tblocks.header("Hello"),\n\tblocks.section("Welcome to EmDash."),\n];',
|
||||
language: "ts",
|
||||
},
|
||||
{ type: "divider" },
|
||||
{
|
||||
type: "table",
|
||||
block_id: "demo-table",
|
||||
columns: [
|
||||
{ key: "block", label: "Block Type" },
|
||||
{ key: "purpose", label: "Purpose" },
|
||||
{ key: "status", label: "Status", format: "badge" },
|
||||
],
|
||||
rows: [
|
||||
{ block: "header", purpose: "Page or section title", status: "stable" },
|
||||
{ block: "section", purpose: "Text with optional accessory", status: "stable" },
|
||||
{ block: "form", purpose: "Input fields with submit", status: "stable" },
|
||||
{ block: "table", purpose: "Sortable data grid", status: "stable" },
|
||||
{ block: "chart", purpose: "Timeseries, bar, pie charts", status: "stable" },
|
||||
{ block: "columns", purpose: "Multi-column layout", status: "stable" },
|
||||
],
|
||||
page_action_id: "demo_page",
|
||||
},
|
||||
{ type: "divider" },
|
||||
{
|
||||
type: "actions",
|
||||
elements: [
|
||||
{ type: "button", action_id: "primary_action", label: "Primary", style: "primary" },
|
||||
{ type: "button", action_id: "secondary_action", label: "Secondary", style: "secondary" },
|
||||
{
|
||||
type: "button",
|
||||
action_id: "danger_action",
|
||||
label: "Danger",
|
||||
style: "danger",
|
||||
confirm: {
|
||||
title: "Are you sure?",
|
||||
text: "This is a destructive action demo.",
|
||||
confirm: "Yes, proceed",
|
||||
deny: "Cancel",
|
||||
style: "danger",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "context",
|
||||
text: "This is a context block \u2014 used for supplementary information, timestamps, or footnotes.",
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
54
packages/blocks/playground/src/useResizable.ts
Normal file
54
packages/blocks/playground/src/useResizable.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
|
||||
interface UseResizableOptions {
|
||||
/** Initial width in pixels */
|
||||
initial: number;
|
||||
/** Minimum width */
|
||||
min: number;
|
||||
/** Maximum width */
|
||||
max: number;
|
||||
}
|
||||
|
||||
interface UseResizableReturn {
|
||||
width: number;
|
||||
isDragging: boolean;
|
||||
handleMouseDown: (e: React.MouseEvent) => void;
|
||||
}
|
||||
|
||||
export function useResizable({ initial, min, max }: UseResizableOptions): UseResizableReturn {
|
||||
const [width, setWidth] = useState(initial);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const startX = useRef(0);
|
||||
const startWidth = useRef(0);
|
||||
|
||||
const handleMouseDown = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
startX.current = e.clientX;
|
||||
startWidth.current = width;
|
||||
setIsDragging(true);
|
||||
|
||||
function onMouseMove(moveEvent: MouseEvent) {
|
||||
const delta = moveEvent.clientX - startX.current;
|
||||
const newWidth = Math.min(max, Math.max(min, startWidth.current + delta));
|
||||
setWidth(newWidth);
|
||||
}
|
||||
|
||||
function onMouseUp() {
|
||||
setIsDragging(false);
|
||||
document.removeEventListener("mousemove", onMouseMove);
|
||||
document.removeEventListener("mouseup", onMouseUp);
|
||||
document.body.style.cursor = "";
|
||||
document.body.style.userSelect = "";
|
||||
}
|
||||
|
||||
document.body.style.cursor = "col-resize";
|
||||
document.body.style.userSelect = "none";
|
||||
document.addEventListener("mousemove", onMouseMove);
|
||||
document.addEventListener("mouseup", onMouseUp);
|
||||
},
|
||||
[width, min, max],
|
||||
);
|
||||
|
||||
return { width, isDragging, handleMouseDown };
|
||||
}
|
||||
14
packages/blocks/playground/tsconfig.json
Normal file
14
packages/blocks/playground/tsconfig.json
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2024",
|
||||
"module": "preserve",
|
||||
"moduleResolution": "bundler",
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"lib": ["ES2024", "DOM", "DOM.Iterable"],
|
||||
"skipLibCheck": true,
|
||||
"verbatimModuleSyntax": true
|
||||
},
|
||||
"include": ["src/**/*"]
|
||||
}
|
||||
15
packages/blocks/playground/vite.config.ts
Normal file
15
packages/blocks/playground/vite.config.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
import tailwindcss from "@tailwindcss/vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
import { defineConfig } from "vite";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react(), tailwindcss()],
|
||||
resolve: {
|
||||
alias: {
|
||||
// Resolve @emdash-cms/blocks from source for HMR
|
||||
"@emdash-cms/blocks": fileURLToPath(new URL("../src/index.ts", import.meta.url)),
|
||||
},
|
||||
},
|
||||
});
|
||||
4
packages/blocks/playground/wrangler.jsonc
Normal file
4
packages/blocks/playground/wrangler.jsonc
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"name": "emdash-blocks",
|
||||
"compatibility_date": "2026-02-25",
|
||||
}
|
||||
21
packages/blocks/src/blocks/accordion.tsx
Normal file
21
packages/blocks/src/blocks/accordion.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import { Collapsible } from "@cloudflare/kumo";
|
||||
import { useState } from "react";
|
||||
|
||||
import { BlockRenderer } from "../renderer.js";
|
||||
import type { AccordionBlock, BlockInteraction } from "../types.js";
|
||||
|
||||
export function AccordionBlockComponent({
|
||||
block,
|
||||
onAction,
|
||||
}: {
|
||||
block: AccordionBlock;
|
||||
onAction: (interaction: BlockInteraction) => void;
|
||||
}) {
|
||||
const [open, setOpen] = useState(block.default_open ?? false);
|
||||
|
||||
return (
|
||||
<Collapsible label={block.label} open={open} onOpenChange={setOpen}>
|
||||
<BlockRenderer blocks={block.blocks} onAction={onAction} />
|
||||
</Collapsible>
|
||||
);
|
||||
}
|
||||
18
packages/blocks/src/blocks/actions.tsx
Normal file
18
packages/blocks/src/blocks/actions.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import { renderElement } from "../render-element.js";
|
||||
import type { ActionsBlock, BlockInteraction } from "../types.js";
|
||||
|
||||
export function ActionsBlockComponent({
|
||||
block,
|
||||
onAction,
|
||||
}: {
|
||||
block: ActionsBlock;
|
||||
onAction: (interaction: BlockInteraction) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{block.elements.map((el, i) => (
|
||||
<div key={el.action_id ?? i}>{renderElement(el, onAction)}</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
26
packages/blocks/src/blocks/banner.tsx
Normal file
26
packages/blocks/src/blocks/banner.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import { Banner } from "@cloudflare/kumo";
|
||||
import { Info, Warning, WarningCircle } from "@phosphor-icons/react";
|
||||
import { useMemo } from "react";
|
||||
|
||||
import type { BannerBlock } from "../types.js";
|
||||
|
||||
function useVariantIcon(variant: "default" | "alert" | "error") {
|
||||
return useMemo(() => {
|
||||
switch (variant) {
|
||||
case "alert":
|
||||
return <Warning weight="fill" size={20} />;
|
||||
case "error":
|
||||
return <WarningCircle weight="fill" size={20} />;
|
||||
default:
|
||||
return <Info weight="fill" size={20} />;
|
||||
}
|
||||
}, [variant]);
|
||||
}
|
||||
|
||||
export function BannerBlockComponent({ block }: { block: BannerBlock }) {
|
||||
const variant = block.variant ?? "default";
|
||||
const icon = useVariantIcon(variant);
|
||||
return (
|
||||
<Banner variant={variant} icon={icon} title={block.title} description={block.description} />
|
||||
);
|
||||
}
|
||||
159
packages/blocks/src/blocks/chart.tsx
Normal file
159
packages/blocks/src/blocks/chart.tsx
Normal file
@@ -0,0 +1,159 @@
|
||||
import { Chart, ChartPalette, TimeseriesChart } from "@cloudflare/kumo/components/chart";
|
||||
import type { EChartsOption } from "echarts";
|
||||
import { BarChart, LineChart, PieChart } from "echarts/charts";
|
||||
import {
|
||||
AriaComponent,
|
||||
AxisPointerComponent,
|
||||
GridComponent,
|
||||
TooltipComponent,
|
||||
} from "echarts/components";
|
||||
import * as echarts from "echarts/core";
|
||||
import { CanvasRenderer } from "echarts/renderers";
|
||||
import { useMemo } from "react";
|
||||
|
||||
import type { ChartBlock } from "../types.js";
|
||||
import { useIsDarkMode } from "../utils.js";
|
||||
|
||||
echarts.use([
|
||||
BarChart,
|
||||
LineChart,
|
||||
PieChart,
|
||||
AriaComponent,
|
||||
AxisPointerComponent,
|
||||
GridComponent,
|
||||
TooltipComponent,
|
||||
CanvasRenderer,
|
||||
]);
|
||||
|
||||
// ── Security: HTML-escape untrusted strings before they reach ECharts ────────
|
||||
// ECharts tooltip renders via innerHTML. Plugin-supplied names/labels must be
|
||||
// escaped to prevent stored XSS in the admin dashboard.
|
||||
|
||||
const RE_AMP = /&/g;
|
||||
const RE_LT = /</g;
|
||||
const RE_GT = />/g;
|
||||
const RE_QUOT = /"/g;
|
||||
const RE_APOS = /'/g;
|
||||
|
||||
function escapeHtml(str: string): string {
|
||||
return str
|
||||
.replace(RE_AMP, "&")
|
||||
.replace(RE_LT, "<")
|
||||
.replace(RE_GT, ">")
|
||||
.replace(RE_QUOT, """)
|
||||
.replace(RE_APOS, "'");
|
||||
}
|
||||
|
||||
// ── Security: Sanitize custom ECharts options ────────────────────────────────
|
||||
// Plugin-supplied options are passed to chart.setOption(). ECharts accepts
|
||||
// formatter strings rendered via innerHTML, tooltip HTML, and graphic elements
|
||||
// that can execute arbitrary code. We strip dangerous properties and force
|
||||
// richText tooltip mode to eliminate HTML injection vectors.
|
||||
|
||||
/** Keys that accept HTML strings or executable content in ECharts options */
|
||||
const DANGEROUS_KEYS = new Set(["formatter", "rich", "graphic", "axisPointer"]);
|
||||
|
||||
function isRecord(v: unknown): v is Record<string, unknown> {
|
||||
return typeof v === "object" && v !== null && !Array.isArray(v);
|
||||
}
|
||||
|
||||
const RE_HTML_TAG = /<[a-z/!]/i;
|
||||
|
||||
function containsHtml(v: unknown): boolean {
|
||||
return typeof v === "string" && RE_HTML_TAG.test(v);
|
||||
}
|
||||
|
||||
/**
|
||||
* Deep-clone an ECharts options object, stripping properties that could
|
||||
* inject HTML or executable content. Strings containing HTML tags are
|
||||
* replaced with escaped versions.
|
||||
*/
|
||||
function sanitizeOptions(obj: Record<string, unknown>): Record<string, unknown> {
|
||||
const result: Record<string, unknown> = {};
|
||||
for (const [key, value] of Object.entries(obj)) {
|
||||
if (DANGEROUS_KEYS.has(key)) continue;
|
||||
if (containsHtml(value)) {
|
||||
result[key] = escapeHtml(value as string);
|
||||
} else if (Array.isArray(value)) {
|
||||
result[key] = value.map((item) =>
|
||||
isRecord(item)
|
||||
? sanitizeOptions(item)
|
||||
: containsHtml(item)
|
||||
? escapeHtml(item as string)
|
||||
: item,
|
||||
);
|
||||
} else if (isRecord(value)) {
|
||||
result[key] = sanitizeOptions(value);
|
||||
} else {
|
||||
result[key] = value;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function TimeseriesChartBlock({ block, isDarkMode }: { block: ChartBlock; isDarkMode: boolean }) {
|
||||
const config = block.config;
|
||||
if (config.chart_type !== "timeseries") return null;
|
||||
|
||||
const data = useMemo(
|
||||
() =>
|
||||
config.series.map((s, i) => ({
|
||||
name: escapeHtml(s.name),
|
||||
data: s.data,
|
||||
color: s.color ?? ChartPalette.color(i, isDarkMode),
|
||||
})),
|
||||
[config.series, isDarkMode],
|
||||
);
|
||||
|
||||
return (
|
||||
<TimeseriesChart
|
||||
echarts={echarts}
|
||||
isDarkMode={isDarkMode}
|
||||
type={config.style}
|
||||
data={data}
|
||||
xAxisName={config.x_axis_name ? escapeHtml(config.x_axis_name) : undefined}
|
||||
yAxisName={config.y_axis_name ? escapeHtml(config.y_axis_name) : undefined}
|
||||
height={config.height}
|
||||
gradient={config.gradient}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CustomChartBlock({ block, isDarkMode }: { block: ChartBlock; isDarkMode: boolean }) {
|
||||
const config = block.config;
|
||||
if (config.chart_type !== "custom") return null;
|
||||
|
||||
const safeOptions = useMemo(() => {
|
||||
const sanitized = sanitizeOptions(config.options);
|
||||
// Force richText tooltip mode — renders via canvas, not innerHTML
|
||||
if (isRecord(sanitized.tooltip)) {
|
||||
sanitized.tooltip.renderMode = "richText";
|
||||
} else {
|
||||
sanitized.tooltip = { renderMode: "richText" };
|
||||
}
|
||||
return sanitized;
|
||||
}, [config.options]);
|
||||
|
||||
return (
|
||||
<Chart
|
||||
echarts={echarts}
|
||||
isDarkMode={isDarkMode}
|
||||
options={safeOptions as EChartsOption}
|
||||
height={config.height}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function ChartBlockComponent({ block }: { block: ChartBlock }) {
|
||||
const isDarkMode = useIsDarkMode();
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-kumo-line p-4">
|
||||
{block.config.chart_type === "timeseries" ? (
|
||||
<TimeseriesChartBlock block={block} isDarkMode={isDarkMode} />
|
||||
) : (
|
||||
<CustomChartBlock block={block} isDarkMode={isDarkMode} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
7
packages/blocks/src/blocks/code.tsx
Normal file
7
packages/blocks/src/blocks/code.tsx
Normal file
@@ -0,0 +1,7 @@
|
||||
import { CodeBlock as KumoCodeBlock } from "@cloudflare/kumo";
|
||||
|
||||
import type { CodeBlock } from "../types.js";
|
||||
|
||||
export function CodeBlockComponent({ block }: { block: CodeBlock }) {
|
||||
return <KumoCodeBlock code={block.code} lang={block.language} />;
|
||||
}
|
||||
23
packages/blocks/src/blocks/columns.tsx
Normal file
23
packages/blocks/src/blocks/columns.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import { BlockRenderer } from "../renderer.js";
|
||||
import type { BlockInteraction, ColumnsBlock } from "../types.js";
|
||||
|
||||
export function ColumnsBlockComponent({
|
||||
block,
|
||||
onAction,
|
||||
}: {
|
||||
block: ColumnsBlock;
|
||||
onAction: (interaction: BlockInteraction) => void;
|
||||
}) {
|
||||
const colCount = Math.min(block.columns.length, 3);
|
||||
const gridClass = colCount === 2 ? "grid grid-cols-2 gap-4" : "grid grid-cols-3 gap-4";
|
||||
|
||||
return (
|
||||
<div className={gridClass}>
|
||||
{block.columns.map((col, i) => (
|
||||
<div key={i}>
|
||||
<BlockRenderer blocks={col} onAction={onAction} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
5
packages/blocks/src/blocks/context.tsx
Normal file
5
packages/blocks/src/blocks/context.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import type { ContextBlock } from "../types.js";
|
||||
|
||||
export function ContextBlockComponent({ block }: { block: ContextBlock }) {
|
||||
return <p className="text-sm text-kumo-subtle">{block.text}</p>;
|
||||
}
|
||||
3
packages/blocks/src/blocks/divider.tsx
Normal file
3
packages/blocks/src/blocks/divider.tsx
Normal file
@@ -0,0 +1,3 @@
|
||||
export function DividerBlockComponent() {
|
||||
return <hr className="my-4 border-kumo-line" />;
|
||||
}
|
||||
33
packages/blocks/src/blocks/empty.tsx
Normal file
33
packages/blocks/src/blocks/empty.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import { Empty } from "@cloudflare/kumo";
|
||||
import { Package } from "@phosphor-icons/react";
|
||||
|
||||
import { renderElement } from "../render-element.js";
|
||||
import type { BlockInteraction, EmptyBlock } from "../types.js";
|
||||
|
||||
export function EmptyBlockComponent({
|
||||
block,
|
||||
onAction,
|
||||
}: {
|
||||
block: EmptyBlock;
|
||||
onAction: (interaction: BlockInteraction) => void;
|
||||
}) {
|
||||
const contents =
|
||||
block.actions && block.actions.length > 0 ? (
|
||||
<div className="flex flex-wrap justify-center gap-2">
|
||||
{block.actions.map((el, i) => (
|
||||
<div key={el.action_id ?? i}>{renderElement(el, onAction)}</div>
|
||||
))}
|
||||
</div>
|
||||
) : undefined;
|
||||
|
||||
return (
|
||||
<Empty
|
||||
icon={<Package size={48} weight="duotone" />}
|
||||
title={block.title}
|
||||
description={block.description}
|
||||
commandLine={block.command_line}
|
||||
size={block.size}
|
||||
contents={contents}
|
||||
/>
|
||||
);
|
||||
}
|
||||
16
packages/blocks/src/blocks/fields.tsx
Normal file
16
packages/blocks/src/blocks/fields.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
import type { FieldsBlock } from "../types.js";
|
||||
|
||||
export function FieldsBlockComponent({ block }: { block: FieldsBlock }) {
|
||||
return (
|
||||
<div className="grid grid-cols-2 gap-x-6 gap-y-3">
|
||||
{block.fields.map((field, i) => (
|
||||
<div key={i}>
|
||||
<div className="text-sm text-kumo-subtle">{field.label}</div>
|
||||
<div className="text-kumo-default truncate" title={field.value}>
|
||||
{field.value}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
75
packages/blocks/src/blocks/form.tsx
Normal file
75
packages/blocks/src/blocks/form.tsx
Normal file
@@ -0,0 +1,75 @@
|
||||
import { Button } from "@cloudflare/kumo";
|
||||
import { useCallback, useState } from "react";
|
||||
|
||||
import { renderElement } from "../render-element.js";
|
||||
import type { BlockInteraction, FieldCondition, FormBlock, FormField } from "../types.js";
|
||||
|
||||
function deepEqual(a: unknown, b: unknown): boolean {
|
||||
if (a === b) return true;
|
||||
if (Array.isArray(a) && Array.isArray(b)) {
|
||||
if (a.length !== b.length) return false;
|
||||
return a.every((v, i) => deepEqual(v, b[i]));
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function evaluateCondition(condition: FieldCondition, values: Record<string, unknown>): boolean {
|
||||
const fieldValue = values[condition.field];
|
||||
if ("eq" in condition && condition.eq !== undefined) {
|
||||
return deepEqual(fieldValue, condition.eq);
|
||||
}
|
||||
if ("neq" in condition && condition.neq !== undefined) {
|
||||
return !deepEqual(fieldValue, condition.neq);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function getInitialValues(fields: FormField[]): Record<string, unknown> {
|
||||
const values: Record<string, unknown> = {};
|
||||
for (const field of fields) {
|
||||
if ("initial_value" in field && field.initial_value !== undefined) {
|
||||
values[field.action_id] = field.initial_value;
|
||||
}
|
||||
}
|
||||
return values;
|
||||
}
|
||||
|
||||
export function FormBlockComponent({
|
||||
block,
|
||||
onAction,
|
||||
}: {
|
||||
block: FormBlock;
|
||||
onAction: (interaction: BlockInteraction) => void;
|
||||
}) {
|
||||
const [values, setValues] = useState<Record<string, unknown>>(() =>
|
||||
getInitialValues(block.fields),
|
||||
);
|
||||
|
||||
const handleChange = useCallback((actionId: string, value: unknown) => {
|
||||
setValues((prev) => ({ ...prev, [actionId]: value }));
|
||||
}, []);
|
||||
|
||||
function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
onAction({
|
||||
type: "form_submit",
|
||||
action_id: block.submit.action_id,
|
||||
block_id: block.block_id,
|
||||
values,
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
|
||||
{block.fields.map((field) => {
|
||||
if (field.condition && !evaluateCondition(field.condition, values)) {
|
||||
return null;
|
||||
}
|
||||
return <div key={field.action_id}>{renderElement(field, onAction, handleChange)}</div>;
|
||||
})}
|
||||
<div>
|
||||
<Button type="submit">{block.submit.label}</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
5
packages/blocks/src/blocks/header.tsx
Normal file
5
packages/blocks/src/blocks/header.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import type { HeaderBlock } from "../types.js";
|
||||
|
||||
export function HeaderBlockComponent({ block }: { block: HeaderBlock }) {
|
||||
return <h2 className="text-xl font-bold text-kumo-default">{block.text}</h2>;
|
||||
}
|
||||
12
packages/blocks/src/blocks/image.tsx
Normal file
12
packages/blocks/src/blocks/image.tsx
Normal file
@@ -0,0 +1,12 @@
|
||||
import type { ImageBlock } from "../types.js";
|
||||
|
||||
export function ImageBlockComponent({ block }: { block: ImageBlock }) {
|
||||
return (
|
||||
<figure>
|
||||
<img src={block.url} alt={block.alt} className="max-w-full rounded" />
|
||||
{block.title && (
|
||||
<figcaption className="mt-1 text-sm text-kumo-subtle">{block.title}</figcaption>
|
||||
)}
|
||||
</figure>
|
||||
);
|
||||
}
|
||||
15
packages/blocks/src/blocks/meter.tsx
Normal file
15
packages/blocks/src/blocks/meter.tsx
Normal file
@@ -0,0 +1,15 @@
|
||||
import { Meter } from "@cloudflare/kumo";
|
||||
|
||||
import type { MeterBlock } from "../types.js";
|
||||
|
||||
export function MeterBlockComponent({ block }: { block: MeterBlock }) {
|
||||
return (
|
||||
<Meter
|
||||
label={block.label}
|
||||
value={block.value}
|
||||
max={block.max}
|
||||
min={block.min}
|
||||
customValue={block.custom_value}
|
||||
/>
|
||||
);
|
||||
}
|
||||
19
packages/blocks/src/blocks/section.tsx
Normal file
19
packages/blocks/src/blocks/section.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import { renderElement } from "../render-element.js";
|
||||
import type { BlockInteraction, SectionBlock } from "../types.js";
|
||||
|
||||
export function SectionBlockComponent({
|
||||
block,
|
||||
onAction,
|
||||
}: {
|
||||
block: SectionBlock;
|
||||
onAction: (interaction: BlockInteraction) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex-1 text-kumo-default">{block.text}</div>
|
||||
{block.accessory && (
|
||||
<div className="flex-shrink-0">{renderElement(block.accessory, onAction)}</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
40
packages/blocks/src/blocks/stats.tsx
Normal file
40
packages/blocks/src/blocks/stats.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
import { ArrowDown, ArrowUp, Minus } from "@phosphor-icons/react";
|
||||
|
||||
import type { StatItem, StatsBlock } from "../types.js";
|
||||
import { cn } from "../utils.js";
|
||||
|
||||
const trendConfig = {
|
||||
up: { icon: ArrowUp, color: "text-green-600" },
|
||||
down: { icon: ArrowDown, color: "text-red-600" },
|
||||
neutral: { icon: Minus, color: "text-kumo-subtle" },
|
||||
} as const;
|
||||
|
||||
function StatCard({ item }: { item: StatItem }) {
|
||||
const trend = item.trend ? trendConfig[item.trend] : null;
|
||||
const TrendIcon = trend?.icon;
|
||||
|
||||
return (
|
||||
<div className="flex-1 rounded-lg border border-kumo-line p-4">
|
||||
<div className="text-sm text-kumo-subtle">{item.label}</div>
|
||||
<div className="mt-1 flex items-baseline gap-2">
|
||||
<span className="text-2xl font-bold text-kumo-default">{item.value}</span>
|
||||
{TrendIcon && (
|
||||
<span className={cn("flex items-center", trend.color)}>
|
||||
<TrendIcon size={16} />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{item.description && <div className="mt-1 text-sm text-kumo-subtle">{item.description}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function StatsBlockComponent({ block }: { block: StatsBlock }) {
|
||||
return (
|
||||
<div className="flex gap-4">
|
||||
{block.items.map((item, i) => (
|
||||
<StatCard key={i} item={item} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
30
packages/blocks/src/blocks/tab.tsx
Normal file
30
packages/blocks/src/blocks/tab.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import { Tabs } from "@cloudflare/kumo";
|
||||
import { useState } from "react";
|
||||
|
||||
import { BlockRenderer } from "../renderer.js";
|
||||
import type { BlockInteraction, TabBlock } from "../types.js";
|
||||
|
||||
export function TabBlockComponent({
|
||||
block,
|
||||
onAction,
|
||||
}: {
|
||||
block: TabBlock;
|
||||
onAction: (interaction: BlockInteraction) => void;
|
||||
}) {
|
||||
const [activeTab, setActiveTab] = useState(block.default_tab ?? 0);
|
||||
const tabs = block.panels.map((panel, i) => ({ value: String(i), label: panel.label }));
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Tabs
|
||||
variant="underline"
|
||||
value={String(activeTab)}
|
||||
onValueChange={(value) => setActiveTab(Number(value))}
|
||||
tabs={tabs}
|
||||
/>
|
||||
<div className="pt-4">
|
||||
<BlockRenderer blocks={block.panels[activeTab]?.blocks ?? []} onAction={onAction} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
122
packages/blocks/src/blocks/table.tsx
Normal file
122
packages/blocks/src/blocks/table.tsx
Normal file
@@ -0,0 +1,122 @@
|
||||
import { Badge } from "@cloudflare/kumo";
|
||||
import { ArrowDown, ArrowUp } from "@phosphor-icons/react";
|
||||
import { useState } from "react";
|
||||
|
||||
import type { BlockInteraction, TableBlock, TableColumn } from "../types.js";
|
||||
import { cn, formatRelativeTime } from "../utils.js";
|
||||
|
||||
function formatCell(value: unknown, format: TableColumn["format"]): React.ReactNode {
|
||||
let str: string;
|
||||
if (value == null) {
|
||||
str = "";
|
||||
} else if (typeof value === "string") {
|
||||
str = value;
|
||||
} else if (typeof value === "number" || typeof value === "boolean") {
|
||||
str = String(value);
|
||||
} else if (typeof value === "object") {
|
||||
str = JSON.stringify(value);
|
||||
} else {
|
||||
str = "";
|
||||
}
|
||||
switch (format) {
|
||||
case "badge":
|
||||
return <Badge>{str}</Badge>;
|
||||
case "relative_time":
|
||||
return str ? formatRelativeTime(str) : "";
|
||||
case "number": {
|
||||
const num = Number(value);
|
||||
return Number.isNaN(num) ? str : num.toLocaleString();
|
||||
}
|
||||
case "code":
|
||||
return <code className="rounded bg-kumo-tint px-1.5 py-0.5 font-mono text-sm">{str}</code>;
|
||||
default:
|
||||
return str;
|
||||
}
|
||||
}
|
||||
|
||||
export function TableBlockComponent({
|
||||
block,
|
||||
onAction,
|
||||
}: {
|
||||
block: TableBlock;
|
||||
onAction: (interaction: BlockInteraction) => void;
|
||||
}) {
|
||||
const [sort, setSort] = useState<{ key: string; dir: "asc" | "desc" } | null>(null);
|
||||
|
||||
function handleSort(key: string) {
|
||||
const next =
|
||||
sort?.key === key && sort.dir === "asc"
|
||||
? { key, dir: "desc" as const }
|
||||
: { key, dir: "asc" as const };
|
||||
setSort(next);
|
||||
onAction({
|
||||
type: "block_action",
|
||||
action_id: block.page_action_id,
|
||||
block_id: block.block_id,
|
||||
value: { sort: next },
|
||||
});
|
||||
}
|
||||
|
||||
function handleLoadMore() {
|
||||
onAction({
|
||||
type: "block_action",
|
||||
action_id: block.page_action_id,
|
||||
block_id: block.block_id,
|
||||
value: { cursor: block.next_cursor, sort },
|
||||
});
|
||||
}
|
||||
|
||||
if (block.rows.length === 0 && block.empty_text) {
|
||||
return <p className="py-4 text-center text-sm text-kumo-subtle">{block.empty_text}</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-start text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-kumo-line">
|
||||
{block.columns.map((col) => (
|
||||
<th
|
||||
key={col.key}
|
||||
className={cn(
|
||||
"px-3 py-2 text-sm font-medium text-kumo-subtle",
|
||||
col.sortable && "cursor-pointer select-none",
|
||||
)}
|
||||
onClick={col.sortable ? () => handleSort(col.key) : undefined}
|
||||
>
|
||||
<span className="inline-flex items-center gap-1">
|
||||
{col.label}
|
||||
{col.sortable &&
|
||||
sort?.key === col.key &&
|
||||
(sort.dir === "asc" ? <ArrowUp size={14} /> : <ArrowDown size={14} />)}
|
||||
</span>
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{block.rows.map((row, i) => (
|
||||
<tr key={i} className="border-b border-kumo-line last:border-0">
|
||||
{block.columns.map((col) => (
|
||||
<td key={col.key} className="px-3 py-2 text-kumo-default">
|
||||
{formatCell(row[col.key], col.format)}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{block.next_cursor && (
|
||||
<div className="mt-2 flex justify-center">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleLoadMore}
|
||||
className="text-sm text-kumo-link hover:underline"
|
||||
>
|
||||
Load more
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
535
packages/blocks/src/builders.ts
Normal file
535
packages/blocks/src/builders.ts
Normal file
@@ -0,0 +1,535 @@
|
||||
import type {
|
||||
AccordionBlock,
|
||||
ActionsBlock,
|
||||
BannerBlock,
|
||||
Block,
|
||||
ButtonElement,
|
||||
CheckboxElement,
|
||||
ChartBlock,
|
||||
ChartSeries,
|
||||
CodeBlock,
|
||||
ComboboxElement,
|
||||
ColumnsBlock,
|
||||
ConfirmDialog,
|
||||
ContextBlock,
|
||||
DateInputElement,
|
||||
RadioElement,
|
||||
RepeaterElement,
|
||||
RepeaterSubField,
|
||||
DividerBlock,
|
||||
Element,
|
||||
EmptyBlock,
|
||||
FieldsBlock,
|
||||
FormBlock,
|
||||
FormField,
|
||||
HeaderBlock,
|
||||
ImageBlock,
|
||||
MediaPickerElement,
|
||||
MeterBlock,
|
||||
NumberInputElement,
|
||||
SecretInputElement,
|
||||
SectionBlock,
|
||||
SelectElement,
|
||||
StatItem,
|
||||
StatsBlock,
|
||||
TableBlock,
|
||||
TableColumn,
|
||||
TextInputElement,
|
||||
ToggleElement,
|
||||
TabBlock,
|
||||
TabPanel,
|
||||
} from "./types.js";
|
||||
|
||||
// ── Block Builders ───────────────────────────────────────────────────────────
|
||||
|
||||
function header(text: string, opts?: { blockId?: string }): HeaderBlock {
|
||||
return {
|
||||
type: "header",
|
||||
text,
|
||||
...(opts?.blockId !== undefined && { block_id: opts.blockId }),
|
||||
};
|
||||
}
|
||||
|
||||
function section(text: string, opts?: { accessory?: Element; blockId?: string }): SectionBlock {
|
||||
return {
|
||||
type: "section",
|
||||
text,
|
||||
...(opts?.accessory !== undefined && { accessory: opts.accessory }),
|
||||
...(opts?.blockId !== undefined && { block_id: opts.blockId }),
|
||||
};
|
||||
}
|
||||
|
||||
function divider(opts?: { blockId?: string }): DividerBlock {
|
||||
return {
|
||||
type: "divider",
|
||||
...(opts?.blockId !== undefined && { block_id: opts.blockId }),
|
||||
};
|
||||
}
|
||||
|
||||
function fieldsBlock(
|
||||
fields: Array<{ label: string; value: string }>,
|
||||
opts?: { blockId?: string },
|
||||
): FieldsBlock {
|
||||
return {
|
||||
type: "fields",
|
||||
fields,
|
||||
...(opts?.blockId !== undefined && { block_id: opts.blockId }),
|
||||
};
|
||||
}
|
||||
|
||||
function table(opts: {
|
||||
blockId?: string;
|
||||
columns: TableColumn[];
|
||||
rows: Array<Record<string, unknown>>;
|
||||
nextCursor?: string;
|
||||
pageActionId: string;
|
||||
emptyText?: string;
|
||||
}): TableBlock {
|
||||
return {
|
||||
type: "table",
|
||||
columns: opts.columns,
|
||||
rows: opts.rows,
|
||||
page_action_id: opts.pageActionId,
|
||||
...(opts.nextCursor !== undefined && { next_cursor: opts.nextCursor }),
|
||||
...(opts.emptyText !== undefined && { empty_text: opts.emptyText }),
|
||||
...(opts.blockId !== undefined && { block_id: opts.blockId }),
|
||||
};
|
||||
}
|
||||
|
||||
function actionsBlock(elements: Element[], opts?: { blockId?: string }): ActionsBlock {
|
||||
return {
|
||||
type: "actions",
|
||||
elements,
|
||||
...(opts?.blockId !== undefined && { block_id: opts.blockId }),
|
||||
};
|
||||
}
|
||||
|
||||
function stats(items: StatItem[], opts?: { blockId?: string }): StatsBlock {
|
||||
return {
|
||||
type: "stats",
|
||||
items,
|
||||
...(opts?.blockId !== undefined && { block_id: opts.blockId }),
|
||||
};
|
||||
}
|
||||
|
||||
function form(opts: {
|
||||
blockId?: string;
|
||||
fields: FormField[];
|
||||
submit: { label: string; actionId: string };
|
||||
}): FormBlock {
|
||||
return {
|
||||
type: "form",
|
||||
fields: opts.fields,
|
||||
submit: { label: opts.submit.label, action_id: opts.submit.actionId },
|
||||
...(opts.blockId !== undefined && { block_id: opts.blockId }),
|
||||
};
|
||||
}
|
||||
|
||||
function image(opts: { url: string; alt: string; title?: string; blockId?: string }): ImageBlock {
|
||||
return {
|
||||
type: "image",
|
||||
url: opts.url,
|
||||
alt: opts.alt,
|
||||
...(opts.title !== undefined && { title: opts.title }),
|
||||
...(opts.blockId !== undefined && { block_id: opts.blockId }),
|
||||
};
|
||||
}
|
||||
|
||||
function context(text: string, opts?: { blockId?: string }): ContextBlock {
|
||||
return {
|
||||
type: "context",
|
||||
text,
|
||||
...(opts?.blockId !== undefined && { block_id: opts.blockId }),
|
||||
};
|
||||
}
|
||||
|
||||
function columnsBlock(columns: Block[][], opts?: { blockId?: string }): ColumnsBlock {
|
||||
return {
|
||||
type: "columns",
|
||||
columns,
|
||||
...(opts?.blockId !== undefined && { block_id: opts.blockId }),
|
||||
};
|
||||
}
|
||||
|
||||
function bannerBlock(
|
||||
opts: {
|
||||
blockId?: string;
|
||||
variant?: "default" | "alert" | "error";
|
||||
} & ({ title: string; description?: string } | { title?: string; description: string }),
|
||||
): BannerBlock {
|
||||
return {
|
||||
type: "banner",
|
||||
...(opts.title !== undefined && { title: opts.title }),
|
||||
...(opts.description !== undefined && { description: opts.description }),
|
||||
...(opts.variant !== undefined && { variant: opts.variant }),
|
||||
...(opts.blockId !== undefined && { block_id: opts.blockId }),
|
||||
};
|
||||
}
|
||||
|
||||
// ── Element Builders ─────────────────────────────────────────────────────────
|
||||
|
||||
function textInput(
|
||||
actionId: string,
|
||||
label: string,
|
||||
opts?: {
|
||||
placeholder?: string;
|
||||
initialValue?: string;
|
||||
multiline?: boolean;
|
||||
},
|
||||
): TextInputElement {
|
||||
return {
|
||||
type: "text_input",
|
||||
action_id: actionId,
|
||||
label,
|
||||
...(opts?.placeholder !== undefined && { placeholder: opts.placeholder }),
|
||||
...(opts?.initialValue !== undefined && {
|
||||
initial_value: opts.initialValue,
|
||||
}),
|
||||
...(opts?.multiline !== undefined && { multiline: opts.multiline }),
|
||||
};
|
||||
}
|
||||
|
||||
function numberInput(
|
||||
actionId: string,
|
||||
label: string,
|
||||
opts?: { initialValue?: number; min?: number; max?: number },
|
||||
): NumberInputElement {
|
||||
return {
|
||||
type: "number_input",
|
||||
action_id: actionId,
|
||||
label,
|
||||
...(opts?.initialValue !== undefined && {
|
||||
initial_value: opts.initialValue,
|
||||
}),
|
||||
...(opts?.min !== undefined && { min: opts.min }),
|
||||
...(opts?.max !== undefined && { max: opts.max }),
|
||||
};
|
||||
}
|
||||
|
||||
function select(
|
||||
actionId: string,
|
||||
label: string,
|
||||
options: Array<{ label: string; value: string }>,
|
||||
opts?: { initialValue?: string },
|
||||
): SelectElement {
|
||||
return {
|
||||
type: "select",
|
||||
action_id: actionId,
|
||||
label,
|
||||
options,
|
||||
...(opts?.initialValue !== undefined && {
|
||||
initial_value: opts.initialValue,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
function toggle(
|
||||
actionId: string,
|
||||
label: string,
|
||||
opts?: { description?: string; initialValue?: boolean },
|
||||
): ToggleElement {
|
||||
return {
|
||||
type: "toggle",
|
||||
action_id: actionId,
|
||||
label,
|
||||
...(opts?.description !== undefined && { description: opts.description }),
|
||||
...(opts?.initialValue !== undefined && {
|
||||
initial_value: opts.initialValue,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
function button(
|
||||
actionId: string,
|
||||
label: string,
|
||||
opts?: {
|
||||
style?: "primary" | "danger" | "secondary";
|
||||
value?: unknown;
|
||||
confirm?: ConfirmDialog;
|
||||
},
|
||||
): ButtonElement {
|
||||
return {
|
||||
type: "button",
|
||||
action_id: actionId,
|
||||
label,
|
||||
...(opts?.style !== undefined && { style: opts.style }),
|
||||
...(opts?.value !== undefined && { value: opts.value }),
|
||||
...(opts?.confirm !== undefined && { confirm: opts.confirm }),
|
||||
};
|
||||
}
|
||||
|
||||
function secretInput(
|
||||
actionId: string,
|
||||
label: string,
|
||||
opts?: { placeholder?: string; hasValue?: boolean },
|
||||
): SecretInputElement {
|
||||
return {
|
||||
type: "secret_input",
|
||||
action_id: actionId,
|
||||
label,
|
||||
...(opts?.placeholder !== undefined && { placeholder: opts.placeholder }),
|
||||
...(opts?.hasValue !== undefined && { has_value: opts.hasValue }),
|
||||
};
|
||||
}
|
||||
|
||||
function checkbox(
|
||||
actionId: string,
|
||||
label: string,
|
||||
options: Array<{ label: string; value: string }>,
|
||||
opts?: { initialValue?: string[] },
|
||||
): CheckboxElement {
|
||||
return {
|
||||
type: "checkbox",
|
||||
action_id: actionId,
|
||||
label,
|
||||
options,
|
||||
...(opts?.initialValue !== undefined && { initial_value: opts.initialValue }),
|
||||
};
|
||||
}
|
||||
|
||||
function dateInput(
|
||||
actionId: string,
|
||||
label: string,
|
||||
opts?: { initialValue?: string; placeholder?: string },
|
||||
): DateInputElement {
|
||||
return {
|
||||
type: "date_input",
|
||||
action_id: actionId,
|
||||
label,
|
||||
...(opts?.initialValue !== undefined && { initial_value: opts.initialValue }),
|
||||
...(opts?.placeholder !== undefined && { placeholder: opts.placeholder }),
|
||||
};
|
||||
}
|
||||
|
||||
function combobox(
|
||||
actionId: string,
|
||||
label: string,
|
||||
options: Array<{ label: string; value: string }>,
|
||||
opts?: { initialValue?: string; placeholder?: string },
|
||||
): ComboboxElement {
|
||||
return {
|
||||
type: "combobox",
|
||||
action_id: actionId,
|
||||
label,
|
||||
options,
|
||||
...(opts?.initialValue !== undefined && { initial_value: opts.initialValue }),
|
||||
...(opts?.placeholder !== undefined && { placeholder: opts.placeholder }),
|
||||
};
|
||||
}
|
||||
|
||||
function radio(
|
||||
actionId: string,
|
||||
label: string,
|
||||
options: Array<{ label: string; value: string }>,
|
||||
opts?: { initialValue?: string },
|
||||
): RadioElement {
|
||||
return {
|
||||
type: "radio",
|
||||
action_id: actionId,
|
||||
label,
|
||||
options,
|
||||
...(opts?.initialValue !== undefined && { initial_value: opts.initialValue }),
|
||||
};
|
||||
}
|
||||
|
||||
function repeater(
|
||||
actionId: string,
|
||||
label: string,
|
||||
fields: RepeaterSubField[],
|
||||
opts?: {
|
||||
itemLabel?: string;
|
||||
minItems?: number;
|
||||
maxItems?: number;
|
||||
initialValue?: Array<Record<string, unknown>>;
|
||||
},
|
||||
): RepeaterElement {
|
||||
return {
|
||||
type: "repeater",
|
||||
action_id: actionId,
|
||||
label,
|
||||
fields,
|
||||
...(opts?.itemLabel !== undefined && { item_label: opts.itemLabel }),
|
||||
...(opts?.minItems !== undefined && { min_items: opts.minItems }),
|
||||
...(opts?.maxItems !== undefined && { max_items: opts.maxItems }),
|
||||
...(opts?.initialValue !== undefined && { initial_value: opts.initialValue }),
|
||||
};
|
||||
}
|
||||
|
||||
function mediaPicker(
|
||||
actionId: string,
|
||||
label: string,
|
||||
opts?: {
|
||||
mimeTypeFilter?: string;
|
||||
initialValue?: string;
|
||||
placeholder?: string;
|
||||
},
|
||||
): MediaPickerElement {
|
||||
return {
|
||||
type: "media_picker",
|
||||
action_id: actionId,
|
||||
label,
|
||||
...(opts?.mimeTypeFilter !== undefined && { mime_type_filter: opts.mimeTypeFilter }),
|
||||
...(opts?.initialValue !== undefined && { initial_value: opts.initialValue }),
|
||||
...(opts?.placeholder !== undefined && { placeholder: opts.placeholder }),
|
||||
};
|
||||
}
|
||||
|
||||
function timeseriesChart(opts: {
|
||||
blockId?: string;
|
||||
series: ChartSeries[];
|
||||
style?: "line" | "bar";
|
||||
xAxisName?: string;
|
||||
yAxisName?: string;
|
||||
height?: number;
|
||||
gradient?: boolean;
|
||||
}): ChartBlock {
|
||||
return {
|
||||
type: "chart",
|
||||
config: {
|
||||
chart_type: "timeseries",
|
||||
series: opts.series,
|
||||
...(opts.style !== undefined && { style: opts.style }),
|
||||
...(opts.xAxisName !== undefined && { x_axis_name: opts.xAxisName }),
|
||||
...(opts.yAxisName !== undefined && { y_axis_name: opts.yAxisName }),
|
||||
...(opts.height !== undefined && { height: opts.height }),
|
||||
...(opts.gradient !== undefined && { gradient: opts.gradient }),
|
||||
},
|
||||
...(opts.blockId !== undefined && { block_id: opts.blockId }),
|
||||
};
|
||||
}
|
||||
|
||||
function customChart(opts: {
|
||||
blockId?: string;
|
||||
options: Record<string, unknown>;
|
||||
height?: number;
|
||||
}): ChartBlock {
|
||||
return {
|
||||
type: "chart",
|
||||
config: {
|
||||
chart_type: "custom",
|
||||
options: opts.options,
|
||||
...(opts.height !== undefined && { height: opts.height }),
|
||||
},
|
||||
...(opts.blockId !== undefined && { block_id: opts.blockId }),
|
||||
};
|
||||
}
|
||||
|
||||
function meter(opts: {
|
||||
blockId?: string;
|
||||
label: string;
|
||||
value: number;
|
||||
max?: number;
|
||||
min?: number;
|
||||
customValue?: string;
|
||||
}): MeterBlock {
|
||||
return {
|
||||
type: "meter",
|
||||
label: opts.label,
|
||||
value: opts.value,
|
||||
...(opts.max !== undefined && { max: opts.max }),
|
||||
...(opts.min !== undefined && { min: opts.min }),
|
||||
...(opts.customValue !== undefined && { custom_value: opts.customValue }),
|
||||
...(opts.blockId !== undefined && { block_id: opts.blockId }),
|
||||
};
|
||||
}
|
||||
|
||||
function codeBlock(opts: {
|
||||
blockId?: string;
|
||||
code: string;
|
||||
language?: "ts" | "tsx" | "jsonc" | "bash" | "css";
|
||||
}): CodeBlock {
|
||||
return {
|
||||
type: "code",
|
||||
code: opts.code,
|
||||
...(opts.language !== undefined && { language: opts.language }),
|
||||
...(opts.blockId !== undefined && { block_id: opts.blockId }),
|
||||
};
|
||||
}
|
||||
|
||||
function tabBlock(
|
||||
panels: TabPanel[],
|
||||
opts?: {
|
||||
defaultTab?: number;
|
||||
blockId?: string;
|
||||
},
|
||||
): TabBlock {
|
||||
return {
|
||||
type: "tab",
|
||||
panels,
|
||||
...(opts?.defaultTab !== undefined && { default_tab: opts.defaultTab }),
|
||||
...(opts?.blockId !== undefined && { block_id: opts.blockId }),
|
||||
};
|
||||
}
|
||||
|
||||
function empty(opts: {
|
||||
blockId?: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
commandLine?: string;
|
||||
size?: "sm" | "base" | "lg";
|
||||
actions?: Element[];
|
||||
}): EmptyBlock {
|
||||
return {
|
||||
type: "empty",
|
||||
title: opts.title,
|
||||
...(opts.description !== undefined && { description: opts.description }),
|
||||
...(opts.commandLine !== undefined && { command_line: opts.commandLine }),
|
||||
...(opts.size !== undefined && { size: opts.size }),
|
||||
...(opts.actions !== undefined && { actions: opts.actions }),
|
||||
...(opts.blockId !== undefined && { block_id: opts.blockId }),
|
||||
};
|
||||
}
|
||||
|
||||
function accordion(opts: {
|
||||
blockId?: string;
|
||||
label: string;
|
||||
blocks: Block[];
|
||||
defaultOpen?: boolean;
|
||||
}): AccordionBlock {
|
||||
return {
|
||||
type: "accordion",
|
||||
label: opts.label,
|
||||
blocks: opts.blocks,
|
||||
...(opts.defaultOpen !== undefined && { default_open: opts.defaultOpen }),
|
||||
...(opts.blockId !== undefined && { block_id: opts.blockId }),
|
||||
};
|
||||
}
|
||||
|
||||
// ── Exports ──────────────────────────────────────────────────────────────────
|
||||
|
||||
export const blocks = {
|
||||
header,
|
||||
section,
|
||||
divider,
|
||||
fields: fieldsBlock,
|
||||
table,
|
||||
actions: actionsBlock,
|
||||
stats,
|
||||
form,
|
||||
image,
|
||||
context,
|
||||
columns: columnsBlock,
|
||||
timeseriesChart,
|
||||
customChart,
|
||||
banner: bannerBlock,
|
||||
meter,
|
||||
code: codeBlock,
|
||||
tab: tabBlock,
|
||||
empty,
|
||||
accordion,
|
||||
};
|
||||
|
||||
export const elements = {
|
||||
textInput,
|
||||
numberInput,
|
||||
select,
|
||||
toggle,
|
||||
button,
|
||||
secretInput,
|
||||
checkbox,
|
||||
combobox,
|
||||
dateInput,
|
||||
radio,
|
||||
repeater,
|
||||
mediaPicker,
|
||||
};
|
||||
69
packages/blocks/src/elements/button.tsx
Normal file
69
packages/blocks/src/elements/button.tsx
Normal file
@@ -0,0 +1,69 @@
|
||||
import { Button, Dialog, DialogRoot } from "@cloudflare/kumo";
|
||||
import { useCallback, useState } from "react";
|
||||
|
||||
import type { BlockInteraction, ButtonElement } from "../types.js";
|
||||
|
||||
export function ButtonElementComponent({
|
||||
element,
|
||||
onAction,
|
||||
}: {
|
||||
element: ButtonElement;
|
||||
onAction: (interaction: BlockInteraction) => void;
|
||||
}) {
|
||||
const [confirmOpen, setConfirmOpen] = useState(false);
|
||||
|
||||
const fireAction = useCallback(() => {
|
||||
onAction({
|
||||
type: "block_action",
|
||||
action_id: element.action_id,
|
||||
value: element.value,
|
||||
});
|
||||
}, [onAction, element.action_id, element.value]);
|
||||
|
||||
const handleClick = useCallback(() => {
|
||||
if (element.confirm) {
|
||||
setConfirmOpen(true);
|
||||
} else {
|
||||
fireAction();
|
||||
}
|
||||
}, [element.confirm, fireAction]);
|
||||
|
||||
const handleConfirm = useCallback(() => {
|
||||
setConfirmOpen(false);
|
||||
fireAction();
|
||||
}, [fireAction]);
|
||||
|
||||
const variant =
|
||||
element.style === "primary"
|
||||
? ("primary" as const)
|
||||
: element.style === "danger"
|
||||
? ("destructive" as const)
|
||||
: ("secondary" as const);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button variant={variant} onClick={handleClick}>
|
||||
{element.label}
|
||||
</Button>
|
||||
{element.confirm && (
|
||||
<DialogRoot open={confirmOpen} onOpenChange={setConfirmOpen}>
|
||||
<Dialog>
|
||||
<h3 className="text-lg font-semibold text-kumo-default">{element.confirm.title}</h3>
|
||||
<p className="mt-1 text-sm text-kumo-subtle">{element.confirm.text}</p>
|
||||
<div className="flex justify-end gap-2 pt-4">
|
||||
<Button variant="secondary" onClick={() => setConfirmOpen(false)}>
|
||||
{element.confirm.deny}
|
||||
</Button>
|
||||
<Button
|
||||
variant={element.confirm.style === "danger" ? "destructive" : "primary"}
|
||||
onClick={handleConfirm}
|
||||
>
|
||||
{element.confirm.confirm}
|
||||
</Button>
|
||||
</div>
|
||||
</Dialog>
|
||||
</DialogRoot>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
44
packages/blocks/src/elements/checkbox.tsx
Normal file
44
packages/blocks/src/elements/checkbox.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
import { Checkbox } from "@cloudflare/kumo";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
|
||||
import type { BlockInteraction, CheckboxElement } from "../types.js";
|
||||
|
||||
export function CheckboxElementComponent({
|
||||
element,
|
||||
onAction,
|
||||
onChange,
|
||||
}: {
|
||||
element: CheckboxElement;
|
||||
onAction: (interaction: BlockInteraction) => void;
|
||||
onChange?: (actionId: string, value: unknown) => void;
|
||||
}) {
|
||||
const [values, setValues] = useState<string[]>(element.initial_value ?? []);
|
||||
|
||||
useEffect(() => {
|
||||
setValues(element.initial_value ?? []);
|
||||
}, [element.initial_value]);
|
||||
|
||||
const handleChange = useCallback(
|
||||
(newValues: string[]) => {
|
||||
setValues(newValues);
|
||||
if (onChange) {
|
||||
onChange(element.action_id, newValues);
|
||||
} else {
|
||||
onAction({
|
||||
type: "block_action",
|
||||
action_id: element.action_id,
|
||||
value: newValues,
|
||||
});
|
||||
}
|
||||
},
|
||||
[onChange, onAction, element.action_id],
|
||||
);
|
||||
|
||||
return (
|
||||
<Checkbox.Group legend={element.label} value={values} onValueChange={handleChange}>
|
||||
{element.options.map((opt) => (
|
||||
<Checkbox.Item key={opt.value} value={opt.value} label={opt.label} />
|
||||
))}
|
||||
</Checkbox.Group>
|
||||
);
|
||||
}
|
||||
62
packages/blocks/src/elements/combobox.tsx
Normal file
62
packages/blocks/src/elements/combobox.tsx
Normal file
@@ -0,0 +1,62 @@
|
||||
import { Combobox } from "@cloudflare/kumo";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
|
||||
import type { BlockInteraction, ComboboxElement } from "../types.js";
|
||||
|
||||
export function ComboboxElementComponent({
|
||||
element,
|
||||
onAction,
|
||||
onChange,
|
||||
}: {
|
||||
element: ComboboxElement;
|
||||
onAction: (interaction: BlockInteraction) => void;
|
||||
onChange?: (actionId: string, value: unknown) => void;
|
||||
}) {
|
||||
const initialOption = useMemo(
|
||||
() => element.options.find((o) => o.value === element.initial_value) ?? null,
|
||||
[element.options, element.initial_value],
|
||||
);
|
||||
|
||||
const [selected, setSelected] = useState<{ label: string; value: string } | null>(initialOption);
|
||||
|
||||
useEffect(() => {
|
||||
setSelected(initialOption);
|
||||
}, [initialOption]);
|
||||
|
||||
const handleChange = useCallback(
|
||||
(newValue: unknown) => {
|
||||
const opt = newValue as { label: string; value: string } | null;
|
||||
setSelected(opt);
|
||||
const val = opt?.value ?? null;
|
||||
if (onChange) {
|
||||
onChange(element.action_id, val);
|
||||
} else {
|
||||
onAction({
|
||||
type: "block_action",
|
||||
action_id: element.action_id,
|
||||
value: val,
|
||||
});
|
||||
}
|
||||
},
|
||||
[onChange, onAction, element.action_id],
|
||||
);
|
||||
|
||||
return (
|
||||
<Combobox
|
||||
label={element.label}
|
||||
items={element.options}
|
||||
value={selected}
|
||||
onValueChange={handleChange}
|
||||
>
|
||||
<Combobox.TriggerInput placeholder={element.placeholder ?? "Search..."} />
|
||||
<Combobox.Content>
|
||||
<Combobox.List>
|
||||
{(item: { label: string; value: string }) => (
|
||||
<Combobox.Item value={item}>{item.label}</Combobox.Item>
|
||||
)}
|
||||
</Combobox.List>
|
||||
<Combobox.Empty>No results</Combobox.Empty>
|
||||
</Combobox.Content>
|
||||
</Combobox>
|
||||
);
|
||||
}
|
||||
57
packages/blocks/src/elements/date-input.tsx
Normal file
57
packages/blocks/src/elements/date-input.tsx
Normal file
@@ -0,0 +1,57 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
|
||||
import type { BlockInteraction, DateInputElement } from "../types.js";
|
||||
|
||||
export function DateInputElementComponent({
|
||||
element,
|
||||
onAction,
|
||||
onChange,
|
||||
}: {
|
||||
element: DateInputElement;
|
||||
onAction: (interaction: BlockInteraction) => void;
|
||||
onChange?: (actionId: string, value: unknown) => void;
|
||||
}) {
|
||||
const [value, setValue] = useState(element.initial_value ?? "");
|
||||
|
||||
useEffect(() => {
|
||||
setValue(element.initial_value ?? "");
|
||||
}, [element.initial_value]);
|
||||
|
||||
const handleChange = useCallback(
|
||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const newValue = e.target.value;
|
||||
setValue(newValue);
|
||||
if (onChange) {
|
||||
onChange(element.action_id, newValue);
|
||||
}
|
||||
},
|
||||
[onChange, element.action_id],
|
||||
);
|
||||
|
||||
const handleBlur = useCallback(
|
||||
(e: React.FocusEvent<HTMLInputElement>) => {
|
||||
if (!onChange) {
|
||||
onAction({
|
||||
type: "block_action",
|
||||
action_id: element.action_id,
|
||||
value: e.target.value,
|
||||
});
|
||||
}
|
||||
},
|
||||
[onChange, onAction, element.action_id],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-sm font-medium text-kumo-text">{element.label}</label>
|
||||
<input
|
||||
type="date"
|
||||
value={value}
|
||||
onChange={handleChange}
|
||||
onBlur={handleBlur}
|
||||
placeholder={element.placeholder}
|
||||
className="h-9 rounded-lg border border-kumo-line bg-kumo-bg px-3 text-sm text-kumo-text outline-none focus:ring-2 focus:ring-kumo-ring"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
50
packages/blocks/src/elements/number-input.tsx
Normal file
50
packages/blocks/src/elements/number-input.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
import { Input } from "@cloudflare/kumo";
|
||||
import { useCallback } from "react";
|
||||
|
||||
import type { BlockInteraction, NumberInputElement } from "../types.js";
|
||||
|
||||
export function NumberInputElementComponent({
|
||||
element,
|
||||
onAction,
|
||||
onChange,
|
||||
}: {
|
||||
element: NumberInputElement;
|
||||
onAction: (interaction: BlockInteraction) => void;
|
||||
onChange?: (actionId: string, value: unknown) => void;
|
||||
}) {
|
||||
const handleChange = useCallback(
|
||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const val = e.target.value === "" ? undefined : Number(e.target.value);
|
||||
if (onChange) {
|
||||
onChange(element.action_id, val);
|
||||
}
|
||||
},
|
||||
[onChange, element.action_id],
|
||||
);
|
||||
|
||||
const handleBlur = useCallback(
|
||||
(e: React.FocusEvent<HTMLInputElement>) => {
|
||||
if (!onChange) {
|
||||
const val = e.target.value === "" ? undefined : Number(e.target.value);
|
||||
onAction({
|
||||
type: "block_action",
|
||||
action_id: element.action_id,
|
||||
value: val,
|
||||
});
|
||||
}
|
||||
},
|
||||
[onChange, onAction, element.action_id],
|
||||
);
|
||||
|
||||
return (
|
||||
<Input
|
||||
label={element.label}
|
||||
type="number"
|
||||
min={element.min}
|
||||
max={element.max}
|
||||
defaultValue={element.initial_value}
|
||||
onChange={handleChange}
|
||||
onBlur={handleBlur}
|
||||
/>
|
||||
);
|
||||
}
|
||||
44
packages/blocks/src/elements/radio.tsx
Normal file
44
packages/blocks/src/elements/radio.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
import { Radio } from "@cloudflare/kumo";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
|
||||
import type { BlockInteraction, RadioElement } from "../types.js";
|
||||
|
||||
export function RadioElementComponent({
|
||||
element,
|
||||
onAction,
|
||||
onChange,
|
||||
}: {
|
||||
element: RadioElement;
|
||||
onAction: (interaction: BlockInteraction) => void;
|
||||
onChange?: (actionId: string, value: unknown) => void;
|
||||
}) {
|
||||
const [value, setValue] = useState(element.initial_value ?? "");
|
||||
|
||||
useEffect(() => {
|
||||
setValue(element.initial_value ?? "");
|
||||
}, [element.initial_value]);
|
||||
|
||||
const handleChange = useCallback(
|
||||
(newValue: string) => {
|
||||
setValue(newValue);
|
||||
if (onChange) {
|
||||
onChange(element.action_id, newValue);
|
||||
} else {
|
||||
onAction({
|
||||
type: "block_action",
|
||||
action_id: element.action_id,
|
||||
value: newValue,
|
||||
});
|
||||
}
|
||||
},
|
||||
[onChange, onAction, element.action_id],
|
||||
);
|
||||
|
||||
return (
|
||||
<Radio.Group legend={element.label} value={value} onValueChange={handleChange}>
|
||||
{element.options.map((opt) => (
|
||||
<Radio.Item key={opt.value} value={opt.value} label={opt.label} />
|
||||
))}
|
||||
</Radio.Group>
|
||||
);
|
||||
}
|
||||
70
packages/blocks/src/elements/secret-input.tsx
Normal file
70
packages/blocks/src/elements/secret-input.tsx
Normal file
@@ -0,0 +1,70 @@
|
||||
import { SensitiveInput } from "@cloudflare/kumo";
|
||||
import { useCallback, useState } from "react";
|
||||
|
||||
import type { BlockInteraction, SecretInputElement } from "../types.js";
|
||||
|
||||
export function SecretInputElementComponent({
|
||||
element,
|
||||
onAction,
|
||||
onChange,
|
||||
}: {
|
||||
element: SecretInputElement;
|
||||
onAction: (interaction: BlockInteraction) => void;
|
||||
onChange?: (actionId: string, value: unknown) => void;
|
||||
}) {
|
||||
const [value, setValue] = useState("");
|
||||
const [editing, setEditing] = useState(!element.has_value);
|
||||
|
||||
const handleValueChange = useCallback(
|
||||
(v: string) => {
|
||||
setValue(v);
|
||||
if (onChange) {
|
||||
onChange(element.action_id, v);
|
||||
}
|
||||
},
|
||||
[onChange, element.action_id],
|
||||
);
|
||||
|
||||
const handleFocus = useCallback(() => {
|
||||
if (!editing) {
|
||||
setEditing(true);
|
||||
setValue("");
|
||||
}
|
||||
}, [editing]);
|
||||
|
||||
const handleBlur = useCallback(() => {
|
||||
if (!onChange && value) {
|
||||
onAction({
|
||||
type: "block_action",
|
||||
action_id: element.action_id,
|
||||
value,
|
||||
});
|
||||
}
|
||||
if (!value && element.has_value) {
|
||||
setEditing(false);
|
||||
}
|
||||
}, [onChange, onAction, element.action_id, value, element.has_value]);
|
||||
|
||||
if (!editing) {
|
||||
return (
|
||||
<SensitiveInput
|
||||
label={element.label}
|
||||
value={"••••••••"}
|
||||
readOnly
|
||||
onFocus={handleFocus}
|
||||
placeholder={element.placeholder}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<SensitiveInput
|
||||
label={element.label}
|
||||
value={value}
|
||||
onValueChange={handleValueChange}
|
||||
onFocus={handleFocus}
|
||||
onBlur={handleBlur}
|
||||
placeholder={element.placeholder}
|
||||
/>
|
||||
);
|
||||
}
|
||||
43
packages/blocks/src/elements/select.tsx
Normal file
43
packages/blocks/src/elements/select.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
import { Select } from "@cloudflare/kumo";
|
||||
import { useCallback } from "react";
|
||||
|
||||
import type { BlockInteraction, SelectElement } from "../types.js";
|
||||
|
||||
export function SelectElementComponent({
|
||||
element,
|
||||
onAction,
|
||||
onChange,
|
||||
}: {
|
||||
element: SelectElement;
|
||||
onAction: (interaction: BlockInteraction) => void;
|
||||
onChange?: (actionId: string, value: unknown) => void;
|
||||
}) {
|
||||
const handleValueChange = useCallback(
|
||||
(value: unknown) => {
|
||||
if (onChange) {
|
||||
onChange(element.action_id, value);
|
||||
} else {
|
||||
onAction({
|
||||
type: "block_action",
|
||||
action_id: element.action_id,
|
||||
value,
|
||||
});
|
||||
}
|
||||
},
|
||||
[onChange, onAction, element.action_id],
|
||||
);
|
||||
|
||||
return (
|
||||
<Select
|
||||
label={element.label}
|
||||
defaultValue={element.initial_value}
|
||||
onValueChange={handleValueChange}
|
||||
>
|
||||
{element.options.map((opt) => (
|
||||
<Select.Option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</Select.Option>
|
||||
))}
|
||||
</Select>
|
||||
);
|
||||
}
|
||||
58
packages/blocks/src/elements/text-input.tsx
Normal file
58
packages/blocks/src/elements/text-input.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
import { Input, InputArea } from "@cloudflare/kumo";
|
||||
import { useCallback } from "react";
|
||||
|
||||
import type { BlockInteraction, TextInputElement } from "../types.js";
|
||||
|
||||
export function TextInputElementComponent({
|
||||
element,
|
||||
onAction,
|
||||
onChange,
|
||||
}: {
|
||||
element: TextInputElement;
|
||||
onAction: (interaction: BlockInteraction) => void;
|
||||
onChange?: (actionId: string, value: unknown) => void;
|
||||
}) {
|
||||
const handleChange = useCallback(
|
||||
(e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
|
||||
if (onChange) {
|
||||
onChange(element.action_id, e.target.value);
|
||||
}
|
||||
},
|
||||
[onChange, element.action_id],
|
||||
);
|
||||
|
||||
const handleBlur = useCallback(
|
||||
(e: React.FocusEvent<HTMLInputElement | HTMLTextAreaElement>) => {
|
||||
if (!onChange) {
|
||||
onAction({
|
||||
type: "block_action",
|
||||
action_id: element.action_id,
|
||||
value: e.target.value,
|
||||
});
|
||||
}
|
||||
},
|
||||
[onChange, onAction, element.action_id],
|
||||
);
|
||||
|
||||
if (element.multiline) {
|
||||
return (
|
||||
<InputArea
|
||||
label={element.label}
|
||||
placeholder={element.placeholder}
|
||||
defaultValue={element.initial_value}
|
||||
onChange={handleChange}
|
||||
onBlur={handleBlur}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Input
|
||||
label={element.label}
|
||||
placeholder={element.placeholder}
|
||||
defaultValue={element.initial_value}
|
||||
onChange={handleChange}
|
||||
onBlur={handleBlur}
|
||||
/>
|
||||
);
|
||||
}
|
||||
34
packages/blocks/src/elements/toggle.tsx
Normal file
34
packages/blocks/src/elements/toggle.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
import { Switch } from "@cloudflare/kumo";
|
||||
import { useCallback, useState } from "react";
|
||||
|
||||
import type { BlockInteraction, ToggleElement } from "../types.js";
|
||||
|
||||
export function ToggleElementComponent({
|
||||
element,
|
||||
onAction,
|
||||
onChange,
|
||||
}: {
|
||||
element: ToggleElement;
|
||||
onAction: (interaction: BlockInteraction) => void;
|
||||
onChange?: (actionId: string, value: unknown) => void;
|
||||
}) {
|
||||
const [checked, setChecked] = useState(element.initial_value ?? false);
|
||||
|
||||
const handleChange = useCallback(
|
||||
(value: boolean) => {
|
||||
setChecked(value);
|
||||
if (onChange) {
|
||||
onChange(element.action_id, value);
|
||||
} else {
|
||||
onAction({
|
||||
type: "block_action",
|
||||
action_id: element.action_id,
|
||||
value,
|
||||
});
|
||||
}
|
||||
},
|
||||
[onChange, onAction, element.action_id],
|
||||
);
|
||||
|
||||
return <Switch label={element.label} checked={checked} onCheckedChange={handleChange} />;
|
||||
}
|
||||
67
packages/blocks/src/index.ts
Normal file
67
packages/blocks/src/index.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
export { BlockRenderer } from "./renderer.js";
|
||||
export type { BlockRendererProps } from "./renderer.js";
|
||||
export { renderElement } from "./render-element.js";
|
||||
export { cn, formatRelativeTime } from "./utils.js";
|
||||
|
||||
// Builders and validation
|
||||
export { blocks, elements } from "./builders.js";
|
||||
export { validateBlocks } from "./validation.js";
|
||||
|
||||
// Re-export all types
|
||||
export type {
|
||||
// Composition objects
|
||||
ConfirmDialog,
|
||||
// Elements
|
||||
ButtonElement,
|
||||
TextInputElement,
|
||||
NumberInputElement,
|
||||
SelectElement,
|
||||
ToggleElement,
|
||||
SecretInputElement,
|
||||
CheckboxElement,
|
||||
ComboboxElement,
|
||||
DateInputElement,
|
||||
RadioElement,
|
||||
RepeaterElement,
|
||||
RepeaterSubField,
|
||||
MediaPickerElement,
|
||||
Element,
|
||||
// Form
|
||||
FieldCondition,
|
||||
FormField,
|
||||
// Block sub-types
|
||||
TableColumn,
|
||||
StatItem,
|
||||
ChartSeries,
|
||||
ChartConfig,
|
||||
TimeseriesChartConfig,
|
||||
CustomChartConfig,
|
||||
TabPanel,
|
||||
// Blocks
|
||||
HeaderBlock,
|
||||
SectionBlock,
|
||||
DividerBlock,
|
||||
FieldsBlock,
|
||||
TableBlock,
|
||||
ActionsBlock,
|
||||
StatsBlock,
|
||||
FormBlock,
|
||||
ImageBlock,
|
||||
ContextBlock,
|
||||
ColumnsBlock,
|
||||
ChartBlock,
|
||||
CodeBlock,
|
||||
TabBlock,
|
||||
BannerBlock,
|
||||
MeterBlock,
|
||||
EmptyBlock,
|
||||
AccordionBlock,
|
||||
Block,
|
||||
// Interactions
|
||||
BlockAction,
|
||||
FormSubmit,
|
||||
PageLoad,
|
||||
BlockInteraction,
|
||||
// Response
|
||||
BlockResponse,
|
||||
} from "./types.js";
|
||||
64
packages/blocks/src/render-element.tsx
Normal file
64
packages/blocks/src/render-element.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
import { ButtonElementComponent } from "./elements/button.js";
|
||||
import { CheckboxElementComponent } from "./elements/checkbox.js";
|
||||
import { ComboboxElementComponent } from "./elements/combobox.js";
|
||||
import { DateInputElementComponent } from "./elements/date-input.js";
|
||||
import { NumberInputElementComponent } from "./elements/number-input.js";
|
||||
import { RadioElementComponent } from "./elements/radio.js";
|
||||
import { SecretInputElementComponent } from "./elements/secret-input.js";
|
||||
import { SelectElementComponent } from "./elements/select.js";
|
||||
import { TextInputElementComponent } from "./elements/text-input.js";
|
||||
import { ToggleElementComponent } from "./elements/toggle.js";
|
||||
import type { BlockInteraction, Element } from "./types.js";
|
||||
|
||||
export function renderElement(
|
||||
element: Element,
|
||||
onAction: (interaction: BlockInteraction) => void,
|
||||
onChange?: (actionId: string, value: unknown) => void,
|
||||
): React.ReactNode {
|
||||
switch (element.type) {
|
||||
case "button":
|
||||
return <ButtonElementComponent element={element} onAction={onAction} />;
|
||||
case "text_input":
|
||||
return (
|
||||
<TextInputElementComponent element={element} onAction={onAction} onChange={onChange} />
|
||||
);
|
||||
case "number_input":
|
||||
return (
|
||||
<NumberInputElementComponent element={element} onAction={onAction} onChange={onChange} />
|
||||
);
|
||||
case "select":
|
||||
return <SelectElementComponent element={element} onAction={onAction} onChange={onChange} />;
|
||||
case "toggle":
|
||||
return <ToggleElementComponent element={element} onAction={onAction} onChange={onChange} />;
|
||||
case "secret_input":
|
||||
return (
|
||||
<SecretInputElementComponent element={element} onAction={onAction} onChange={onChange} />
|
||||
);
|
||||
case "checkbox":
|
||||
return <CheckboxElementComponent element={element} onAction={onAction} onChange={onChange} />;
|
||||
case "radio":
|
||||
return <RadioElementComponent element={element} onAction={onAction} onChange={onChange} />;
|
||||
case "date_input":
|
||||
return (
|
||||
<DateInputElementComponent element={element} onAction={onAction} onChange={onChange} />
|
||||
);
|
||||
case "combobox":
|
||||
return <ComboboxElementComponent element={element} onAction={onAction} onChange={onChange} />;
|
||||
case "repeater":
|
||||
// Admin-authoring only. The runtime block renderer never returns a
|
||||
// DOM node for `repeater` — values are persisted on the parent
|
||||
// block and consumed by the plugin's own runtime component.
|
||||
if ((import.meta as { env?: { DEV?: boolean } }).env?.DEV) {
|
||||
console.warn(
|
||||
"[blocks] renderElement: 'repeater' is an admin-authoring element and renders nothing at runtime",
|
||||
);
|
||||
}
|
||||
return null;
|
||||
case "media_picker":
|
||||
return null;
|
||||
default: {
|
||||
const _exhaustive: never = element;
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
82
packages/blocks/src/renderer.tsx
Normal file
82
packages/blocks/src/renderer.tsx
Normal file
@@ -0,0 +1,82 @@
|
||||
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 { CodeBlockComponent } from "./blocks/code.js";
|
||||
import { ColumnsBlockComponent } from "./blocks/columns.js";
|
||||
import { ContextBlockComponent } from "./blocks/context.js";
|
||||
import { DividerBlockComponent } from "./blocks/divider.js";
|
||||
import { EmptyBlockComponent } from "./blocks/empty.js";
|
||||
import { FieldsBlockComponent } from "./blocks/fields.js";
|
||||
import { FormBlockComponent } from "./blocks/form.js";
|
||||
import { HeaderBlockComponent } from "./blocks/header.js";
|
||||
import { ImageBlockComponent } from "./blocks/image.js";
|
||||
import { MeterBlockComponent } from "./blocks/meter.js";
|
||||
import { SectionBlockComponent } from "./blocks/section.js";
|
||||
import { StatsBlockComponent } from "./blocks/stats.js";
|
||||
import { TabBlockComponent } from "./blocks/tab.js";
|
||||
import { TableBlockComponent } from "./blocks/table.js";
|
||||
import type { Block, BlockInteraction } from "./types.js";
|
||||
|
||||
function renderBlock(
|
||||
block: Block,
|
||||
onAction: (interaction: BlockInteraction) => void,
|
||||
): React.ReactNode {
|
||||
switch (block.type) {
|
||||
case "header":
|
||||
return <HeaderBlockComponent block={block} />;
|
||||
case "section":
|
||||
return <SectionBlockComponent block={block} onAction={onAction} />;
|
||||
case "divider":
|
||||
return <DividerBlockComponent />;
|
||||
case "fields":
|
||||
return <FieldsBlockComponent block={block} />;
|
||||
case "table":
|
||||
return <TableBlockComponent block={block} onAction={onAction} />;
|
||||
case "actions":
|
||||
return <ActionsBlockComponent block={block} onAction={onAction} />;
|
||||
case "stats":
|
||||
return <StatsBlockComponent block={block} />;
|
||||
case "form":
|
||||
return <FormBlockComponent block={block} onAction={onAction} />;
|
||||
case "image":
|
||||
return <ImageBlockComponent block={block} />;
|
||||
case "context":
|
||||
return <ContextBlockComponent block={block} />;
|
||||
case "columns":
|
||||
return <ColumnsBlockComponent block={block} onAction={onAction} />;
|
||||
case "chart":
|
||||
return <ChartBlockComponent block={block} />;
|
||||
case "meter":
|
||||
return <MeterBlockComponent block={block} />;
|
||||
case "banner":
|
||||
return <BannerBlockComponent block={block} />;
|
||||
case "code":
|
||||
return <CodeBlockComponent block={block} />;
|
||||
case "tab":
|
||||
return <TabBlockComponent block={block} onAction={onAction} />;
|
||||
case "empty":
|
||||
return <EmptyBlockComponent block={block} onAction={onAction} />;
|
||||
case "accordion":
|
||||
return <AccordionBlockComponent block={block} onAction={onAction} />;
|
||||
default: {
|
||||
const _exhaustive: never = block;
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export interface BlockRendererProps {
|
||||
blocks: Block[];
|
||||
onAction: (interaction: BlockInteraction) => void;
|
||||
}
|
||||
|
||||
export function BlockRenderer({ blocks, onAction }: BlockRendererProps) {
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
{blocks.map((block, i) => (
|
||||
<div key={block.block_id ?? i}>{renderBlock(block, onAction)}</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
49
packages/blocks/src/server.ts
Normal file
49
packages/blocks/src/server.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* Server-safe exports for @emdash-cms/blocks.
|
||||
*
|
||||
* Use this entry point in plugin route handlers and other server-side code
|
||||
* that doesn't have React available. Provides builders, validation, and types
|
||||
* without importing any React components.
|
||||
*/
|
||||
|
||||
export { blocks, elements } from "./builders.js";
|
||||
export { validateBlocks } from "./validation.js";
|
||||
|
||||
export type {
|
||||
// Composition objects
|
||||
ConfirmDialog,
|
||||
// Elements
|
||||
ButtonElement,
|
||||
TextInputElement,
|
||||
NumberInputElement,
|
||||
SelectElement,
|
||||
ToggleElement,
|
||||
SecretInputElement,
|
||||
Element,
|
||||
// Form
|
||||
FieldCondition,
|
||||
FormField,
|
||||
// Block sub-types
|
||||
TableColumn,
|
||||
StatItem,
|
||||
// Blocks
|
||||
HeaderBlock,
|
||||
SectionBlock,
|
||||
DividerBlock,
|
||||
FieldsBlock,
|
||||
TableBlock,
|
||||
ActionsBlock,
|
||||
StatsBlock,
|
||||
FormBlock,
|
||||
ImageBlock,
|
||||
ContextBlock,
|
||||
ColumnsBlock,
|
||||
Block,
|
||||
// Interactions
|
||||
BlockAction,
|
||||
FormSubmit,
|
||||
PageLoad,
|
||||
BlockInteraction,
|
||||
// Response
|
||||
BlockResponse,
|
||||
} from "./types.js";
|
||||
415
packages/blocks/src/types.ts
Normal file
415
packages/blocks/src/types.ts
Normal file
@@ -0,0 +1,415 @@
|
||||
// ── Composition Objects ──────────────────────────────────────────────────────
|
||||
|
||||
export interface ConfirmDialog {
|
||||
title: string;
|
||||
text: string;
|
||||
confirm: string;
|
||||
deny: string;
|
||||
style?: "danger";
|
||||
}
|
||||
|
||||
// ── Elements ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface ButtonElement {
|
||||
type: "button";
|
||||
action_id: string;
|
||||
label: string;
|
||||
style?: "primary" | "danger" | "secondary";
|
||||
value?: unknown;
|
||||
confirm?: ConfirmDialog;
|
||||
}
|
||||
|
||||
export interface TextInputElement {
|
||||
type: "text_input";
|
||||
action_id: string;
|
||||
label: string;
|
||||
placeholder?: string;
|
||||
initial_value?: string;
|
||||
multiline?: boolean;
|
||||
}
|
||||
|
||||
export interface NumberInputElement {
|
||||
type: "number_input";
|
||||
action_id: string;
|
||||
label: string;
|
||||
initial_value?: number;
|
||||
min?: number;
|
||||
max?: number;
|
||||
}
|
||||
|
||||
export interface SelectElement {
|
||||
type: "select";
|
||||
action_id: string;
|
||||
label: string;
|
||||
options: Array<{ label: string; value: string }>;
|
||||
initial_value?: string;
|
||||
/** Plugin route that returns `{ items: Array<{ id, name }> }` to populate options dynamically */
|
||||
optionsRoute?: string;
|
||||
}
|
||||
|
||||
export interface ToggleElement {
|
||||
type: "toggle";
|
||||
action_id: string;
|
||||
label: string;
|
||||
description?: string;
|
||||
initial_value?: boolean;
|
||||
}
|
||||
|
||||
export interface SecretInputElement {
|
||||
type: "secret_input";
|
||||
action_id: string;
|
||||
label: string;
|
||||
placeholder?: string;
|
||||
has_value?: boolean;
|
||||
}
|
||||
|
||||
export interface CheckboxElement {
|
||||
type: "checkbox";
|
||||
action_id: string;
|
||||
label: string;
|
||||
options: Array<{ label: string; value: string }>;
|
||||
initial_value?: string[];
|
||||
}
|
||||
|
||||
export interface DateInputElement {
|
||||
type: "date_input";
|
||||
action_id: string;
|
||||
label: string;
|
||||
initial_value?: string;
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
export interface ComboboxElement {
|
||||
type: "combobox";
|
||||
action_id: string;
|
||||
label: string;
|
||||
options: Array<{ label: string; value: string }>;
|
||||
initial_value?: string;
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
export interface RadioElement {
|
||||
type: "radio";
|
||||
action_id: string;
|
||||
label: string;
|
||||
options: Array<{ label: string; value: string }>;
|
||||
initial_value?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sub-field types allowed inside a RepeaterElement. Limited to the scalar
|
||||
* inputs the admin widget currently renders inline.
|
||||
*/
|
||||
export type RepeaterSubField =
|
||||
| TextInputElement
|
||||
| NumberInputElement
|
||||
| SelectElement
|
||||
| ToggleElement;
|
||||
|
||||
/**
|
||||
* Array-of-objects field. Renders as a list of collapsible cards with inline
|
||||
* add/remove and drag-and-drop reordering. Sub-fields are scalar Block Kit
|
||||
* elements keyed by their `action_id`.
|
||||
*
|
||||
* Admin-authoring only: this element is rendered by the admin widget so plugin
|
||||
* blocks can capture repeating data. The runtime block renderer
|
||||
* (`renderElement`) deliberately returns `null` for `repeater` — repeater
|
||||
* values are persisted on the parent block and consumed by the plugin's own
|
||||
* runtime component, not re-rendered as a stand-alone block.
|
||||
*/
|
||||
export interface RepeaterElement {
|
||||
type: "repeater";
|
||||
action_id: string;
|
||||
label: string;
|
||||
/** Singular label used in the UI (e.g. "FAQ" → "Add FAQ"). */
|
||||
item_label?: string;
|
||||
fields: RepeaterSubField[];
|
||||
min_items?: number;
|
||||
max_items?: number;
|
||||
/**
|
||||
* Default rows for the field. Note: the admin widget seeds new rows from
|
||||
* the sub-field types (empty string / `false`), not from `initial_value`;
|
||||
* plugins should populate persisted state via the form `values` payload
|
||||
* instead of relying on `initial_value` for pre-filled rows.
|
||||
*/
|
||||
initial_value?: Array<Record<string, unknown>>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Picks an item from the media library (or uploads a new one). The stored value
|
||||
* is the selected asset's URL string, so this element is value-compatible with a
|
||||
* plain `text_input` — existing content continues to work after swapping.
|
||||
*/
|
||||
export interface MediaPickerElement {
|
||||
type: "media_picker";
|
||||
action_id: string;
|
||||
label: string;
|
||||
/** Mime-type prefix filter (e.g. "image/"). Defaults to "image/". */
|
||||
mime_type_filter?: string;
|
||||
initial_value?: string;
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
export type Element =
|
||||
| ButtonElement
|
||||
| TextInputElement
|
||||
| NumberInputElement
|
||||
| SelectElement
|
||||
| ToggleElement
|
||||
| SecretInputElement
|
||||
| CheckboxElement
|
||||
| DateInputElement
|
||||
| ComboboxElement
|
||||
| RadioElement
|
||||
| RepeaterElement
|
||||
| MediaPickerElement;
|
||||
|
||||
// ── Form Fields (elements + optional condition) ──────────────────────────────
|
||||
|
||||
export type FieldCondition =
|
||||
| { field: string; eq?: unknown; neq?: never }
|
||||
| { field: string; neq?: unknown; eq?: never };
|
||||
|
||||
export type FormField = (
|
||||
| ButtonElement
|
||||
| TextInputElement
|
||||
| NumberInputElement
|
||||
| SelectElement
|
||||
| ToggleElement
|
||||
| SecretInputElement
|
||||
| CheckboxElement
|
||||
| DateInputElement
|
||||
| ComboboxElement
|
||||
| RadioElement
|
||||
) & {
|
||||
condition?: FieldCondition;
|
||||
};
|
||||
|
||||
// ── Block Sub-types ──────────────────────────────────────────────────────────
|
||||
|
||||
export interface TableColumn {
|
||||
key: string;
|
||||
label: string;
|
||||
format?: "text" | "badge" | "relative_time" | "number" | "code";
|
||||
sortable?: boolean;
|
||||
}
|
||||
|
||||
export interface StatItem {
|
||||
label: string;
|
||||
value: string | number;
|
||||
description?: string;
|
||||
trend?: "up" | "down" | "neutral";
|
||||
}
|
||||
|
||||
/** A single data series for a timeseries chart. */
|
||||
export interface ChartSeries {
|
||||
/** Display name shown in tooltips and legends */
|
||||
name: string;
|
||||
/** Array of `[timestamp_ms, value]` tuples ordered by time */
|
||||
data: [number, number][];
|
||||
/**
|
||||
* Hex color for this series. If omitted, an automatic categorical color
|
||||
* from the Kumo palette is assigned based on the series index.
|
||||
*/
|
||||
color?: string;
|
||||
}
|
||||
|
||||
/** Timeseries-specific chart configuration */
|
||||
export interface TimeseriesChartConfig {
|
||||
chart_type: "timeseries";
|
||||
/** Visual style of each series. Defaults to `"line"`. */
|
||||
style?: "line" | "bar";
|
||||
/** Array of time series to display */
|
||||
series: ChartSeries[];
|
||||
/** Label for the x-axis */
|
||||
x_axis_name?: string;
|
||||
/** Label for the y-axis */
|
||||
y_axis_name?: string;
|
||||
/** Height of the chart in pixels. Defaults to 350. */
|
||||
height?: number;
|
||||
/** Render a gradient fill beneath line series */
|
||||
gradient?: boolean;
|
||||
}
|
||||
|
||||
/** Custom chart configuration using raw ECharts options (pie, etc.) */
|
||||
export interface CustomChartConfig {
|
||||
chart_type: "custom";
|
||||
/** Raw ECharts option object — passed through to `chart.setOption()` */
|
||||
options: Record<string, unknown>;
|
||||
/** Height of the chart in pixels. Defaults to 350. */
|
||||
height?: number;
|
||||
}
|
||||
|
||||
export type ChartConfig = TimeseriesChartConfig | CustomChartConfig;
|
||||
|
||||
// ── Blocks ───────────────────────────────────────────────────────────────────
|
||||
|
||||
interface BlockBase {
|
||||
block_id?: string;
|
||||
}
|
||||
|
||||
export interface HeaderBlock extends BlockBase {
|
||||
type: "header";
|
||||
text: string;
|
||||
}
|
||||
|
||||
export interface SectionBlock extends BlockBase {
|
||||
type: "section";
|
||||
text: string;
|
||||
accessory?: Element;
|
||||
}
|
||||
|
||||
export interface DividerBlock extends BlockBase {
|
||||
type: "divider";
|
||||
}
|
||||
|
||||
export interface FieldsBlock extends BlockBase {
|
||||
type: "fields";
|
||||
fields: Array<{ label: string; value: string }>;
|
||||
}
|
||||
|
||||
export interface TableBlock extends BlockBase {
|
||||
type: "table";
|
||||
columns: TableColumn[];
|
||||
rows: Array<Record<string, unknown>>;
|
||||
next_cursor?: string;
|
||||
page_action_id: string;
|
||||
empty_text?: string;
|
||||
}
|
||||
|
||||
export interface ActionsBlock extends BlockBase {
|
||||
type: "actions";
|
||||
elements: Element[];
|
||||
}
|
||||
|
||||
export interface StatsBlock extends BlockBase {
|
||||
type: "stats";
|
||||
items: StatItem[];
|
||||
}
|
||||
|
||||
export interface FormBlock extends BlockBase {
|
||||
type: "form";
|
||||
fields: FormField[];
|
||||
submit: { label: string; action_id: string };
|
||||
}
|
||||
|
||||
export interface ImageBlock extends BlockBase {
|
||||
type: "image";
|
||||
url: string;
|
||||
alt: string;
|
||||
title?: string;
|
||||
}
|
||||
|
||||
export interface ContextBlock extends BlockBase {
|
||||
type: "context";
|
||||
text: string;
|
||||
}
|
||||
|
||||
export interface ColumnsBlock extends BlockBase {
|
||||
type: "columns";
|
||||
columns: Block[][];
|
||||
}
|
||||
|
||||
export interface ChartBlock extends BlockBase {
|
||||
type: "chart";
|
||||
config: ChartConfig;
|
||||
}
|
||||
|
||||
export interface BannerBlock extends BlockBase {
|
||||
type: "banner";
|
||||
title?: string;
|
||||
description?: string;
|
||||
variant?: "default" | "alert" | "error";
|
||||
}
|
||||
|
||||
export interface MeterBlock extends BlockBase {
|
||||
type: "meter";
|
||||
label: string;
|
||||
value: number;
|
||||
max?: number;
|
||||
min?: number;
|
||||
custom_value?: string;
|
||||
}
|
||||
|
||||
export interface CodeBlock extends BlockBase {
|
||||
type: "code";
|
||||
code: string;
|
||||
language?: "ts" | "tsx" | "jsonc" | "bash" | "css";
|
||||
}
|
||||
|
||||
export interface TabPanel {
|
||||
label: string;
|
||||
blocks: Block[];
|
||||
}
|
||||
|
||||
export interface TabBlock extends BlockBase {
|
||||
type: "tab";
|
||||
panels: TabPanel[];
|
||||
default_tab?: number;
|
||||
}
|
||||
|
||||
export interface EmptyBlock extends BlockBase {
|
||||
type: "empty";
|
||||
title: string;
|
||||
description?: string;
|
||||
command_line?: string;
|
||||
size?: "sm" | "base" | "lg";
|
||||
actions?: Element[];
|
||||
}
|
||||
|
||||
export interface AccordionBlock extends BlockBase {
|
||||
type: "accordion";
|
||||
label: string;
|
||||
blocks: Block[];
|
||||
default_open?: boolean;
|
||||
}
|
||||
|
||||
export type Block =
|
||||
| HeaderBlock
|
||||
| SectionBlock
|
||||
| DividerBlock
|
||||
| FieldsBlock
|
||||
| TableBlock
|
||||
| ActionsBlock
|
||||
| StatsBlock
|
||||
| FormBlock
|
||||
| ImageBlock
|
||||
| ContextBlock
|
||||
| ColumnsBlock
|
||||
| ChartBlock
|
||||
| BannerBlock
|
||||
| MeterBlock
|
||||
| CodeBlock
|
||||
| TabBlock
|
||||
| EmptyBlock
|
||||
| AccordionBlock;
|
||||
|
||||
// ── Interactions ─────────────────────────────────────────────────────────────
|
||||
|
||||
export interface BlockAction {
|
||||
type: "block_action";
|
||||
action_id: string;
|
||||
block_id?: string;
|
||||
value?: unknown;
|
||||
}
|
||||
|
||||
export interface FormSubmit {
|
||||
type: "form_submit";
|
||||
action_id: string;
|
||||
block_id?: string;
|
||||
values: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface PageLoad {
|
||||
type: "page_load";
|
||||
page: string;
|
||||
}
|
||||
|
||||
export type BlockInteraction = BlockAction | FormSubmit | PageLoad;
|
||||
|
||||
// ── Response ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface BlockResponse {
|
||||
blocks: Block[];
|
||||
toast?: { message: string; type: "success" | "error" | "info" };
|
||||
}
|
||||
93
packages/blocks/src/utils.ts
Normal file
93
packages/blocks/src/utils.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
import { clsx } from "clsx";
|
||||
import { useEffect, useState } from "react";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
export function cn(...inputs: Parameters<typeof clsx>) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
|
||||
/**
|
||||
* Detects dark mode from `<html data-theme="dark">` or the system
|
||||
* `prefers-color-scheme` media query and stays in sync reactively.
|
||||
*/
|
||||
export function useIsDarkMode(): boolean {
|
||||
const [dark, setDark] = useState(() => {
|
||||
if (typeof document === "undefined") return false;
|
||||
const attr = document.documentElement.getAttribute("data-theme");
|
||||
if (attr === "dark") return true;
|
||||
if (attr === "light") return false;
|
||||
return window.matchMedia("(prefers-color-scheme: dark)").matches;
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
// Watch for data-theme attribute changes on <html>
|
||||
const observer = new MutationObserver(() => {
|
||||
const attr = document.documentElement.getAttribute("data-theme");
|
||||
if (attr === "dark") return setDark(true);
|
||||
if (attr === "light") return setDark(false);
|
||||
setDark(window.matchMedia("(prefers-color-scheme: dark)").matches);
|
||||
});
|
||||
observer.observe(document.documentElement, {
|
||||
attributes: true,
|
||||
attributeFilter: ["data-theme"],
|
||||
});
|
||||
|
||||
// Also watch the system media query
|
||||
const mq = window.matchMedia("(prefers-color-scheme: dark)");
|
||||
const handler = (e: MediaQueryListEvent) => {
|
||||
if (!document.documentElement.hasAttribute("data-theme")) {
|
||||
setDark(e.matches);
|
||||
}
|
||||
};
|
||||
mq.addEventListener("change", handler);
|
||||
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
mq.removeEventListener("change", handler);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return dark;
|
||||
}
|
||||
|
||||
const MINUTE = 60;
|
||||
const HOUR = 60 * MINUTE;
|
||||
const DAY = 24 * HOUR;
|
||||
const WEEK = 7 * DAY;
|
||||
const MONTH = 30 * DAY;
|
||||
const YEAR = 365 * DAY;
|
||||
|
||||
export function formatRelativeTime(iso: string): string {
|
||||
const date = new Date(iso);
|
||||
const now = Date.now();
|
||||
const diff = Math.floor((now - date.getTime()) / 1000);
|
||||
|
||||
if (diff < 0) {
|
||||
return "just now";
|
||||
}
|
||||
if (diff < MINUTE) {
|
||||
return "just now";
|
||||
}
|
||||
if (diff < HOUR) {
|
||||
const mins = Math.floor(diff / MINUTE);
|
||||
return mins === 1 ? "1 minute ago" : `${mins} minutes ago`;
|
||||
}
|
||||
if (diff < DAY) {
|
||||
const hours = Math.floor(diff / HOUR);
|
||||
return hours === 1 ? "1 hour ago" : `${hours} hours ago`;
|
||||
}
|
||||
if (diff < WEEK) {
|
||||
const days = Math.floor(diff / DAY);
|
||||
return days === 1 ? "1 day ago" : `${days} days ago`;
|
||||
}
|
||||
if (diff < MONTH) {
|
||||
const weeks = Math.floor(diff / WEEK);
|
||||
return weeks === 1 ? "1 week ago" : `${weeks} weeks ago`;
|
||||
}
|
||||
if (diff < YEAR) {
|
||||
const months = Math.floor(diff / MONTH);
|
||||
return months === 1 ? "1 month ago" : `${months} months ago`;
|
||||
}
|
||||
const years = Math.floor(diff / YEAR);
|
||||
return years === 1 ? "1 year ago" : `${years} years ago`;
|
||||
}
|
||||
1219
packages/blocks/src/validation.ts
Normal file
1219
packages/blocks/src/validation.ts
Normal file
File diff suppressed because it is too large
Load Diff
403
packages/blocks/tests/form-conditions.test.tsx
Normal file
403
packages/blocks/tests/form-conditions.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
664
packages/blocks/tests/renderer.test.tsx
Normal file
664
packages/blocks/tests/renderer.test.tsx
Normal 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",
|
||||
});
|
||||
});
|
||||
});
|
||||
821
packages/blocks/tests/validation.test.ts
Normal file
821
packages/blocks/tests/validation.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
});
|
||||
11
packages/blocks/tsconfig.json
Normal file
11
packages/blocks/tsconfig.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"jsx": "react-jsx",
|
||||
"lib": ["ES2023", "DOM", "DOM.Iterable"]
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist", "tests"]
|
||||
}
|
||||
10
packages/blocks/tsdown.config.ts
Normal file
10
packages/blocks/tsdown.config.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { defineConfig } from "tsdown";
|
||||
|
||||
export default defineConfig({
|
||||
entry: ["src/index.ts", "src/server.ts"],
|
||||
format: ["esm"],
|
||||
dts: true,
|
||||
clean: true,
|
||||
platform: "browser",
|
||||
external: ["react", "react-dom", "react/jsx-runtime", "react/jsx-dev-runtime"],
|
||||
});
|
||||
9
packages/blocks/vitest.config.ts
Normal file
9
packages/blocks/vitest.config.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import react from "@vitejs/plugin-react";
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
test: {
|
||||
environment: "jsdom",
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user