Emdash source with visual editor image upload fix

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

View File

@@ -0,0 +1,14 @@
# @emdash-cms/plugin-field-kit
## 0.1.0
### Minor Changes
- [#702](https://github.com/emdash-cms/emdash/pull/702) [`0ee372a`](https://github.com/emdash-cms/emdash/commit/0ee372a7f33eecce7d90e12624923d2d9c132adf) Thanks [@ilicfilip](https://github.com/ilicfilip)! - Adds `@emdash-cms/plugin-field-kit` — composable field widgets for `json` fields. Four widgets (`object-form`, `list`, `grid`, `tags`) are configured entirely through seed `options` so site builders don't need to write React to get a usable editing UI. Widgets store clean JSON (no nesting, no mutation of shape), so removing the plugin leaves valid data in the database. See discussion #571 for background.
Widens `FieldDescriptor.options` to `Array<{ value: string; label: string }> | Record<string, unknown>` so plugin widgets can accept arbitrary widget config (not only enum choices). The array shape for `select` / `multiSelect` continues to work unchanged.
### Patch Changes
- Updated dependencies [[`e2b3c6c`](https://github.com/emdash-cms/emdash/commit/e2b3c6cd930d5fa6fc607a0b26fd796f5b0f98b2), [`9dfc65c`](https://github.com/emdash-cms/emdash/commit/9dfc65c42c04c41088e0c8f5a8ca4347643e2fea), [`e0dc6fb`](https://github.com/emdash-cms/emdash/commit/e0dc6fb8adadc0e048f3f314d62bfa98d9bb48d4), [`c22fb3a`](https://github.com/emdash-cms/emdash/commit/c22fb3a10d445f12cca91620c9258d50695afa44), [`6a4e9b8`](https://github.com/emdash-cms/emdash/commit/6a4e9b8b0fa6064989224a42b14de435f487a76f), [`0ee372a`](https://github.com/emdash-cms/emdash/commit/0ee372a7f33eecce7d90e12624923d2d9c132adf), [`22a16ee`](https://github.com/emdash-cms/emdash/commit/22a16eed607a4e81391ecb6c45fe2e59aaca92fe), [`1e2b024`](https://github.com/emdash-cms/emdash/commit/1e2b02486ee0407e4f50b8342ba1a9e7d060e405), [`81662e9`](https://github.com/emdash-cms/emdash/commit/81662e98fcf1ad0ee880d4f1af96271c527d7423), [`2f22f57`](https://github.com/emdash-cms/emdash/commit/2f22f57abadf305cf6d3ce07ee78290178e032d1), [`ef3f076`](https://github.com/emdash-cms/emdash/commit/ef3f076c8112e9dffc2a87c019e5521e823f5e86), [`a9c29ea`](https://github.com/emdash-cms/emdash/commit/a9c29ea584300f6cf67206bedcb1d39f05ea1c26), [`e7df21f`](https://github.com/emdash-cms/emdash/commit/e7df21f0adca795cdb233d6e64cd543ead7e2347), [`d5f7c48`](https://github.com/emdash-cms/emdash/commit/d5f7c481a507868f470361cfd715a5828640d45a), [`8ae227c`](https://github.com/emdash-cms/emdash/commit/8ae227cceade5c9852897c7b56f89e7422ee82a1), [`e2d5d16`](https://github.com/emdash-cms/emdash/commit/e2d5d160acea4444945b1ea79c80ca9ce138965b), [`0d98c62`](https://github.com/emdash-cms/emdash/commit/0d98c620a5f407648f3b7f3cbd30b642c74be607), [`64bf5b9`](https://github.com/emdash-cms/emdash/commit/64bf5b98125ca18ec26f7e0e65a71fcbe71fd44f), [`e81aa0f`](https://github.com/emdash-cms/emdash/commit/e81aa0f717be11bacdff30ed9bbc454824268555), [`0041d76`](https://github.com/emdash-cms/emdash/commit/0041d7699b32b77b4cd2ecd77b97340f0dd3abce), [`cee403d`](https://github.com/emdash-cms/emdash/commit/cee403d5c008feb9ca60bb7201e151b828737743), [`a8bac5d`](https://github.com/emdash-cms/emdash/commit/a8bac5d7216e185b1bd9a2aaaeaa9a0306ab066e), [`5b6f059`](https://github.com/emdash-cms/emdash/commit/5b6f059d06175ae0cb740d1ba32867d1ec6b2249), [`a86ff80`](https://github.com/emdash-cms/emdash/commit/a86ff80836fed175508ff06f744c7ad6b805627c), [`d4be24f`](https://github.com/emdash-cms/emdash/commit/d4be24f478a0c8d0a7bba3c299e11105bba3ed94), [`eb6dbd0`](https://github.com/emdash-cms/emdash/commit/eb6dbd056717fd076a8b5fa807d91516a00f5f2f)]:
- emdash@0.9.0

View File

@@ -0,0 +1,48 @@
{
"name": "@emdash-cms/plugin-field-kit",
"version": "0.1.0",
"description": "Composable field widgets for EmDash CMS — object forms, lists, grids, and tag inputs for json fields",
"type": "module",
"main": "src/index.ts",
"exports": {
".": "./src/index.ts",
"./admin": "./src/admin.tsx"
},
"files": [
"src"
],
"keywords": [
"emdash",
"cms",
"plugin",
"field-widget",
"json"
],
"author": "Filip Ilic",
"license": "MIT",
"peerDependencies": {
"@cloudflare/kumo": "^1.0.0",
"@phosphor-icons/react": "^2.1.10",
"emdash": "workspace:>=0.9.0",
"react": "^18.0.0 || ^19.0.0"
},
"devDependencies": {
"@testing-library/react": "^16.3.0",
"@types/react": "catalog:",
"@types/react-dom": "catalog:",
"@vitejs/plugin-react": "^4.6.0",
"jsdom": "^26.1.0",
"react": "catalog:",
"react-dom": "catalog:",
"vitest": "catalog:"
},
"scripts": {
"test": "vitest run",
"typecheck": "tsgo --noEmit"
},
"repository": {
"type": "git",
"url": "git+https://github.com/emdash-cms/emdash.git",
"directory": "packages/plugins/field-kit"
}
}

View File

@@ -0,0 +1,11 @@
import { Grid } from "./widgets/grid";
import { List } from "./widgets/list";
import { ObjectForm } from "./widgets/object-form";
import { Tags } from "./widgets/tags";
export const fields = {
"object-form": ObjectForm,
list: List,
grid: Grid,
tags: Tags,
};

View File

@@ -0,0 +1,65 @@
/**
* Field Kit Plugin for EmDash CMS
*
* Provides composable field widgets for `json` fields configured entirely
* through seed options — no React code required from site builders.
*
* Ships four widgets:
* - object-form — inline form for flat JSON objects
* - list — ordered array editor with add/remove/reorder
* - grid — rows × columns matrix with configurable cell type
* - tags — free-form tag/chip input for string arrays
*
* Usage in astro.config.mjs:
* import { fieldKitPlugin } from "@emdash-cms/plugin-field-kit";
* emdash({ plugins: [fieldKitPlugin()] });
*
* Usage in a seed field:
* {
* "slug": "ingredients",
* "type": "json",
* "widget": "field-kit:list",
* "options": { "fields": [...], "summary": "{{name}}" }
* }
*/
import type { PluginDescriptor } from "emdash";
import { definePlugin } from "emdash";
const PLUGIN_ID = "field-kit";
const PLUGIN_VERSION = "0.0.0";
/**
* Create the field-kit plugin instance.
* Called by the virtual module system at runtime.
*/
export function createPlugin() {
return definePlugin({
id: PLUGIN_ID,
version: PLUGIN_VERSION,
admin: {
entry: "@emdash-cms/plugin-field-kit/admin",
fieldWidgets: [
{ name: "object-form", label: "Object form", fieldTypes: ["json"] },
{ name: "list", label: "List", fieldTypes: ["json"] },
{ name: "grid", label: "Grid", fieldTypes: ["json"] },
{ name: "tags", label: "Tags input", fieldTypes: ["json"] },
],
},
});
}
export default createPlugin;
/**
* Create a plugin descriptor for use in emdash config.
*/
export function fieldKitPlugin(): PluginDescriptor {
return {
id: PLUGIN_ID,
version: PLUGIN_VERSION,
entrypoint: "@emdash-cms/plugin-field-kit",
options: {},
adminEntry: "@emdash-cms/plugin-field-kit/admin",
};
}

View File

@@ -0,0 +1,213 @@
import { Input, InputArea, Select, Switch } from "@cloudflare/kumo";
import * as React from "react";
import type { SubFieldDef } from "./types";
interface SubFieldProps {
/**
* Unique DOM id for this sub-field instance. Required because the same
* sub-field key (e.g. "name") may render many times in a `list` widget,
* so the id must be composed per-instance by the caller to keep label
* and input association correct.
*/
id: string;
def: SubFieldDef;
value: unknown;
onChange: (value: unknown) => void;
}
function normalizeSelectItems(
options: SubFieldDef["options"],
): Array<{ label: string; value: string }> {
if (!options || !Array.isArray(options)) return [];
return options.map((opt) => (typeof opt === "string" ? { label: opt, value: opt } : opt));
}
/**
* Wrap a label with a required asterisk. Kumo's `Field` wrapper marks
* non-required fields with "(optional)" but does not display `*` for
* required ones, so we add it ourselves to make the requirement obvious.
*/
function labelWithRequired(label: string, required: boolean | undefined): React.ReactNode {
if (!required) return label;
return (
<>
{label}
<span className="ml-0.5 text-kumo-danger">*</span>
</>
);
}
/**
* Renders a single sub-field input based on its type definition.
* Used by object-form and list widgets.
*/
export function SubField({ id, def, value, onChange }: SubFieldProps) {
const fieldId = id;
switch (def.type) {
case "text":
return (
<Input
id={fieldId}
type="text"
label={labelWithRequired(def.label, def.required)}
description={def.helpText}
required={def.required}
placeholder={def.placeholder}
value={typeof value === "string" ? value : ""}
onChange={(e) => onChange(e.target.value)}
/>
);
case "url":
return (
<Input
id={fieldId}
type="url"
label={labelWithRequired(def.label, def.required)}
description={def.helpText}
required={def.required}
placeholder={def.placeholder ?? "https://"}
value={typeof value === "string" ? value : ""}
onChange={(e) => onChange(e.target.value)}
/>
);
case "number": {
const prefixOrSuffix = def.prefix || def.suffix;
const labelId = `${fieldId}-label`;
const numberInput = (
<Input
id={fieldId}
type="number"
label={prefixOrSuffix ? undefined : labelWithRequired(def.label, def.required)}
aria-labelledby={prefixOrSuffix ? labelId : undefined}
description={prefixOrSuffix ? undefined : def.helpText}
required={def.required}
placeholder={def.placeholder}
min={def.min}
max={def.max}
step={def.step}
value={typeof value === "number" ? value : ""}
onChange={(e) => {
const v = e.target.value;
onChange(v === "" ? undefined : Number(v));
}}
/>
);
if (!prefixOrSuffix) return numberInput;
return (
<div className="flex flex-col gap-1.5">
<label id={labelId} htmlFor={fieldId} className="text-sm font-medium text-kumo-default">
{def.label}
{def.required && <span className="ml-0.5 text-kumo-danger">*</span>}
</label>
<div className="flex items-center gap-2">
{def.prefix && (
<span className="whitespace-nowrap text-sm text-kumo-subtle">{def.prefix}</span>
)}
{numberInput}
{def.suffix && (
<span className="whitespace-nowrap text-sm text-kumo-subtle">{def.suffix}</span>
)}
</div>
{def.helpText && <p className="text-xs text-kumo-subtle">{def.helpText}</p>}
</div>
);
}
case "boolean":
return (
<Switch
id={fieldId}
label={def.label}
labelTooltip={def.helpText}
checked={!!value}
onCheckedChange={(checked) => onChange(checked)}
/>
);
case "select": {
const items = normalizeSelectItems(def.options);
return (
<Select
label={labelWithRequired(def.label, def.required)}
description={def.helpText}
required={def.required}
placeholder={def.placeholder ?? "Select..."}
value={typeof value === "string" ? value : ""}
onValueChange={(v) => onChange((v as string) === "" ? undefined : v)}
items={items}
/>
);
}
case "textarea":
return (
<InputArea
id={fieldId}
label={labelWithRequired(def.label, def.required)}
description={def.helpText}
required={def.required}
placeholder={def.placeholder}
rows={def.rows ?? 3}
value={typeof value === "string" ? value : ""}
onChange={(e) => onChange(e.target.value)}
/>
);
case "date":
return (
<Input
id={fieldId}
type="date"
label={labelWithRequired(def.label, def.required)}
description={def.helpText}
required={def.required}
value={typeof value === "string" ? value : ""}
onChange={(e) => onChange(e.target.value || undefined)}
/>
);
case "color":
return (
<div className="flex flex-col gap-1.5">
<label htmlFor={fieldId} className="text-sm font-medium text-kumo-default">
{def.label}
{def.required && <span className="ml-0.5 text-kumo-danger">*</span>}
</label>
<div className="flex items-center gap-2">
<input
id={fieldId}
type="color"
className="h-9 w-12 cursor-pointer rounded-md bg-kumo-base ring ring-kumo-hairline p-1"
value={typeof value === "string" ? value : "#000000"}
onChange={(e) => onChange(e.target.value)}
/>
<Input
type="text"
aria-label={`${def.label} hex value`}
placeholder="#000000"
value={typeof value === "string" ? value : ""}
onChange={(e) => onChange(e.target.value)}
/>
</div>
{def.helpText && <p className="text-xs text-kumo-subtle">{def.helpText}</p>}
</div>
);
default:
return (
<Input
id={fieldId}
type="text"
label={labelWithRequired(def.label, def.required)}
value={typeof value === "string" ? String(value) : ""}
onChange={(e) => onChange(e.target.value)}
/>
);
}
}

View File

@@ -0,0 +1,73 @@
/** Sub-field types available in object-form and list widgets. */
export type SubFieldType =
| "text"
| "number"
| "boolean"
| "select"
| "textarea"
| "date"
| "color"
| "url";
/** A single sub-field definition, used in object-form and list options.fields. */
export interface SubFieldDef {
/** JSON object key this sub-field maps to. */
key: string;
/** Display label. */
label: string;
/** Input type. */
type: SubFieldType;
/** Whether this sub-field is required. */
required?: boolean;
/** Placeholder text. */
placeholder?: string;
/** Help text shown below the input. */
helpText?: string;
/** Default value when creating new items. */
defaultValue?: unknown;
/**
* For type: "select" — the available options.
* Accepts either string[] or Array<{ label: string; value: string }>.
*/
options?: string[] | Array<{ label: string; value: string }>;
/** For type: "number" — minimum value. */
min?: number;
/** For type: "number" — maximum value. */
max?: number;
/** For type: "number" — step increment. */
step?: number;
/** For type: "number" — unit label after the input (e.g. "kg", "kcal"). */
suffix?: string;
/** For type: "number" — label before the input (e.g. "$"). */
prefix?: string;
/** For type: "textarea" — number of rows. */
rows?: number;
}
/** Props passed to every field widget component by EmDash admin. */
export interface FieldWidgetProps {
/** Current field value. */
value: unknown;
/** Callback to update the field value. Must receive the complete new value. */
onChange: (value: unknown) => void;
/** Field label from the schema. */
label: string;
/** HTML id attribute. */
id: string;
/** Whether the field is required. */
required?: boolean;
/** Widget-specific options from the seed field definition. */
options?: Record<string, unknown>;
/** When true, render in compact mode (hide the top-level label). */
minimal?: boolean;
}
/** Row/column definition for the grid widget. */
export interface GridAxisDef {
/** Unique key used in the stored value object. */
key: string;
/** Display label. */
label: string;
/** Optional icon image URL. */
image?: string;
}

View File

@@ -0,0 +1,101 @@
import type { SubFieldDef, GridAxisDef } from "./types";
/**
* Normalize a value into a plain object keyed by sub-field definitions.
* Missing declared keys get their defaultValue (or undefined). Keys present
* on the input that aren't declared in `fields` are preserved verbatim, so
* stored JSON round-trips cleanly when the schema evolves or partial data
* is managed outside this widget.
*/
export function normalizeObject(value: unknown, fields: SubFieldDef[]): Record<string, unknown> {
const source =
value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: {};
const obj: Record<string, unknown> = { ...source };
for (const field of fields) {
if (source[field.key] === undefined) {
obj[field.key] = field.defaultValue ?? undefined;
}
}
return obj;
}
/** Normalize a value into an array. Non-arrays become empty arrays. */
export function normalizeArray(value: unknown): unknown[] {
return Array.isArray(value) ? value : [];
}
/**
* Normalize a grid value into `{ rowKey: { colKey: cellValue } }`.
*
* Handles two input formats:
* - Object format: `{ jan: { leaf: true, fruit: true } }` (canonical)
* - Array format: `{ jan: ["leaf", "fruit"] }` (legacy, e.g. harvest calendar)
*
* Missing rows are initialized as empty objects.
*/
export function normalizeGrid(
value: unknown,
rows: GridAxisDef[],
columns: GridAxisDef[],
): Record<string, Record<string, unknown>> {
const out: Record<string, Record<string, unknown>> = {};
for (const row of rows) {
out[row.key] = {};
}
if (!value || typeof value !== "object" || Array.isArray(value)) {
return out;
}
const source = value as Record<string, unknown>;
for (const row of rows) {
const rowVal = source[row.key];
const rowOut = out[row.key]!;
if (Array.isArray(rowVal)) {
// Legacy array format: convert ["leaf", "fruit"] → { leaf: true, fruit: true }
for (const code of rowVal) {
if (typeof code === "string") {
rowOut[code] = true;
}
}
} else if (rowVal && typeof rowVal === "object") {
// Object format: preserve all stored keys, then layer declared columns
// over them. Unknown keys survive so cells added to the schema later
// or managed outside this widget aren't silently dropped on save.
const rowObj = rowVal as Record<string, unknown>;
Object.assign(rowOut, rowObj);
for (const col of columns) {
if (rowObj[col.key] !== undefined) {
rowOut[col.key] = rowObj[col.key];
}
}
}
}
return out;
}
/** Normalize a value into a string array. Filters out non-strings. */
export function normalizeTags(value: unknown): string[] {
if (!Array.isArray(value)) return [];
return value.filter((item): item is string => typeof item === "string");
}
const MUSTACHE_PATTERN = /\{\{(\w+)\}\}/g;
/**
* Render a simple mustache-style summary template.
* Replaces `{{key}}` with the corresponding value from `item`.
* Non-scalar values render as empty to avoid `[object Object]` leaking into UI.
*/
export function renderSummary(template: string, item: Record<string, unknown>): string {
return template.replace(MUSTACHE_PATTERN, (_match, key: string) => {
const val = item[key];
if (val === undefined || val === null) return "";
if (typeof val === "string") return val;
if (typeof val === "number" || typeof val === "boolean") return String(val);
return "";
});
}

View File

@@ -0,0 +1,250 @@
import { Checkbox, Input, Select } from "@cloudflare/kumo";
import * as React from "react";
import type { FieldWidgetProps, GridAxisDef } from "../shared/types";
import { normalizeGrid } from "../shared/utils";
type CellType = "toggle" | "text" | "number" | "select";
interface SelectOption {
label: string;
value: string;
}
/**
* Grid widget — a two-dimensional matrix of rows × columns with configurable
* cell types. Stores as a nested JSON object.
*
* Seed usage:
* {
* "slug": "availability",
* "type": "json",
* "widget": "field-kit:grid",
* "options": {
* "rows": [
* { "key": "mon", "label": "Monday" },
* { "key": "tue", "label": "Tuesday" }
* ],
* "columns": [
* { "key": "morning", "label": "Morning" },
* { "key": "afternoon", "label": "Afternoon" }
* ],
* "cell": "toggle"
* }
* }
*
* Stored value: { "mon": { "morning": true, "afternoon": false }, ... }
*/
export function Grid({ value, onChange, label, required, options, minimal }: FieldWidgetProps) {
const rows = (options?.rows as GridAxisDef[] | undefined) ?? [];
const columns = (options?.columns as GridAxisDef[] | undefined) ?? [];
const cellType = ((options?.cell as string | undefined) ?? "toggle") as CellType;
const cellOptions = (options?.cellOptions as SelectOption[] | string[] | undefined) ?? [];
const helpText = options?.helpText as string | undefined;
const data = normalizeGrid(value, rows, columns);
const dataRef = React.useRef(data);
dataRef.current = data;
const normalizedCellOptions: SelectOption[] = React.useMemo(
() => cellOptions.map((opt) => (typeof opt === "string" ? { label: opt, value: opt } : opt)),
[cellOptions],
);
const updateCell = React.useCallback(
(rowKey: string, colKey: string, cellValue: unknown) => {
const rowData = { ...dataRef.current[rowKey], [colKey]: cellValue };
onChange({ ...dataRef.current, [rowKey]: rowData });
},
[onChange],
);
const toggleCell = React.useCallback(
(rowKey: string, colKey: string, next: boolean) => {
const rowData = { ...dataRef.current[rowKey], [colKey]: next };
onChange({ ...dataRef.current, [rowKey]: rowData });
},
[onChange],
);
if (rows.length === 0 || columns.length === 0) {
return (
<div>
{!minimal && (
<label className="mb-1.5 block text-sm font-medium text-kumo-default">
{label}
{required && <span className="ml-0.5 text-kumo-danger">*</span>}
</label>
)}
<div className="rounded-md bg-kumo-danger-tint/60 p-3 text-sm text-kumo-danger">
<p className="font-medium">Widget misconfigured</p>
<p className="mt-1 opacity-80">
The field's <code>options.rows</code> and <code>options.columns</code> arrays are
required. Define them in your seed file to use this widget.
</p>
</div>
</div>
);
}
return (
<div>
{!minimal && (
<label className="mb-1.5 block text-sm font-medium text-kumo-default">
{label}
{required && <span className="ml-0.5 text-kumo-danger">*</span>}
</label>
)}
<div className="overflow-x-auto rounded-md ring ring-kumo-hairline">
<table className="w-full border-collapse text-sm">
<thead>
<tr className="border-b border-kumo-hairline bg-kumo-tint">
<th className="sticky left-0 z-10 bg-kumo-tint px-3 py-2 text-left font-medium text-kumo-default">
&nbsp;
</th>
{columns.map((col) => (
<th
key={col.key}
className="px-2 py-2 text-center font-medium text-kumo-default"
title={col.label}
>
<div className="flex flex-col items-center gap-1">
{col.image && (
<img
src={col.image}
alt={col.label}
width="24"
height="24"
className="rounded-sm"
/>
)}
<span className="text-xs leading-tight">{col.label}</span>
</div>
</th>
))}
</tr>
</thead>
<tbody>
{rows.map((row, rowIdx) => (
<tr
key={row.key}
className={
rowIdx % 2 === 0
? "border-t border-kumo-hairline"
: "border-t border-kumo-hairline bg-kumo-tint/40"
}
>
<td className="sticky left-0 z-10 whitespace-nowrap bg-inherit px-3 py-2 font-medium text-kumo-default">
<div className="flex items-center gap-1.5">
{row.image && (
<img
src={row.image}
alt={row.label}
width="20"
height="20"
className="rounded-sm"
/>
)}
{row.label}
</div>
</td>
{columns.map((col) => {
const cellValue = data[row.key]?.[col.key];
return (
<td key={col.key} className="px-2 py-2 text-center">
<CellInput
type={cellType}
value={cellValue}
options={normalizedCellOptions}
rowKey={row.key}
colKey={col.key}
onToggle={toggleCell}
onUpdate={updateCell}
ariaLabel={`${row.label}${col.label}`}
/>
</td>
);
})}
</tr>
))}
</tbody>
</table>
</div>
{helpText && <p className="mt-1.5 text-xs text-kumo-subtle">{helpText}</p>}
</div>
);
}
interface CellInputProps {
type: CellType;
value: unknown;
options: SelectOption[];
rowKey: string;
colKey: string;
onToggle: (rowKey: string, colKey: string, next: boolean) => void;
onUpdate: (rowKey: string, colKey: string, value: unknown) => void;
ariaLabel: string;
}
function CellInput({
type,
value,
options,
rowKey,
colKey,
onToggle,
onUpdate,
ariaLabel,
}: CellInputProps) {
switch (type) {
case "toggle":
return (
<div className="flex justify-center">
<Checkbox
aria-label={ariaLabel}
checked={!!value}
onCheckedChange={(next) => onToggle(rowKey, colKey, !!next)}
/>
</div>
);
case "text":
return (
<Input
size="sm"
aria-label={ariaLabel}
value={typeof value === "string" ? value : ""}
onChange={(e) => onUpdate(rowKey, colKey, e.target.value)}
/>
);
case "number":
return (
<Input
size="sm"
type="number"
aria-label={ariaLabel}
value={typeof value === "number" ? value : ""}
onChange={(e) =>
onUpdate(rowKey, colKey, e.target.value === "" ? undefined : Number(e.target.value))
}
/>
);
case "select":
return (
<Select
aria-label={ariaLabel}
value={typeof value === "string" ? value : ""}
placeholder="—"
onValueChange={(v) => onUpdate(rowKey, colKey, (v as string) === "" ? undefined : v)}
items={options}
/>
);
default:
return null;
}
}

View File

@@ -0,0 +1,309 @@
import { Button } from "@cloudflare/kumo";
import { CaretRight, CaretUp, CaretDown, Plus, X } from "@phosphor-icons/react";
import * as React from "react";
import { SubField } from "../shared/sub-field";
import type { FieldWidgetProps, SubFieldDef } from "../shared/types";
import { normalizeArray, normalizeObject, renderSummary } from "../shared/utils";
/**
* List widget — ordered array editor with add/remove/reorder for json fields.
*
* Seed usage:
* {
* "slug": "ingredients",
* "type": "json",
* "widget": "field-kit:list",
* "options": {
* "itemLabel": "Ingredient",
* "min": 1,
* "max": 50,
* "sortable": true,
* "summary": "{{name}} — {{amount}}",
* "fields": [
* { "key": "name", "label": "Name", "type": "text" },
* { "key": "amount", "label": "Amount", "type": "text" },
* { "key": "optional", "label": "Optional", "type": "boolean" }
* ]
* }
* }
*
* Stored value: [{ "name": "Flour", "amount": "500g", "optional": false }, ...]
*/
function makeItemId(): string {
// `crypto.randomUUID` is available in all modern browsers and in Node ≥ 14.17.
// This id is a React-key concern only and never persisted to the stored JSON.
if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
return crypto.randomUUID();
}
return `item-${Math.random().toString(36).slice(2)}-${Date.now()}`;
}
export function List({ value, onChange, label, id, required, options, minimal }: FieldWidgetProps) {
const fields = (options?.fields as SubFieldDef[] | undefined) ?? [];
const itemLabel = (options?.itemLabel as string | undefined) ?? "Item";
const min = options?.min as number | undefined;
const max = options?.max as number | undefined;
const sortable = (options?.sortable as boolean | undefined) ?? true;
const summaryTemplate = options?.summary as string | undefined;
const helpText = options?.helpText as string | undefined;
const items = normalizeArray(value).map((item) => normalizeObject(item, fields));
const itemsRef = React.useRef(items);
itemsRef.current = items;
// Parallel stable-key array, kept in lockstep with `items`. Regenerated
// only when the externally-supplied value length changes unexpectedly
// (e.g. reset from outside); local add/remove/reorder splice this array
// alongside items so each row keeps the same key across moves.
const [itemIds, setItemIds] = React.useState<string[]>(() => items.map(() => makeItemId()));
const itemIdsRef = React.useRef(itemIds);
itemIdsRef.current = itemIds;
React.useEffect(() => {
if (itemIdsRef.current.length !== items.length) {
setItemIds((prev) => {
if (prev.length === items.length) return prev;
if (prev.length < items.length) {
const added: string[] = [];
for (let i = prev.length; i < items.length; i++) added.push(makeItemId());
return [...prev, ...added];
}
return prev.slice(0, items.length);
});
}
}, [items.length]);
const [expandedIndex, setExpandedIndex] = React.useState<number | null>(
items.length === 0 ? null : 0,
);
const expandedRef = React.useRef(expandedIndex);
expandedRef.current = expandedIndex;
const canAdd = max === undefined || items.length < max;
const canRemove = min === undefined || items.length > min;
const updateItems = React.useCallback(
(next: Record<string, unknown>[]) => {
onChange(next);
},
[onChange],
);
const addItem = React.useCallback(() => {
const newItem = normalizeObject(undefined, fields);
const next = [...itemsRef.current, newItem];
setItemIds([...itemIdsRef.current, makeItemId()]);
updateItems(next);
setExpandedIndex(next.length - 1);
}, [fields, updateItems]);
const removeItem = React.useCallback(
(index: number) => {
const next = [...itemsRef.current];
next.splice(index, 1);
const nextIds = [...itemIdsRef.current];
nextIds.splice(index, 1);
setItemIds(nextIds);
updateItems(next);
const exp = expandedRef.current;
if (exp === index) {
setExpandedIndex(null);
} else if (exp !== null && exp > index) {
setExpandedIndex(exp - 1);
}
},
[updateItems],
);
const moveItem = React.useCallback(
(index: number, direction: -1 | 1) => {
const target = index + direction;
if (target < 0 || target >= itemsRef.current.length) return;
const next = [...itemsRef.current];
const a = next[index];
const b = next[target];
if (!a || !b) return;
next[index] = b;
next[target] = a;
const nextIds = [...itemIdsRef.current];
const idA = nextIds[index];
const idB = nextIds[target];
if (idA !== undefined && idB !== undefined) {
nextIds[index] = idB;
nextIds[target] = idA;
setItemIds(nextIds);
}
updateItems(next);
const exp = expandedRef.current;
if (exp === index) {
setExpandedIndex(target);
} else if (exp === target) {
setExpandedIndex(index);
}
},
[updateItems],
);
const updateField = React.useCallback(
(itemIndex: number, key: string, fieldValue: unknown) => {
const next = itemsRef.current.map((item, i) =>
i === itemIndex ? { ...item, [key]: fieldValue } : item,
);
updateItems(next);
},
[updateItems],
);
const getSummary = React.useCallback(
(item: Record<string, unknown>, index: number): string => {
if (summaryTemplate) {
const rendered = renderSummary(summaryTemplate, item).trim();
if (rendered) return rendered;
}
return `${itemLabel} ${index + 1}`;
},
[summaryTemplate, itemLabel],
);
if (fields.length === 0) {
return (
<div>
{!minimal && (
<label className="mb-1.5 block text-sm font-medium text-kumo-default">
{label}
{required && <span className="ml-0.5 text-kumo-danger">*</span>}
</label>
)}
<div className="rounded-md bg-kumo-danger-tint/60 p-3 text-sm text-kumo-danger">
<p className="font-medium">Widget misconfigured</p>
<p className="mt-1 opacity-80">
The field's <code>options.fields</code> array is empty or missing. Define sub-fields in
your seed file to use this widget.
</p>
</div>
</div>
);
}
return (
<div>
{!minimal && (
<label className="mb-1.5 block text-sm font-medium text-kumo-default">
{label}
{required && <span className="ml-0.5 text-kumo-danger">*</span>}
<span className="ml-1.5 text-xs font-normal text-kumo-subtle">
({items.length}
{max !== undefined ? `/${max}` : ""})
</span>
</label>
)}
<div className="rounded-md ring ring-kumo-hairline">
{items.length === 0 && (
<div className="p-3 text-center text-sm text-kumo-subtle">No items yet</div>
)}
{items.map((item, index) => {
const isExpanded = expandedIndex === index;
const rowKey = itemIds[index] ?? `fallback-${index}`;
return (
<div
key={rowKey}
className={`border-b border-kumo-hairline last:border-b-0 ${
isExpanded ? "bg-kumo-tint" : ""
}`}
>
<div className="flex items-center gap-1 px-2 py-1.5">
<Button
variant="ghost"
size="sm"
className="flex-1 justify-start"
onClick={() => setExpandedIndex(isExpanded ? null : index)}
icon={
<CaretRight
style={{
transform: isExpanded ? "rotate(90deg)" : undefined,
transition: "transform 150ms ease",
}}
/>
}
>
<span className={isExpanded ? "font-medium" : ""}>{getSummary(item, index)}</span>
</Button>
{sortable && (
<>
<Button
variant="ghost"
shape="square"
size="sm"
disabled={index === 0}
onClick={(e) => {
e.stopPropagation();
moveItem(index, -1);
}}
aria-label="Move up"
title="Move up"
icon={<CaretUp />}
/>
<Button
variant="ghost"
shape="square"
size="sm"
disabled={index === items.length - 1}
onClick={(e) => {
e.stopPropagation();
moveItem(index, 1);
}}
aria-label="Move down"
title="Move down"
icon={<CaretDown />}
/>
</>
)}
{canRemove && (
<Button
variant="ghost"
shape="square"
size="sm"
onClick={(e) => {
e.stopPropagation();
removeItem(index);
}}
aria-label={`Remove ${itemLabel} ${index + 1}`}
title="Remove"
icon={<X />}
/>
)}
</div>
{isExpanded && (
<div className="space-y-3 border-t border-kumo-hairline px-3 pb-3 pt-2">
{fields.map((field) => (
<SubField
key={field.key}
id={`${id}-${rowKey}-${field.key}`}
def={field}
value={item[field.key]}
onChange={(v) => updateField(index, field.key, v)}
/>
))}
</div>
)}
</div>
);
})}
</div>
{canAdd && (
<Button variant="outline" size="sm" className="mt-2" onClick={addItem} icon={<Plus />}>
Add {itemLabel}
</Button>
)}
{helpText && <p className="mt-1.5 text-xs text-kumo-subtle">{helpText}</p>}
</div>
);
}

View File

@@ -0,0 +1,113 @@
import { Button } from "@cloudflare/kumo";
import { CaretRight } from "@phosphor-icons/react";
import * as React from "react";
import { SubField } from "../shared/sub-field";
import type { FieldWidgetProps, SubFieldDef } from "../shared/types";
import { normalizeObject } from "../shared/utils";
/**
* Object form widget — renders a group of typed sub-fields that store as a
* single JSON object.
*
* Seed usage:
* {
* "slug": "nutrition",
* "type": "json",
* "widget": "field-kit:object-form",
* "options": {
* "fields": [
* { "key": "calories", "label": "Calories", "type": "number", "suffix": "kcal" },
* { "key": "protein", "label": "Protein", "type": "number", "suffix": "g" }
* ]
* }
* }
*
* Stored value: { "calories": 250, "protein": 12.5 }
*/
export function ObjectForm({
value,
onChange,
label,
id,
required,
options,
minimal,
}: FieldWidgetProps) {
const fields = (options?.fields as SubFieldDef[] | undefined) ?? [];
const collapsed = options?.collapsed as boolean | undefined;
const helpText = options?.helpText as string | undefined;
const [isOpen, setIsOpen] = React.useState(!collapsed);
const data = normalizeObject(value, fields);
const dataRef = React.useRef(data);
dataRef.current = data;
const handleFieldChange = React.useCallback(
(key: string, fieldValue: unknown) => {
onChange({ ...dataRef.current, [key]: fieldValue });
},
[onChange],
);
if (fields.length === 0) {
return (
<div>
{!minimal && (
<label className="mb-1.5 block text-sm font-medium text-kumo-default">
{label}
{required && <span className="ml-0.5 text-kumo-danger">*</span>}
</label>
)}
<div className="rounded-md bg-kumo-danger-tint/60 p-3 text-sm text-kumo-danger">
<p className="font-medium">Widget misconfigured</p>
<p className="mt-1 opacity-80">
The field's <code>options.fields</code> array is empty or missing. Define sub-fields in
your seed file to use this widget.
</p>
</div>
</div>
);
}
return (
<div>
{!minimal && (
<Button
variant="ghost"
size="sm"
className="mb-2 !px-1 font-medium"
onClick={() => setIsOpen((o) => !o)}
icon={
<CaretRight
style={{
transform: isOpen ? "rotate(90deg)" : undefined,
transition: "transform 150ms ease",
}}
/>
}
>
{label}
{required && <span className="ml-0.5 text-kumo-danger">*</span>}
</Button>
)}
{isOpen && (
<div className="space-y-3 rounded-md p-3 ring ring-kumo-hairline">
{fields.map((field) => (
<SubField
key={field.key}
id={`${id}-${field.key}`}
def={field}
value={data[field.key]}
onChange={(v) => handleFieldChange(field.key, v)}
/>
))}
</div>
)}
{helpText && <p className="mt-1.5 text-xs text-kumo-subtle">{helpText}</p>}
</div>
);
}

View File

@@ -0,0 +1,158 @@
import { Badge, Button } from "@cloudflare/kumo";
import { X } from "@phosphor-icons/react";
import * as React from "react";
import type { FieldWidgetProps } from "../shared/types";
import { normalizeTags } from "../shared/utils";
/**
* Tags widget — free-form chip/tag input for json fields that store string arrays.
*
* Seed usage:
* {
* "slug": "keywords",
* "type": "json",
* "widget": "field-kit:tags",
* "options": {
* "placeholder": "Add keyword...",
* "max": 10,
* "suggestions": ["organic", "seasonal", "dried"],
* "allowCustom": true,
* "transform": "lowercase"
* }
* }
*
* Stored value: ["organic", "seasonal"]
*/
export function Tags({ value, onChange, label, id, required, options, minimal }: FieldWidgetProps) {
const placeholder = (options?.placeholder as string | undefined) ?? "Add...";
const max = options?.max as number | undefined;
const suggestions = (options?.suggestions as string[] | undefined) ?? [];
const allowCustom = (options?.allowCustom as boolean | undefined) ?? true;
const transform = (options?.transform as string | undefined) ?? "none";
const helpText = options?.helpText as string | undefined;
const tags = normalizeTags(value);
const tagsRef = React.useRef(tags);
tagsRef.current = tags;
const [input, setInput] = React.useState("");
const datalistId = `${id}-suggestions`;
const atLimit = max !== undefined && tags.length >= max;
const applyTransform = React.useCallback(
(val: string): string => {
const trimmed = val.trim();
switch (transform) {
case "lowercase":
return trimmed.toLowerCase();
case "uppercase":
return trimmed.toUpperCase();
case "trim":
return trimmed;
default:
return trimmed;
}
},
[transform],
);
const addTag = React.useCallback(
(raw: string) => {
const tag = applyTransform(raw);
if (!tag) return;
if (tagsRef.current.includes(tag)) return;
if (!allowCustom && !suggestions.includes(tag)) return;
if (max !== undefined && tagsRef.current.length >= max) return;
onChange([...tagsRef.current, tag]);
setInput("");
},
[onChange, applyTransform, allowCustom, suggestions, max],
);
const removeTag = React.useCallback(
(index: number) => {
const next = [...tagsRef.current];
next.splice(index, 1);
onChange(next);
},
[onChange],
);
const handleKeyDown = React.useCallback(
(e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === "Enter" || e.key === ",") {
e.preventDefault();
addTag(input);
}
if (e.key === "Backspace" && input === "" && tagsRef.current.length > 0) {
removeTag(tagsRef.current.length - 1);
}
},
[input, addTag, removeTag],
);
return (
<div>
{!minimal && (
<label htmlFor={id} className="mb-1.5 block text-sm font-medium text-kumo-default">
{label}
{required && <span className="ml-0.5 text-kumo-danger">*</span>}
</label>
)}
<div className="flex min-h-9 flex-wrap items-center gap-1.5 rounded-md bg-kumo-base p-1.5 ring ring-kumo-hairline focus-within:ring-kumo-hairline">
{tags.map((tag, i) => (
<span key={`${tag}-${i}`} className="inline-flex items-center gap-1">
<Badge variant="secondary">
<span className="mr-1">{tag}</span>
<Button
variant="ghost"
shape="circle"
size="xs"
aria-label={`Remove ${tag}`}
onClick={() => removeTag(i)}
icon={<X />}
/>
</Badge>
</span>
))}
{!atLimit && (
<input
id={id}
type="text"
aria-label={label}
className="min-w-32 flex-1 border-none bg-transparent p-1 text-sm text-kumo-default outline-none placeholder:text-kumo-subtle"
value={input}
placeholder={tags.length === 0 ? placeholder : ""}
list={suggestions.length > 0 ? datalistId : undefined}
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyDown}
onBlur={() => {
if (input.trim()) addTag(input);
}}
/>
)}
</div>
{suggestions.length > 0 && (
<datalist id={datalistId}>
{suggestions
.filter((s) => !tags.includes(s))
.map((s) => (
<option key={s} value={s} />
))}
</datalist>
)}
{helpText && <p className="mt-1.5 text-xs text-kumo-subtle">{helpText}</p>}
{max !== undefined && (
<p className="mt-1 text-xs text-kumo-subtle">
{tags.length}/{max}
</p>
)}
</div>
);
}

