Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .changeset/field-kit-plugin-initial.md
Original file line number Diff line number Diff line change
@@ -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<string, unknown>` so plugin widgets can accept arbitrary widget config (not only enum choices). The array shape for `select` / `multiSelect` continues to work unchanged.
16 changes: 11 additions & 5 deletions packages/admin/src/components/ContentEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>;
Comment on lines +85 to +89

Copilot AI Apr 21, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

FieldDescriptor.options is widened here to allow plugin widgets to receive arbitrary config objects, but the admin-side manifest type (packages/admin/src/lib/api/client.tsAdminManifest.collections[].fields[].options) is still typed as an enum array only. This creates a type mismatch and makes it easy for future code to accidentally treat plugin widget options as Array everywhere. Consider updating the shared manifest/client types to match this union so the admin consumes plugin widget config safely and consistently.

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed. Widened AdminManifest.collections[].fields[].options in packages/admin/src/lib/api/client.ts to match the union I landed in FieldDescriptor.optionsArray<{ value: string; label: string }> | Record<string, unknown>. Good catch; I widened two of the three sites originally and this was the one I missed.

widget?: string;
validation?: Record<string, unknown>;
}
Expand Down Expand Up @@ -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<string, unknown>;
minimal?: boolean;
}>
| undefined;
Expand Down Expand Up @@ -1204,8 +1208,9 @@ function FieldRenderer({
);

case "select": {
const selectOptions = Array.isArray(field.options) ? field.options : [];
const selectItems: Record<string, string> = {};
for (const opt of field.options ?? []) {
for (const opt of selectOptions) {
selectItems[opt.value] = opt.label;
}
return (
Expand All @@ -1216,7 +1221,7 @@ function FieldRenderer({
onValueChange={(v) => handleChange(v ?? "")}
items={selectItems}
>
{field.options?.map((opt) => (
{selectOptions.map((opt) => (
<Select.Option key={opt.value} value={opt.value}>
{opt.label}
</Select.Option>
Expand All @@ -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 (
<fieldset>
<Label className={labelClass}>{label}</Label>
<div className="mt-2 flex flex-wrap gap-x-4 gap-y-2">
{field.options?.map((opt) => {
{multiSelectOptions.map((opt) => {
const isChecked = selected.includes(opt.value);
return (
<Checkbox
Expand Down
6 changes: 5 additions & 1 deletion packages/admin/src/lib/api/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,11 @@ export interface AdminManifest {
label?: string;
required?: boolean;
widget?: string;
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<string, unknown>;
validation?: Record<string, unknown>;
}
>;
Expand Down
6 changes: 5 additions & 1 deletion packages/core/src/api/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>;
}

/**
Expand Down
1 change: 1 addition & 0 deletions packages/plugins/field-kit/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
# @emdash-cms/plugin-field-kit
48 changes: 48 additions & 0 deletions packages/plugins/field-kit/package.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
11 changes: 11 additions & 0 deletions packages/plugins/field-kit/src/admin.tsx
Original file line number Diff line number Diff line change
@@ -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 changes: 65 additions & 0 deletions packages/plugins/field-kit/src/index.ts
Original file line number Diff line number Diff line change
@@ -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",
};
}
Loading
Loading