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:
14
packages/plugins/field-kit/CHANGELOG.md
Normal file
14
packages/plugins/field-kit/CHANGELOG.md
Normal 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
|
||||
48
packages/plugins/field-kit/package.json
Normal file
48
packages/plugins/field-kit/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
11
packages/plugins/field-kit/src/admin.tsx
Normal file
11
packages/plugins/field-kit/src/admin.tsx
Normal 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,
|
||||
};
|
||||
65
packages/plugins/field-kit/src/index.ts
Normal file
65
packages/plugins/field-kit/src/index.ts
Normal 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",
|
||||
};
|
||||
}
|
||||
213
packages/plugins/field-kit/src/shared/sub-field.tsx
Normal file
213
packages/plugins/field-kit/src/shared/sub-field.tsx
Normal 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)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
73
packages/plugins/field-kit/src/shared/types.ts
Normal file
73
packages/plugins/field-kit/src/shared/types.ts
Normal 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;
|
||||
}
|
||||
101
packages/plugins/field-kit/src/shared/utils.ts
Normal file
101
packages/plugins/field-kit/src/shared/utils.ts
Normal 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 "";
|
||||
});
|
||||
}
|
||||
250
packages/plugins/field-kit/src/widgets/grid.tsx
Normal file
250
packages/plugins/field-kit/src/widgets/grid.tsx
Normal 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">
|
||||
|
||||
</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;
|
||||
}
|
||||
}
|
||||
309
packages/plugins/field-kit/src/widgets/list.tsx
Normal file
309
packages/plugins/field-kit/src/widgets/list.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
113
packages/plugins/field-kit/src/widgets/object-form.tsx
Normal file
113
packages/plugins/field-kit/src/widgets/object-form.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
158
packages/plugins/field-kit/src/widgets/tags.tsx
Normal file
158
packages/plugins/field-kit/src/widgets/tags.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
167
packages/plugins/field-kit/tests/grid.test.tsx
Normal file
167
packages/plugins/field-kit/tests/grid.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
215
packages/plugins/field-kit/tests/list.test.tsx
Normal file
215
packages/plugins/field-kit/tests/list.test.tsx
Normal 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);
|
||||
});
|
||||
});
|
||||
183
packages/plugins/field-kit/tests/object-form.test.tsx
Normal file
183
packages/plugins/field-kit/tests/object-form.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
131
packages/plugins/field-kit/tests/tags.test.tsx
Normal file
131
packages/plugins/field-kit/tests/tags.test.tsx
Normal 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);
|
||||
});
|
||||
});
|
||||
10
packages/plugins/field-kit/tsconfig.json
Normal file
10
packages/plugins/field-kit/tsconfig.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"extends": "../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"jsx": "react-jsx"
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
11
packages/plugins/field-kit/vitest.config.ts
Normal file
11
packages/plugins/field-kit/vitest.config.ts
Normal 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}"],
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user