feat(plugins): add @emdash-cms/plugin-field-kit#702
Conversation
🦋 Changeset detectedLatest commit: a534ab4 The changes in this PR will be included in the next version bump. This PR includes changesets to release 14 packages
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
Scope checkThis PR changes 3,986 lines across 33 files. Large PRs are harder to review and more likely to be closed without review. If this scope is intentional, no action needed. A maintainer will review it. If not, please consider splitting this into smaller PRs. See CONTRIBUTING.md for contribution guidelines. |
Lunaria Status Overview🌕 This pull request will trigger status changes. Learn moreBy default, every PR changing files present in the Lunaria configuration's You can change this by adding one of the keywords present in the Tracked Files
Warnings reference
|
@emdash-cms/admin
@emdash-cms/auth
@emdash-cms/blocks
@emdash-cms/cloudflare
emdash
create-emdash
@emdash-cms/gutenberg-to-portable-text
@emdash-cms/x402
@emdash-cms/plugin-ai-moderation
@emdash-cms/plugin-atproto
@emdash-cms/plugin-audit-log
@emdash-cms/plugin-color
@emdash-cms/plugin-embeds
@emdash-cms/plugin-forms
@emdash-cms/plugin-webhook-notifier
commit: |
a29c6b0 to
623d2e6
Compare
Overlapping PRsThis PR modifies files that are also changed by other open PRs: This may cause merge conflicts or duplicated work. A maintainer will coordinate. |
There was a problem hiding this comment.
Pull request overview
Adds a new first‑party plugin package, @emdash-cms/plugin-field-kit, that provides configurable (seed‑driven) React field widgets for editing json fields in the admin UI, and updates the manifest/admin types to allow FieldDescriptor.options to be either enum choices or arbitrary widget config.
Changes:
- Introduces
@emdash-cms/plugin-field-kitwith 4 JSON widgets (object-form,list,grid,tags) plus shared helpers and a test suite. - Widens
FieldDescriptor.optionstoArray<{value,label}> | Record<string, unknown>and narrows atselect/multiSelectcall sites inContentEditor. - Updates Lingui locale catalogs to match the shifted line numbers in
ContentEditor.
Reviewed changes
Copilot reviewed 32 out of 33 changed files in this pull request and generated 9 comments.
Show a summary per file
| File | Description |
|---|---|
| pnpm-lock.yaml | Adds workspace entries/dependency resolutions for the new plugin (and other workspace changes). |
| packages/plugins/field-kit/package.json | Declares the new plugin package, peer deps, and scripts. |
| packages/plugins/field-kit/CHANGELOG.md | Initializes plugin changelog. |
| packages/plugins/field-kit/tsconfig.json | Adds TypeScript config for the new plugin package. |
| packages/plugins/field-kit/vitest.config.ts | Adds Vitest config for plugin unit tests (jsdom + React). |
| packages/plugins/field-kit/src/index.ts | Defines the plugin via definePlugin() and exports a config descriptor helper. |
| packages/plugins/field-kit/src/admin.tsx | Exposes the plugin’s field widget components to the admin plugin loader. |
| packages/plugins/field-kit/src/shared/types.ts | Adds shared widget/sub-field type definitions. |
| packages/plugins/field-kit/src/shared/utils.ts | Adds normalization + template rendering utilities used by widgets. |
| packages/plugins/field-kit/src/shared/sub-field.tsx | Implements a reusable sub-field renderer used by object-form and list. |
| packages/plugins/field-kit/src/widgets/object-form.tsx | Implements the inline JSON object editor widget. |
| packages/plugins/field-kit/src/widgets/list.tsx | Implements the ordered JSON array editor widget (add/remove/reorder). |
| packages/plugins/field-kit/src/widgets/grid.tsx | Implements the rows×cols JSON matrix editor widget. |
| packages/plugins/field-kit/src/widgets/tags.tsx | Implements the string-array tags/chips widget. |
| packages/plugins/field-kit/tests/object-form.test.tsx | Unit tests for object-form widget behavior. |
| packages/plugins/field-kit/tests/list.test.tsx | Unit tests for list widget behavior. |
| packages/plugins/field-kit/tests/grid.test.tsx | Unit tests for grid widget behavior (incl. legacy array normalization). |
| packages/plugins/field-kit/tests/tags.test.tsx | Unit tests for tags widget behavior (dedupe/max/transform, etc.). |
| packages/core/src/api/types.ts | Widens API FieldDescriptor.options type to support plugin widget configs. |
| packages/admin/src/components/ContentEditor.tsx | Mirrors widened FieldDescriptor.options type and narrows for select/multiSelect rendering. |
| packages/admin/src/locales/en/messages.po | Regenerates message catalog references after ContentEditor line shifts. |
| packages/admin/src/locales/zh-CN/messages.po | Regenerates message catalog references after ContentEditor line shifts. |
| packages/admin/src/locales/pt-BR/messages.po | Regenerates message catalog references after ContentEditor line shifts. |
| packages/admin/src/locales/pseudo/messages.po | Regenerates message catalog references after ContentEditor line shifts. |
| packages/admin/src/locales/ko/messages.po | Regenerates message catalog references after ContentEditor line shifts. |
| packages/admin/src/locales/eu/messages.po | Regenerates message catalog references after ContentEditor line shifts. |
| packages/admin/src/locales/es-419/messages.po | Regenerates message catalog references after ContentEditor line shifts. |
| packages/admin/src/locales/de/messages.po | Regenerates message catalog references after ContentEditor line shifts. |
| .changeset/field-kit-plugin-initial.md | Adds changeset entries for the new plugin and related patches. |
Files not reviewed (1)
- pnpm-lock.yaml: Language not supported
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| * Normalize a value into a plain object keyed by sub-field definitions. | ||
| * Missing keys get their defaultValue (or undefined). Unknown keys are stripped. | ||
| */ | ||
| export function normalizeObject(value: unknown, fields: SubFieldDef[]): Record<string, unknown> { | ||
| const obj: Record<string, unknown> = {}; | ||
| const source = | ||
| value && typeof value === "object" && !Array.isArray(value) | ||
| ? (value as Record<string, unknown>) | ||
| : {}; | ||
| for (const field of fields) { | ||
| obj[field.key] = | ||
| source[field.key] !== undefined ? source[field.key] : (field.defaultValue ?? undefined); | ||
| } |
There was a problem hiding this comment.
normalizeObject() explicitly strips unknown keys (only keys present in options.fields are preserved). This is data-lossy for existing JSON values that may contain extra keys (e.g. schema evolution or partially-managed objects) and contradicts the PR description claim of "no shape mutation". Consider preserving unknown keys by merging the original object into the normalized output (or add an explicit stripUnknownKeys option defaulting to false).
There was a problem hiding this comment.
Fixed. normalizeObject() now starts with a shallow clone of the input (when it's a plain object) and then overlays declared-field defaults, so keys not listed in options.fields survive a round-trip. Added a test in object-form.test.tsx that asserts an unknown key stray is preserved through onChange, and flipped the previous test that had codified the strip-on-write behavior. This makes the "no shape mutation" claim in the PR body actually true.
| export function SubField({ def, value, onChange }: SubFieldProps) { | ||
| const fieldId = `field-kit-${def.key}`; | ||
|
|
There was a problem hiding this comment.
SubField builds its input id as field-kit-${def.key}. In the list widget, the same sub-field key repeats across multiple list items, producing duplicate IDs in the DOM and breaking label/input associations (and potentially automation/a11y tooling). Pass a stable per-instance prefix (e.g. parent field id + item index) into SubField, or accept an id prop and compose it in the parent.
There was a problem hiding this comment.
Fixed. SubField now takes an explicit id prop instead of deriving field-kit-${def.key} internally. The two callers compose per-instance ids:
object-form.tsx→${parentId}-${def.key}list.tsx→${parentId}-${rowKey}-${def.key}(whererowKeyis the stable item id introduced for comment fix(create-emdash): fix spinner hanging during dep install #7)
Added a test in object-form.test.tsx asserting sub-field ids are unique and scoped under the parent id, plus a test in list.test.tsx asserting different list items produce different sub-field ids.
| {!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="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" |
There was a problem hiding this comment.
The rendered <label> isn’t associated with the <input> (no htmlFor, and the input has no aria-label). In minimal mode the top label is hidden entirely, leaving the input without an accessible name. Associate the label via htmlFor={id} and/or add aria-label/aria-labelledby on the input so screen readers can announce what the field is.
There was a problem hiding this comment.
Fixed. Added htmlFor={id} to the visible label and aria-label={label} on the <input>. The aria-label is the load-bearing one for minimal mode — when the visible label is hidden, the input still announces with the field's label as its accessible name.
| <button | ||
| type="button" | ||
| className="flex flex-1 cursor-pointer items-center gap-1.5 border-none bg-transparent p-0 text-left text-sm text-kumo-default" | ||
| onClick={() => setExpandedIndex(isExpanded ? null : index)} | ||
| > |
There was a problem hiding this comment.
<button> is used for the expandable row header even though the repo’s admin UI conventions require using Kumo components for standard UI controls (see AGENTS.md “Admin UI: Use Kumo Components”). Consider replacing this with a Kumo Button (e.g. variant="ghost", full-width) to keep consistent styling and built-in accessibility behaviors.
There was a problem hiding this comment.
Holding off on swapping to Kumo Button — happy to revisit if you'd prefer. The row header is a disclosure-style trigger (chevron + summary text, where the summary itself is a user-configured mustache template), and the closest existing pattern in the admin is RepeaterField.tsx which uses a plain <div onClick> for its collapsible header — our <button type="button"> is strictly more accessible than that. Raw <button type="button"> styled with Kumo design tokens is also used routinely elsewhere in the admin (TaxonomySidebar.tsx, SetupWizard.tsx, LoginPage.tsx, etc.) alongside Kumo <Button>. If you'd prefer we swap to Kumo Button anyway for consistency, just flag it and I'll change both this and the object-form header.
There was a problem hiding this comment.
Aside from existing patterns, is there a reason to not use the Kumo components? Does the style not work here? I'm fine with diverging if that's the case, but in general I'd say use Kumo unless there's a specific reason not too. There are definitely quite a few places in the codebase that should be using Kumo but aren't – whether because they slipped in, or because they weren't caught when we first migrated, so I wouldn't place much weight on that.
There was a problem hiding this comment.
Fair point — you're right. I checked Kumo Button's actual API: variant="ghost" with the chevron passed as icon handles this cleanly, no technical blocker. I'd been fighting Kumo's defaults with border-none bg-transparent p-0 which is exactly the kind of thing that belongs on a migration follow-up list.
Switched both the list.tsx and object-form.tsx disclosure headers to Kumo Button.
Tests + visual smoke check confirm both disclosure patterns render + interact correctly.
| {!minimal && ( | ||
| <button | ||
| type="button" | ||
| className="mb-2 flex cursor-pointer items-center gap-1.5 border-none bg-transparent p-0 text-sm font-medium text-kumo-default" | ||
| onClick={() => setIsOpen((o) => !o)} | ||
| > |
There was a problem hiding this comment.
The collapsible header uses a raw <button> instead of a Kumo Button, which diverges from the admin UI convention of using Kumo components for standard controls (AGENTS.md “Admin UI: Use Kumo Components”). Swapping to Kumo Button helps ensure consistent focus styles, accessibility, and theming.
There was a problem hiding this comment.
Same reasoning as the list.tsx header above — mirroring that decision here for consistency. Will switch both (or neither) based on your call.
| } else if (rowVal && typeof rowVal === "object") { | ||
| // Object format: copy known column values | ||
| const rowObj = rowVal as Record<string, unknown>; | ||
| for (const col of columns) { | ||
| if (rowObj[col.key] !== undefined) { | ||
| rowOut[col.key] = rowObj[col.key]; | ||
| } | ||
| } |
There was a problem hiding this comment.
normalizeGrid() drops any unknown column keys when the stored value is in object form (it only copies keys listed in columns). If a site stored additional per-cell metadata or the column set changed over time, those values will be silently lost on the next write. If the goal is to keep JSON durable/removable, consider preserving unknown keys (copy the entire row object and then ensure required row/col keys exist), or gate pruning behind an explicit option.
There was a problem hiding this comment.
Fixed. In the object-format branch the row is now seeded with Object.assign(rowOut, rowObj) before declared columns are overlaid, so cells added to the schema later (or managed outside the widget) aren't silently dropped on save. Added a grid test asserting an unknown cell key legacy: "keep-me" survives a toggle write.
| {items.map((item, index) => { | ||
| const isExpanded = expandedIndex === index; | ||
| return ( | ||
| <div | ||
| key={index} | ||
| className={`border-b border-kumo-hairline last:border-b-0 ${ | ||
| isExpanded ? "bg-kumo-tint" : "" |
There was a problem hiding this comment.
This list is explicitly reorderable (sortable + move up/down), but each row uses key={index}. Using the array index as a React key for reorderable items can cause incorrect state retention and focus/DOM mismatches when items are moved. Use a stable per-item key instead (e.g. include an internal id per item in state, or maintain a parallel stable key list that isn’t persisted to the stored JSON).
There was a problem hiding this comment.
Fixed. The widget now maintains a parallel itemIds: string[] state array, generated with crypto.randomUUID() when items are added. It's kept in lockstep with the stored items across add/remove/reorder and used as the React key — so form state, focus, and sub-field DOM nodes now follow the moved item rather than the slot. The stable ids are never persisted to the stored JSON; the value shape stays a plain array of objects.
Considered the alternative of persisting a _key per item (as RepeaterField.tsx does), but chose ephemeral-id-in-state to keep the stored JSON shape a clean array of plain objects, matching the PR's "no shape mutation" promise. Happy to change course if you'd prefer the _key approach for consistency.
| /** | ||
| * 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>; |
There was a problem hiding this comment.
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.ts → AdminManifest.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.
There was a problem hiding this comment.
Fixed. Widened AdminManifest.collections[].fields[].options in packages/admin/src/lib/api/client.ts to match the union I landed in FieldDescriptor.options — Array<{ value: string; label: string }> | Record<string, unknown>. Good catch; I widened two of the three sites originally and this was the one I missed.
| demos/field-kit-test: | ||
| dependencies: | ||
| '@astrojs/node': | ||
| specifier: 'catalog:' | ||
| version: 10.0.0(astro@6.0.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.31.1)(rollup@4.55.2)(tsx@4.21.0)(typescript@6.0.0-beta)(yaml@2.8.2)) | ||
| '@astrojs/react': | ||
| specifier: 'catalog:' | ||
| version: 5.0.0(@types/node@24.10.13)(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(jiti@2.6.1)(lightningcss@1.31.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(tsx@4.21.0)(yaml@2.8.2) | ||
| '@emdash-cms/plugin-field-kit': | ||
| specifier: workspace:* | ||
| version: link:../../packages/plugins/field-kit | ||
| astro: | ||
| specifier: 'catalog:' | ||
| version: 6.0.1(@types/node@24.10.13)(jiti@2.6.1)(lightningcss@1.31.1)(rollup@4.55.2)(tsx@4.21.0)(typescript@6.0.0-beta)(yaml@2.8.2) | ||
| better-sqlite3: | ||
| specifier: 'catalog:' | ||
| version: 12.8.0 | ||
| emdash: | ||
| specifier: workspace:* | ||
| version: link:../../packages/core | ||
| react: | ||
| specifier: 'catalog:' | ||
| version: 19.2.4 | ||
| react-dom: | ||
| specifier: 'catalog:' | ||
| version: 19.2.4(react@19.2.4) | ||
| devDependencies: | ||
| '@astrojs/check': | ||
| specifier: 'catalog:' | ||
| version: 0.9.7(prettier-plugin-astro@0.14.1)(prettier@3.8.1)(typescript@6.0.0-beta) | ||
|
|
There was a problem hiding this comment.
pnpm-lock.yaml now includes an importers.demos/field-kit-test workspace entry, but there is no demos/field-kit-test/ directory in the repo and it isn't listed in pnpm-workspace.yaml. This makes the lockfile inconsistent with the workspace and can cause confusing lockfile churn (and may break frozen installs depending on pnpm version). Regenerate the lockfile without this stray importer entry (or add the missing workspace package if it was intended to be committed).
There was a problem hiding this comment.
Fixed. The demos/field-kit-test/ directory was local-only smoke-test scaffolding I'd created during development; the demos/* workspace glob captured it when I ran pnpm install. I moved that directory out of the workspace and regenerated the lockfile — grep -n "demos/field-kit-test" pnpm-lock.yaml now returns nothing. Good catch, this would have broken --frozen-lockfile on CI.
623d2e6 to
2862582
Compare
|
Pushed an update addressing Copilot's review — single clean commit rebased on latest Fixed (7): duplicate sub-field DOM ids in the list widget, missing accessible name on tags input in minimal mode, Left as-is (2): raw Verification: typecheck + 784 admin tests + 33 plugin tests (30 + 3 new) pass. |
2862582 to
1fe617b
Compare
|
@ilicfilip this needs conflicts to be resolved. the easiest way is to remove the messages.po changes from the PR: the locales will be updated automatically when we merge this |
1fe617b to
1e2b2bb
Compare
|
@ascorbic done — force-pushed 1e2b2bb rebased onto latest main.
Verified locally: 33 plugin tests + 796 admin tests pass, pnpm format applied, no new lint errors. |
| @@ -0,0 +1,48 @@ | |||
| { | |||
| "name": "@emdash-cms/plugin-field-kit", | |||
| "version": "0.1.0", | |||
There was a problem hiding this comment.
| "version": "0.1.0", | |
| "version": "0.0.0", |
New packages need to start at 0.0.0 – I have to manually publish it the first time, before it's bumped when published in CI
There was a problem hiding this comment.
Done in a534ab4 - set version to 0.0.0 in package.json. Also updated the matching PLUGIN_VERSION constant in src/index.ts so the runtime-reported version stays in sync with the package version. The minor bump in the changeset is unchanged, so the next CI release after your manual publish will move it to 0.1.0.
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<string, unknown>`
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 <ilic.filip@gmail.com>
1e2b2bb to
a534ab4
Compare
ascorbic
left a comment
There was a problem hiding this comment.
Thanks! Could you do a follow-up PR with docs for this
Adds the Field Kit reference page covering installation, all four widgets (object-form, list, grid, tags), sub-field types, summary templates, and data durability guarantees. Adds the page to the Plugins sidebar after Block Kit. Follow-up to #702 per the maintainer's request on review. Signed-off-by: Filip Ilic <ilic.filip@gmail.com>
What does this PR do?
Adds
@emdash-cms/plugin-field-kit— a first-party plugin with four composable field widgets forjsonfields, configured entirely through seedoptionsso site builders don't need to write React to get a usable editing UI.object-form— inline form for flat JSON objects (nutrition facts, contact info, etc.)list— ordered array editor with add / remove / reordergrid— rows × columns matrix (availability hours, price matrix, spec sheet)tags— free-form tag/chip input for string arraysWidgets 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.
Also widens
FieldDescriptor.optionsfromArray<{ value: string; label: string }>toArray<{ value: string; label: string }> | Record<string, unknown>so plugin widgets can accept arbitrary widget config (not only enum choices), per @ascorbic's note in the discussion. The array shape forselect/multiSelectcontinues to work unchanged —ContentEditornarrows toArrayat the usage site.Follows up on discussion #571 — maintainer confirmed direction as first-party plugin.
Type of change
Checklist
pnpm typecheckpassespnpm lintpasses — no new errors introduced; the 22 warnings + 2 errors on this branch are pre-existing onmain(ininfra/cache-demo/tsconfig.json,fixtures/perf-site/tsconfig.json, and other unrelated files)pnpm testpasses — 771 admin tests + 30 new plugin testspnpm formathas been runonChangeshapes, grid legacy-array normalization, and tags dedupe / max / transformt`…`) andpnpm locale:extracthas been run@emdash-cms/plugin-field-kit: minor,emdash: patch,@emdash-cms/admin: patchAI-generated code disclosure
Screenshots / test output
Smoke-tested end-to-end in a local demo (
demos/field-kit-test/— not committed) against aproductscollection exercising all four widgets. JSON round-trips cleanly and every widget renders without console warnings.Test output
Follow-ups (separate PRs)
visibleWhen— conditional sub-field visibility (already prototyped onilicfilip/field-kit#2; leaving to a follow-up per discussion feedback)