View File

@@ -0,0 +1,167 @@
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import * as React from "react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { Grid } from "../src/widgets/grid";
vi.mock("@cloudflare/kumo", () => ({
Checkbox: ({ checked, onCheckedChange, "aria-label": ariaLabel }: any) => (
<input
type="checkbox"
aria-label={ariaLabel}
checked={!!checked}
onChange={(e) => onCheckedChange?.(e.target.checked)}
/>
),
Input: ({ value, onChange, "aria-label": ariaLabel, type }: any) => (
<input type={type ?? "text"} aria-label={ariaLabel} value={value ?? ""} onChange={onChange} />
),
Select: ({ value, onValueChange, items, "aria-label": ariaLabel }: any) => (
<select
aria-label={ariaLabel}
value={value ?? ""}
onChange={(e) => onValueChange?.(e.target.value)}
>
<option value=""></option>
{(items ?? []).map((opt: any) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
),
}));
afterEach(() => cleanup());
const rows = [
{ key: "mon", label: "Mon" },
{ key: "tue", label: "Tue" },
];
const columns = [
{ key: "am", label: "AM" },
{ key: "pm", label: "PM" },
];
describe("Grid widget", () => {
it("renders all cells as toggle checkboxes by default", () => {
render(<Grid value={{}} onChange={() => {}} label="Grid" id="g" options={{ rows, columns }} />);
const boxes = screen.getAllByRole("checkbox");
expect(boxes).toHaveLength(4); // 2 rows × 2 cols
});
it("reflects existing toggle values", () => {
render(
<Grid
value={{ mon: { am: true, pm: false }, tue: { am: true } }}
onChange={() => {}}
label="Grid"
id="g"
options={{ rows, columns }}
/>,
);
expect((screen.getByLabelText("Mon — AM") as HTMLInputElement).checked).toBe(true);
expect((screen.getByLabelText("Mon — PM") as HTMLInputElement).checked).toBe(false);
expect((screen.getByLabelText("Tue — AM") as HTMLInputElement).checked).toBe(true);
});
it("normalizes legacy array format on read", () => {
render(
<Grid
value={{ mon: ["am", "pm"], tue: ["am"] }}
onChange={() => {}}
label="Grid"
id="g"
options={{ rows, columns }}
/>,
);
expect((screen.getByLabelText("Mon — AM") as HTMLInputElement).checked).toBe(true);
expect((screen.getByLabelText("Mon — PM") as HTMLInputElement).checked).toBe(true);
expect((screen.getByLabelText("Tue — AM") as HTMLInputElement).checked).toBe(true);
expect((screen.getByLabelText("Tue — PM") as HTMLInputElement).checked).toBe(false);
});
it("emits object-shape on toggle write (even when input was array format)", () => {
const onChange = vi.fn();
render(
<Grid
value={{ mon: ["am"] }}
onChange={onChange}
label="Grid"
id="g"
options={{ rows, columns }}
/>,
);
fireEvent.click(screen.getByLabelText("Mon — PM"));
expect(onChange).toHaveBeenCalledWith({
mon: { am: true, pm: true },
tue: {},
});
});
it("renders text cells when cell is 'text'", () => {
render(
<Grid
value={{}}
onChange={() => {}}
label="Grid"
id="g"
options={{ rows, columns, cell: "text" }}
/>,
);
expect(screen.getAllByRole("textbox")).toHaveLength(4);
});
it("renders select cells with cellOptions", () => {
render(
<Grid
value={{}}
onChange={() => {}}
label="Grid"
id="g"
options={{
rows,
columns,
cell: "select",
cellOptions: [
{ label: "A", value: "a" },
{ label: "B", value: "b" },
],
}}
/>,
);
const selects = screen.getAllByRole("combobox");
expect(selects).toHaveLength(4);
});
it("preserves unknown cell keys on write so evolving schemas don't drop data", () => {
const onChange = vi.fn();
render(
<Grid
value={{ mon: { am: true, legacy: "keep-me" } }}
onChange={onChange}
label="Grid"
id="g"
options={{ rows, columns }}
/>,
);
fireEvent.click(screen.getByLabelText("Mon — PM"));
expect(onChange).toHaveBeenCalledWith({
mon: { am: true, pm: true, legacy: "keep-me" },
tue: {},
});
});
it("shows misconfigured warning when rows or columns are missing", () => {
render(
<Grid
value={{}}
onChange={() => {}}
label="Grid"
id="g"
options={{ rows: [], columns: [] }}
/>,
);
expect(screen.queryByText(/Widget misconfigured/i)).not.toBeNull();
});
});

View File

@@ -0,0 +1,215 @@
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import * as React from "react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { List } from "../src/widgets/list";
vi.mock("@cloudflare/kumo", () => ({
Button: ({ children, onClick, icon, "aria-label": ariaLabel, disabled }: any) => (
<button type="button" onClick={onClick} aria-label={ariaLabel} disabled={disabled}>
{icon}
{children}
</button>
),
Input: ({ label, value, onChange, type, id }: any) => (
<label>
{label}
<input id={id} type={type ?? "text"} value={value ?? ""} onChange={onChange} />
</label>
),
InputArea: ({ label, value, onChange, id }: any) => (
<label>
{label}
<textarea id={id} value={value ?? ""} onChange={onChange} />
</label>
),
Select: ({ label, value, onValueChange, items }: any) => (
<label>
{label}
<select value={value ?? ""} onChange={(e) => onValueChange?.(e.target.value)}>
<option value=""></option>
{(items ?? []).map((opt: any) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</label>
),
Switch: ({ label, checked, onCheckedChange, id }: any) => (
<label>
{label}
<input
id={id}
role="switch"
type="checkbox"
checked={!!checked}
onChange={(e) => onCheckedChange?.(e.target.checked)}
/>
</label>
),
}));
vi.mock("@phosphor-icons/react", () => ({
CaretRight: () => <span></span>,
CaretUp: () => <span></span>,
CaretDown: () => <span></span>,
Plus: () => <span>+</span>,
X: () => <span>×</span>,
}));
afterEach(() => cleanup());
const fields = [
{ key: "name", label: "Name", type: "text" as const },
{ key: "amount", label: "Amount", type: "text" as const },
];
describe("List widget", () => {
it("renders each item as a summary row using the summary template", () => {
render(
<List
value={[
{ name: "Flour", amount: "500g" },
{ name: "Sugar", amount: "100g" },
]}
onChange={() => {}}
label="Ingredients"
id="ing"
options={{ fields, summary: "{{name}} — {{amount}}" }}
/>,
);
expect(screen.getByRole("button", { name: /Flour — 500g/ })).not.toBeNull();
expect(screen.getByRole("button", { name: /Sugar — 100g/ })).not.toBeNull();
});
it("falls back to itemLabel + index when no summary template", () => {
render(
<List
value={[{ name: "a" }, { name: "b" }]}
onChange={() => {}}
label="Items"
id="x"
options={{ fields, itemLabel: "Thing" }}
/>,
);
// Summary buttons for both rows use itemLabel + index
const summaryButtons = screen
.getAllByRole("button")
.filter((b) => /Thing \d$/.test(b.textContent ?? ""));
expect(summaryButtons.map((b) => b.textContent?.trim())).toEqual(
expect.arrayContaining([
expect.stringMatching(/Thing 1$/),
expect.stringMatching(/Thing 2$/),
]),
);
});
it("adds a new empty item when Add is clicked", () => {
const onChange = vi.fn();
render(<List value={[]} onChange={onChange} label="Items" id="x" options={{ fields }} />);
fireEvent.click(screen.getByRole("button", { name: /Add Item/ }));
expect(onChange).toHaveBeenCalledWith([{ name: undefined, amount: undefined }]);
});
it("removes an item", () => {
const onChange = vi.fn();
render(
<List
value={[{ name: "a" }, { name: "b" }]}
onChange={onChange}
label="Items"
id="x"
options={{ fields }}
/>,
);
const [, removeB] = screen.getAllByRole("button", {
name: /Remove Item/,
});
fireEvent.click(removeB!);
expect(onChange).toHaveBeenCalledWith([{ name: "a", amount: undefined }]);
});
it("reorders items with move down", () => {
const onChange = vi.fn();
render(
<List
value={[{ name: "a" }, { name: "b" }]}
onChange={onChange}
label="Items"
id="x"
options={{ fields }}
/>,
);
const downButtons = screen.getAllByLabelText("Move down");
fireEvent.click(downButtons[0]!);
expect(onChange).toHaveBeenCalledWith([
{ name: "b", amount: undefined },
{ name: "a", amount: undefined },
]);
});
it("respects max: add button disappears at limit", () => {
render(
<List
value={[{ name: "a" }, { name: "b" }]}
onChange={() => {}}
label="Items"
id="x"
options={{ fields, max: 2 }}
/>,
);
expect(screen.queryByRole("button", { name: /Add/i })).toBeNull();
});
it("respects min: remove buttons disappear at limit", () => {
render(
<List
value={[{ name: "a" }]}
onChange={() => {}}
label="Items"
id="x"
options={{ fields, min: 1 }}
/>,
);
expect(screen.queryAllByRole("button", { name: /Remove Item/ })).toHaveLength(0);
});
it("shows empty-state message when no items", () => {
render(<List value={[]} onChange={() => {}} label="Items" id="x" options={{ fields }} />);
expect(screen.queryByText(/No items yet/i)).not.toBeNull();
});
it("shows misconfigured warning when fields is empty", () => {
render(<List value={[]} onChange={() => {}} label="Items" id="x" options={{ fields: [] }} />);
expect(screen.queryByText(/Widget misconfigured/i)).not.toBeNull();
});
it("scopes sub-field ids under the parent field id for each expanded item", () => {
const { container } = render(
<List
value={[{ name: "a" }, { name: "b" }]}
onChange={() => {}}
label="Ingredients"
id="ing"
options={{ fields }}
/>,
);
// Default: first item expanded → sub-field id scoped to parent "ing"
let nameInputs = container.querySelectorAll('input[id*="-name"]');
expect(nameInputs.length).toBe(1);
const firstId = (nameInputs[0] as HTMLInputElement).id;
expect(firstId.startsWith("ing-")).toBe(true);
expect(firstId.endsWith("-name")).toBe(true);
// Collapse first, expand second → distinct id because stable key differs
fireEvent.click(screen.getByRole("button", { name: /^▸ Item 1$/ }));
fireEvent.click(screen.getByRole("button", { name: /^▸ Item 2$/ }));
nameInputs = container.querySelectorAll('input[id*="-name"]');
expect(nameInputs.length).toBe(1);
const secondId = (nameInputs[0] as HTMLInputElement).id;
expect(secondId.startsWith("ing-")).toBe(true);
expect(secondId.endsWith("-name")).toBe(true);
expect(secondId).not.toBe(firstId);
});
});

View File

@@ -0,0 +1,183 @@
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import * as React from "react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { ObjectForm } from "../src/widgets/object-form";
vi.mock("@cloudflare/kumo", () => ({
Button: ({ children, onClick, icon, "aria-label": ariaLabel, disabled }: any) => (
<button type="button" onClick={onClick} aria-label={ariaLabel} disabled={disabled}>
{icon}
{children}
</button>
),
Input: ({ label, value, onChange, type, id, required }: any) => (
<label>
{typeof label === "string" ? label : label}
<input
id={id}
type={type ?? "text"}
value={value ?? ""}
required={required}
onChange={onChange}
/>
</label>
),
InputArea: ({ label, value, onChange, id, required }: any) => (
<label>
{typeof label === "string" ? label : label}
<textarea id={id} value={value ?? ""} required={required} onChange={onChange} />
</label>
),
Select: ({ label, value, onValueChange, items, required }: any) => (
<label>
{typeof label === "string" ? label : label}
<select
value={value ?? ""}
required={required}
onChange={(e) => onValueChange?.(e.target.value)}
>
<option value=""></option>
{(items ?? []).map((opt: any) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</label>
),
Switch: ({ label, checked, onCheckedChange, id }: any) => (
<label>
{label}
<input
id={id}
type="checkbox"
role="switch"
checked={!!checked}
onChange={(e) => onCheckedChange?.(e.target.checked)}
/>
</label>
),
}));
vi.mock("@phosphor-icons/react", () => ({
CaretRight: () => <span></span>,
}));
afterEach(() => cleanup());
describe("ObjectForm widget", () => {
it("renders sub-fields from options.fields", () => {
render(
<ObjectForm
value={{}}
onChange={() => {}}
label="Nutrition"
id="nut"
options={{
fields: [
{ key: "name", label: "Name", type: "text" },
{ key: "count", label: "Count", type: "number" },
],
}}
/>,
);
expect(screen.getByText("Name")).not.toBeNull();
expect(screen.getByText("Count")).not.toBeNull();
});
it("populates sub-field values from the stored object", () => {
render(
<ObjectForm
value={{ name: "flour", count: 3 }}
onChange={() => {}}
label="Nutrition"
id="nut"
options={{
fields: [
{ key: "name", label: "Name", type: "text" },
{ key: "count", label: "Count", type: "number" },
],
}}
/>,
);
expect(screen.getByDisplayValue("flour")).not.toBeNull();
expect(screen.getByDisplayValue("3")).not.toBeNull();
});
it("emits the full object on field change", () => {
const onChange = vi.fn();
render(
<ObjectForm
value={{ name: "flour", count: 3 }}
onChange={onChange}
label="Nutrition"
id="nut"
options={{
fields: [
{ key: "name", label: "Name", type: "text" },
{ key: "count", label: "Count", type: "number" },
],
}}
/>,
);
fireEvent.change(screen.getByDisplayValue("flour"), {
target: { value: "sugar" },
});
expect(onChange).toHaveBeenCalledWith({ name: "sugar", count: 3 });
});
it("shows misconfigured warning when fields is empty", () => {
render(
<ObjectForm
value={{}}
onChange={() => {}}
label="Empty"
id="empty"
options={{ fields: [] }}
/>,
);
expect(screen.getByText(/Widget misconfigured/i)).not.toBeNull();
});
it("preserves unknown keys not defined in options.fields", () => {
const onChange = vi.fn();
render(
<ObjectForm
value={{ name: "a", stray: "unexpected" }}
onChange={onChange}
label="Form"
id="f"
options={{
fields: [{ key: "name", label: "Name", type: "text" }],
}}
/>,
);
fireEvent.change(screen.getByDisplayValue("a"), {
target: { value: "b" },
});
// onChange should pass along keys not managed by this widget so stored
// JSON round-trips cleanly when the schema evolves.
const payload = onChange.mock.calls[0]?.[0] as Record<string, unknown>;
expect(payload).toEqual({ name: "b", stray: "unexpected" });
});
it("gives each sub-field a unique DOM id composed from the parent id", () => {
const { container } = render(
<ObjectForm
value={{}}
onChange={() => {}}
label="Form"
id="nutrition"
options={{
fields: [
{ key: "calories", label: "Calories", type: "number" },
{ key: "protein", label: "Protein", type: "number" },
],
}}
/>,
);
expect(container.querySelector("#nutrition-calories")).not.toBeNull();
expect(container.querySelector("#nutrition-protein")).not.toBeNull();
});
});

View File

@@ -0,0 +1,131 @@
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import * as React from "react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { Tags } from "../src/widgets/tags";
// ── Kumo mocks ──────────────────────────────────────────────────────────────
vi.mock("@cloudflare/kumo", () => ({
Badge: ({ children }: any) => <span data-testid="badge">{children}</span>,
Button: ({ children, onClick, icon, "aria-label": ariaLabel }: any) => (
<button type="button" onClick={onClick} aria-label={ariaLabel}>
{icon}
{children}
</button>
),
}));
vi.mock("@phosphor-icons/react", () => ({
X: () => <span>×</span>,
}));
afterEach(() => cleanup());
// An <input> with a `list` attribute has role="combobox"; without it, "textbox".
// Both are the same HTML element in our widget; query by id for consistency.
function findInput(id = "tags"): HTMLInputElement | null {
return document.querySelector(`input#${id}`);
}
describe("Tags widget", () => {
it("renders existing tags as chips", () => {
render(<Tags value={["a", "b", "c"]} onChange={() => {}} label="Tags" id="tags" />);
const badges = screen.getAllByTestId("badge");
// each badge renders tag + mocked remove icon; check the tag text is present
expect(badges).toHaveLength(3);
expect(badges[0]!.textContent).toContain("a");
expect(badges[1]!.textContent).toContain("b");
expect(badges[2]!.textContent).toContain("c");
});
it("adds a tag on Enter", () => {
const onChange = vi.fn();
render(<Tags value={[]} onChange={onChange} label="Tags" id="tags" />);
const input = findInput()!;
fireEvent.change(input, { target: { value: "new-tag" } });
fireEvent.keyDown(input, { key: "Enter" });
expect(onChange).toHaveBeenCalledWith(["new-tag"]);
});
it("removes a tag when its remove button is clicked", () => {
const onChange = vi.fn();
render(<Tags value={["keep", "drop"]} onChange={onChange} label="Tags" id="tags" />);
const removeButton = screen.getByLabelText("Remove drop");
fireEvent.click(removeButton);
expect(onChange).toHaveBeenCalledWith(["keep"]);
});
it("deduplicates tags", () => {
const onChange = vi.fn();
render(<Tags value={["a"]} onChange={onChange} label="Tags" id="tags" />);
const input = findInput()!;
fireEvent.change(input, { target: { value: "a" } });
fireEvent.keyDown(input, { key: "Enter" });
expect(onChange).not.toHaveBeenCalled();
});
it("enforces max", () => {
const onChange = vi.fn();
render(
<Tags value={["a", "b"]} onChange={onChange} label="Tags" id="tags" options={{ max: 2 }} />,
);
// input is hidden when at limit
expect(findInput()).toBeNull();
});
it("applies lowercase transform", () => {
const onChange = vi.fn();
render(
<Tags
value={[]}
onChange={onChange}
label="Tags"
id="tags"
options={{ transform: "lowercase" }}
/>,
);
const input = findInput()!;
fireEvent.change(input, { target: { value: "FooBar" } });
fireEvent.keyDown(input, { key: "Enter" });
expect(onChange).toHaveBeenCalledWith(["foobar"]);
});
it("rejects non-suggestion when allowCustom is false", () => {
const onChange = vi.fn();
render(
<Tags
value={[]}
onChange={onChange}
label="Tags"
id="tags"
options={{ allowCustom: false, suggestions: ["apple", "banana"] }}
/>,
);
const input = findInput()!;
fireEvent.change(input, { target: { value: "cherry" } });
fireEvent.keyDown(input, { key: "Enter" });
expect(onChange).not.toHaveBeenCalled();
});
it("accepts suggestion when allowCustom is false", () => {
const onChange = vi.fn();
render(
<Tags
value={[]}
onChange={onChange}
label="Tags"
id="tags"
options={{ allowCustom: false, suggestions: ["apple", "banana"] }}
/>,
);
const input = findInput()!;
fireEvent.change(input, { target: { value: "apple" } });
fireEvent.keyDown(input, { key: "Enter" });
expect(onChange).toHaveBeenCalledWith(["apple"]);
});
it("normalizes non-array value to empty array", () => {
render(<Tags value={"not-an-array"} onChange={() => {}} label="Tags" id="tags" />);
expect(screen.queryAllByTestId("badge")).toHaveLength(0);
});
});

View File

@@ -0,0 +1,10 @@
{
"extends": "../tsconfig.base.json",
"compilerOptions": {
"outDir": "./dist",
"rootDir": "./src",
"jsx": "react-jsx"
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}

View File

@@ -0,0 +1,11 @@
import react from "@vitejs/plugin-react";
import { defineConfig } from "vitest/config";
export default defineConfig({
plugins: [react()],
test: {
environment: "jsdom",
globals: true,
include: ["tests/**/*.test.{ts,tsx}"],
},
});