From a534ab44049bf2974629ca44a649343e64d2c806 Mon Sep 17 00:00:00 2001 From: Filip Ilic Date: Fri, 17 Apr 2026 15:48:59 +0200 Subject: [PATCH] feat(plugins): add @emdash-cms/plugin-field-kit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Composable field widgets for `json` fields. Four widgets configured entirely through seed `options` — no React required from site builders: - object-form — inline form for flat JSON objects - list — ordered array editor with add/remove/reorder - grid — rows × columns matrix (toggle / text / number / select cells) - tags — free-form tag/chip input for string arrays Widgets use Kumo components and semantic design tokens to match the admin's visual language. Stored data is clean JSON that survives removing the plugin — no shape mutation, no new columns, no migration. Ships with 30 unit tests covering render, onChange shapes, grid legacy-array format normalization, and tags dedupe/max/transform. Also widens `FieldDescriptor.options` from `Array<{ value: string; label: string }>` to `Array<{ value: string; label: string }> | Record` so plugin widgets can accept arbitrary widget config (not only enum choices). The array shape for `select` / `multiSelect` continues to work unchanged — `ContentEditor` narrows at the usage site. Signed-off-by: Filip Ilic --- .changeset/field-kit-plugin-initial.md | 9 + .../admin/src/components/ContentEditor.tsx | 16 +- packages/admin/src/lib/api/client.ts | 6 +- packages/core/src/api/types.ts | 6 +- packages/plugins/field-kit/CHANGELOG.md | 1 + packages/plugins/field-kit/package.json | 48 +++ packages/plugins/field-kit/src/admin.tsx | 11 + packages/plugins/field-kit/src/index.ts | 65 ++++ .../field-kit/src/shared/sub-field.tsx | 213 ++++++++++++ .../plugins/field-kit/src/shared/types.ts | 73 +++++ .../plugins/field-kit/src/shared/utils.ts | 101 ++++++ .../plugins/field-kit/src/widgets/grid.tsx | 250 ++++++++++++++ .../plugins/field-kit/src/widgets/list.tsx | 309 ++++++++++++++++++ .../field-kit/src/widgets/object-form.tsx | 113 +++++++ .../plugins/field-kit/src/widgets/tags.tsx | 158 +++++++++ .../plugins/field-kit/tests/grid.test.tsx | 167 ++++++++++ .../plugins/field-kit/tests/list.test.tsx | 215 ++++++++++++ .../field-kit/tests/object-form.test.tsx | 183 +++++++++++ .../plugins/field-kit/tests/tags.test.tsx | 131 ++++++++ packages/plugins/field-kit/tsconfig.json | 10 + packages/plugins/field-kit/vitest.config.ts | 11 + pnpm-lock.yaml | 37 +++ 22 files changed, 2126 insertions(+), 7 deletions(-) create mode 100644 .changeset/field-kit-plugin-initial.md create mode 100644 packages/plugins/field-kit/CHANGELOG.md create mode 100644 packages/plugins/field-kit/package.json create mode 100644 packages/plugins/field-kit/src/admin.tsx create mode 100644 packages/plugins/field-kit/src/index.ts create mode 100644 packages/plugins/field-kit/src/shared/sub-field.tsx create mode 100644 packages/plugins/field-kit/src/shared/types.ts create mode 100644 packages/plugins/field-kit/src/shared/utils.ts create mode 100644 packages/plugins/field-kit/src/widgets/grid.tsx create mode 100644 packages/plugins/field-kit/src/widgets/list.tsx create mode 100644 packages/plugins/field-kit/src/widgets/object-form.tsx create mode 100644 packages/plugins/field-kit/src/widgets/tags.tsx create mode 100644 packages/plugins/field-kit/tests/grid.test.tsx create mode 100644 packages/plugins/field-kit/tests/list.test.tsx create mode 100644 packages/plugins/field-kit/tests/object-form.test.tsx create mode 100644 packages/plugins/field-kit/tests/tags.test.tsx create mode 100644 packages/plugins/field-kit/tsconfig.json create mode 100644 packages/plugins/field-kit/vitest.config.ts diff --git a/.changeset/field-kit-plugin-initial.md b/.changeset/field-kit-plugin-initial.md new file mode 100644 index 000000000..336556021 --- /dev/null +++ b/.changeset/field-kit-plugin-initial.md @@ -0,0 +1,9 @@ +--- +"@emdash-cms/plugin-field-kit": minor +"emdash": patch +"@emdash-cms/admin": patch +--- + +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` so plugin widgets can accept arbitrary widget config (not only enum choices). The array shape for `select` / `multiSelect` continues to work unchanged. diff --git a/packages/admin/src/components/ContentEditor.tsx b/packages/admin/src/components/ContentEditor.tsx index 91da08a7a..b35f3a26b 100644 --- a/packages/admin/src/components/ContentEditor.tsx +++ b/packages/admin/src/components/ContentEditor.tsx @@ -82,7 +82,11 @@ export interface FieldDescriptor { kind: string; label?: string; required?: boolean; - options?: Array<{ value: string; label: string }>; + /** + * For `select` / `multiSelect`: the list of enum choices. + * For `json` fields driven by a plugin `widget`: arbitrary widget config. + */ + options?: Array<{ value: string; label: string }> | Record; widget?: string; validation?: Record; } @@ -1087,7 +1091,7 @@ function FieldRenderer({ label: string; id: string; required?: boolean; - options?: Array<{ value: string; label: string }>; + options?: Array<{ value: string; label: string }> | Record; minimal?: boolean; }> | undefined; @@ -1204,8 +1208,9 @@ function FieldRenderer({ ); case "select": { + const selectOptions = Array.isArray(field.options) ? field.options : []; const selectItems: Record = {}; - for (const opt of field.options ?? []) { + for (const opt of selectOptions) { selectItems[opt.value] = opt.label; } return ( @@ -1216,7 +1221,7 @@ function FieldRenderer({ onValueChange={(v) => handleChange(v ?? "")} items={selectItems} > - {field.options?.map((opt) => ( + {selectOptions.map((opt) => ( {opt.label} @@ -1226,12 +1231,13 @@ function FieldRenderer({ } case "multiSelect": { + const multiSelectOptions = Array.isArray(field.options) ? field.options : []; const selected: string[] = Array.isArray(value) ? (value as string[]) : []; return (
- {field.options?.map((opt) => { + {multiSelectOptions.map((opt) => { const isChecked = selected.includes(opt.value); return ( ; + /** + * For `select` / `multiSelect`: the list of enum choices. + * For `json` fields driven by a plugin `widget`: arbitrary widget config. + */ + options?: Array<{ value: string; label: string }> | Record; validation?: Record; } >; diff --git a/packages/core/src/api/types.ts b/packages/core/src/api/types.ts index 01dc92ac7..a7ee19d29 100644 --- a/packages/core/src/api/types.ts +++ b/packages/core/src/api/types.ts @@ -51,7 +51,11 @@ export interface FieldDescriptor { kind: string; label?: string; required?: boolean; - options?: Array<{ value: string; label: string }>; + /** + * For `select` / `multiSelect`: the list of enum choices. + * For `json` fields driven by a plugin `widget`: arbitrary widget config. + */ + options?: Array<{ value: string; label: string }> | Record; } /** diff --git a/packages/plugins/field-kit/CHANGELOG.md b/packages/plugins/field-kit/CHANGELOG.md new file mode 100644 index 000000000..9e8c39bab --- /dev/null +++ b/packages/plugins/field-kit/CHANGELOG.md @@ -0,0 +1 @@ +# @emdash-cms/plugin-field-kit diff --git a/packages/plugins/field-kit/package.json b/packages/plugins/field-kit/package.json new file mode 100644 index 000000000..d2c7bc20d --- /dev/null +++ b/packages/plugins/field-kit/package.json @@ -0,0 +1,48 @@ +{ + "name": "@emdash-cms/plugin-field-kit", + "version": "0.0.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.1.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" + } +} diff --git a/packages/plugins/field-kit/src/admin.tsx b/packages/plugins/field-kit/src/admin.tsx new file mode 100644 index 000000000..71e294d3f --- /dev/null +++ b/packages/plugins/field-kit/src/admin.tsx @@ -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, +}; diff --git a/packages/plugins/field-kit/src/index.ts b/packages/plugins/field-kit/src/index.ts new file mode 100644 index 000000000..9c247424c --- /dev/null +++ b/packages/plugins/field-kit/src/index.ts @@ -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", + }; +} diff --git a/packages/plugins/field-kit/src/shared/sub-field.tsx b/packages/plugins/field-kit/src/shared/sub-field.tsx new file mode 100644 index 000000000..bfd2dca10 --- /dev/null +++ b/packages/plugins/field-kit/src/shared/sub-field.tsx @@ -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} + * + + ); +} + +/** + * 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 ( + onChange(e.target.value)} + /> + ); + + case "url": + return ( + onChange(e.target.value)} + /> + ); + + case "number": { + const prefixOrSuffix = def.prefix || def.suffix; + const labelId = `${fieldId}-label`; + const numberInput = ( + { + const v = e.target.value; + onChange(v === "" ? undefined : Number(v)); + }} + /> + ); + + if (!prefixOrSuffix) return numberInput; + + return ( +
+ +
+ {def.prefix && ( + {def.prefix} + )} + {numberInput} + {def.suffix && ( + {def.suffix} + )} +
+ {def.helpText &&

{def.helpText}

} +
+ ); + } + + case "boolean": + return ( + onChange(checked)} + /> + ); + + case "select": { + const items = normalizeSelectItems(def.options); + return ( + onChange(e.target.value || undefined)} + /> + ); + + case "color": + return ( +
+ +
+ onChange(e.target.value)} + /> + onChange(e.target.value)} + /> +
+ {def.helpText &&

{def.helpText}

} +
+ ); + + default: + return ( + onChange(e.target.value)} + /> + ); + } +} diff --git a/packages/plugins/field-kit/src/shared/types.ts b/packages/plugins/field-kit/src/shared/types.ts new file mode 100644 index 000000000..078ff63cf --- /dev/null +++ b/packages/plugins/field-kit/src/shared/types.ts @@ -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; + /** 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; +} diff --git a/packages/plugins/field-kit/src/shared/utils.ts b/packages/plugins/field-kit/src/shared/utils.ts new file mode 100644 index 000000000..e23c8c9d9 --- /dev/null +++ b/packages/plugins/field-kit/src/shared/utils.ts @@ -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 { + const source = + value && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : {}; + const obj: Record = { ...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> { + const out: Record> = {}; + for (const row of rows) { + out[row.key] = {}; + } + + if (!value || typeof value !== "object" || Array.isArray(value)) { + return out; + } + + const source = value as Record; + 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; + 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 { + 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 ""; + }); +} diff --git a/packages/plugins/field-kit/src/widgets/grid.tsx b/packages/plugins/field-kit/src/widgets/grid.tsx new file mode 100644 index 000000000..d654a5cb6 --- /dev/null +++ b/packages/plugins/field-kit/src/widgets/grid.tsx @@ -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 ( +
+ {!minimal && ( + + )} +
+

Widget misconfigured

+

+ The field's options.rows and options.columns arrays are + required. Define them in your seed file to use this widget. +

+
+
+ ); + } + + return ( +
+ {!minimal && ( + + )} + +
+ + + + + {columns.map((col) => ( + + ))} + + + + {rows.map((row, rowIdx) => ( + + + {columns.map((col) => { + const cellValue = data[row.key]?.[col.key]; + return ( + + ); + })} + + ))} + +
+   + +
+ {col.image && ( + {col.label} + )} + {col.label} +
+
+
+ {row.image && ( + {row.label} + )} + {row.label} +
+
+ +
+
+ + {helpText &&

{helpText}

} +
+ ); +} + +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 ( +
+ onToggle(rowKey, colKey, !!next)} + /> +
+ ); + + case "text": + return ( + onUpdate(rowKey, colKey, e.target.value)} + /> + ); + + case "number": + return ( + + onUpdate(rowKey, colKey, e.target.value === "" ? undefined : Number(e.target.value)) + } + /> + ); + + case "select": + return ( + 0 ? datalistId : undefined} + onChange={(e) => setInput(e.target.value)} + onKeyDown={handleKeyDown} + onBlur={() => { + if (input.trim()) addTag(input); + }} + /> + )} +
+ + {suggestions.length > 0 && ( + + {suggestions + .filter((s) => !tags.includes(s)) + .map((s) => ( + + )} + + {helpText &&

{helpText}

} + + {max !== undefined && ( +

+ {tags.length}/{max} +

+ )} + + ); +} diff --git a/packages/plugins/field-kit/tests/grid.test.tsx b/packages/plugins/field-kit/tests/grid.test.tsx new file mode 100644 index 000000000..6425c8cb7 --- /dev/null +++ b/packages/plugins/field-kit/tests/grid.test.tsx @@ -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) => ( + onCheckedChange?.(e.target.checked)} + /> + ), + Input: ({ value, onChange, "aria-label": ariaLabel, type }: any) => ( + + ), + Select: ({ value, onValueChange, items, "aria-label": ariaLabel }: any) => ( + + ), +})); + +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( {}} 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( + {}} + 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( + {}} + 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( + , + ); + fireEvent.click(screen.getByLabelText("Mon — PM")); + expect(onChange).toHaveBeenCalledWith({ + mon: { am: true, pm: true }, + tue: {}, + }); + }); + + it("renders text cells when cell is 'text'", () => { + render( + {}} + label="Grid" + id="g" + options={{ rows, columns, cell: "text" }} + />, + ); + expect(screen.getAllByRole("textbox")).toHaveLength(4); + }); + + it("renders select cells with cellOptions", () => { + render( + {}} + 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( + , + ); + 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( + {}} + label="Grid" + id="g" + options={{ rows: [], columns: [] }} + />, + ); + expect(screen.queryByText(/Widget misconfigured/i)).not.toBeNull(); + }); +}); diff --git a/packages/plugins/field-kit/tests/list.test.tsx b/packages/plugins/field-kit/tests/list.test.tsx new file mode 100644 index 000000000..7b25d36e4 --- /dev/null +++ b/packages/plugins/field-kit/tests/list.test.tsx @@ -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) => ( + + ), + Input: ({ label, value, onChange, type, id }: any) => ( + + ), + InputArea: ({ label, value, onChange, id }: any) => ( +