Skip to content
Open
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
5 changes: 5 additions & 0 deletions .changeset/curvy-pianos-bow.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@emdash-cms/admin": patch
---

Fixes JSON fields in the content editor so existing values load into a formatted textarea and valid edits save back as structured JSON.
2 changes: 2 additions & 0 deletions docs/src/content/docs/reference/field-types.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -367,6 +367,8 @@ Arbitrary JSON data. Use for complex nested structures, third-party integrations
}
```

In the admin UI, JSON fields render in a syntax-highlighted editor with JSON validity checking. Existing values are pretty-printed on load, and invalid JSON blocks saving until it is fixed.

Stored as-is in SQLite JSON column.

<Aside type="caution">JSON fields have no validation. Use sparingly for truly dynamic data.</Aside>
Expand Down
10 changes: 7 additions & 3 deletions packages/admin/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,12 @@
"test": "vitest",
"typecheck": "tsgo --noEmit"
},
"dependencies": {
"@cloudflare/kumo": "^1.16.0",
"dependencies": {
"@cloudflare/kumo": "^1.16.0",
"@codemirror/lang-json": "^6.0.1",
"@codemirror/language": "^6.10.8",
"@codemirror/state": "^6.5.2",
"@codemirror/view": "^6.36.3",
Comment on lines +25 to +30
Copy link

Copilot AI Apr 6, 2026

Choose a reason for hiding this comment

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

The indentation in this package.json becomes inconsistent starting at the dependencies key (tabs here vs spaces elsewhere in the same file). This can create noisy diffs and may fight the repo’s formatter settings. Please re-run the formatter or normalize indentation so the file uses a single consistent style.

Suggested change
"dependencies": {
"@cloudflare/kumo": "^1.16.0",
"@codemirror/lang-json": "^6.0.1",
"@codemirror/language": "^6.10.8",
"@codemirror/state": "^6.5.2",
"@codemirror/view": "^6.36.3",
"dependencies": {
"@cloudflare/kumo": "^1.16.0",
"@codemirror/lang-json": "^6.0.1",
"@codemirror/language": "^6.10.8",
"@codemirror/state": "^6.5.2",
"@codemirror/view": "^6.36.3",

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Addressed. packages/admin/package.json was reformatted and now matches repo formatter output (pnpm format).

"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
Expand Down Expand Up @@ -94,4 +98,4 @@
],
"author": "Matt Kane",
"license": "MIT"
}
}
103 changes: 98 additions & 5 deletions packages/admin/src/components/ContentEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,8 +38,14 @@ import { usePluginAdmins } from "../lib/plugin-context.js";
import { cn, slugify } from "../lib/utils";
import { BlockKitFieldWidget } from "./BlockKitFieldWidget.js";
import { DocumentOutline } from "./editor/DocumentOutline";
import type { JsonCodeEditorProps } from "./JsonCodeEditor.js";
import { PluginFieldErrorBoundary } from "./PluginFieldErrorBoundary.js";

const LazyJsonCodeEditor = React.lazy(async () => {
const module = await import("./JsonCodeEditor.js");
return { default: module.JsonCodeEditor };
});

/** Autosave debounce delay in milliseconds */
const AUTOSAVE_DELAY = 2000;

Expand Down Expand Up @@ -212,6 +218,11 @@ export function ContentEditor({
const [slug, setSlug] = React.useState(item?.slug || "");
const [slugTouched, setSlugTouched] = React.useState(!!item?.slug);
const [status, setStatus] = React.useState(item?.status || "draft");
const [fieldErrors, setFieldErrors] = React.useState<Record<string, string | null>>({});
const fieldErrorsRef = React.useRef(fieldErrors);
fieldErrorsRef.current = fieldErrors;
const [jsonEditorEpoch, setJsonEditorEpoch] = React.useState(0);
const previousItemIdRef = React.useRef(item?.id);
const [internalBylines, setInternalBylines] = React.useState<BylineCreditInput[]>(
item?.bylines?.map((entry) => ({ bylineId: entry.byline.id, roleLabel: entry.roleLabel })) ??
[],
Expand Down Expand Up @@ -254,7 +265,21 @@ export function ContentEditor({
const itemDataString = React.useMemo(() => (item ? JSON.stringify(item.data) : ""), [item?.data]);
React.useEffect(() => {
if (item) {
const didItemIdChange = item.id !== previousItemIdRef.current;
const hasActiveFieldErrors = Object.values(fieldErrorsRef.current).some(
(error) => typeof error === "string" && error.length > 0,
);

if (!didItemIdChange && hasActiveFieldErrors) {
return;
}

setFormData(item.data);
if (didItemIdChange) {
setFieldErrors({});
setJsonEditorEpoch((epoch) => epoch + 1);
}
previousItemIdRef.current = item.id;
Comment on lines 267 to +282
Copy link

Copilot AI Apr 6, 2026

Choose a reason for hiding this comment

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

JsonCodeEditor is intentionally uncontrolled, but this effect always resets formData from item.data whenever the item refreshes (same item.id). That can desync the UI from the data being saved: the editor may keep the user’s in-progress JSON text while formData has been overwritten back to the server value, which can also make isDirty/SaveButton state incorrect. Consider skipping setFormData(item.data) when the same item refreshes and there are unsaved local changes, or otherwise ensuring the JSON editor’s current content is reflected back into formData before saving.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Implemented in 81b8398. I updated the item-sync logic to skip same-item refresh sync while any field validation errors are active, which prevents local in-progress JSON input from being desynchronized by autosave refetches. I also added regression coverage for same-item refresh behavior in ContentEditor tests.

setSlug(item.slug || "");
setSlugTouched(!!item.slug);
setStatus(item.status);
Expand Down Expand Up @@ -301,6 +326,16 @@ export function ContentEditor({
[formData, slug, activeBylines],
);
const isDirty = isNew || currentData !== lastSavedData;
const hasFieldErrors = React.useMemo(
() => Object.values(fieldErrors).some((error) => typeof error === "string" && error.length > 0),
[fieldErrors],
);
Comment on lines 328 to +332
Copy link

Copilot AI Apr 6, 2026

Choose a reason for hiding this comment

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

hasFieldErrors disables the manual Save button, but autosave is still gated only by isDirty/isSaving/isAutosaving. With invalid JSON, the UI can show an error state while autosave continues to run with stale formData (last valid parse). Consider also preventing autosave when hasFieldErrors is true (or when specific fields are invalid) to avoid persisting unintended values.

Suggested change
const isDirty = isNew || currentData !== lastSavedData;
const hasFieldErrors = React.useMemo(
() => Object.values(fieldErrors).some((error) => typeof error === "string" && error.length > 0),
[fieldErrors],
);
const hasFieldErrors = React.useMemo(
() => Object.values(fieldErrors).some((error) => typeof error === "string" && error.length > 0),
[fieldErrors],
);
const isDirty = (isNew || currentData !== lastSavedData) && !hasFieldErrors;

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Implemented in 81b8398. Autosave is now gated by validation state (hasFieldErrors) in addition to dirty/saving checks, so autosave will not run while JSON is invalid. Added regression test: does not autosave while json field has validation errors.

const handleFieldValidationChange = React.useCallback((name: string, error: string | null) => {
setFieldErrors((prev) => {
if ((prev[name] ?? null) === error) return prev;
return { ...prev, [name]: error };
});
}, []);

// Autosave with debounce
// Track pending autosave to cancel on manual save
Expand All @@ -316,8 +351,8 @@ export function ContentEditor({
return;
}

// Don't autosave if not dirty or already saving
if (!isDirty || isSaving || isAutosaving) {
// Don't autosave if not dirty, invalid, or already saving
if (!isDirty || hasFieldErrors || isSaving || isAutosaving) {
return;
}

Expand All @@ -340,7 +375,17 @@ export function ContentEditor({
clearTimeout(autosaveTimeoutRef.current);
}
};
}, [currentData, isNew, onAutosave, item?.id, isDirty, isSaving, isAutosaving, activeBylines]);
}, [
currentData,
isNew,
onAutosave,
item?.id,
isDirty,
hasFieldErrors,
isSaving,
isAutosaving,
activeBylines,
]);

// Cancel pending autosave on manual save
const handleSubmit = (e: React.FormEvent) => {
Expand Down Expand Up @@ -534,7 +579,12 @@ export function ContentEditor({
{hasPendingChanges ? "Preview draft" : "Preview"}
</Button>
)}
<SaveButton type="submit" isDirty={isDirty} isSaving={isSaving || false} />
<SaveButton
type="submit"
isDirty={isDirty}
isSaving={isSaving || false}
disabled={hasFieldErrors}
/>
{!isNew && (
<>
{supportsDrafts && hasPendingChanges && onDiscardDraft && (
Expand Down Expand Up @@ -612,7 +662,7 @@ export function ContentEditor({
<div className="space-y-4">
{Object.entries(fields).map(([name, field]) => (
<FieldRenderer
key={name}
key={field.kind === "json" ? `${name}-${jsonEditorEpoch}` : name}
name={name}
field={field}
value={formData[name]}
Expand All @@ -627,6 +677,7 @@ export function ContentEditor({
field.kind === "portableText" ? handleBlockSidebarClose : undefined
}
manifest={manifest}
onValidationChange={handleFieldValidationChange}
/>
))}
</div>
Expand Down Expand Up @@ -922,6 +973,7 @@ interface FieldRendererProps {
field: FieldDescriptor;
value: unknown;
onChange: (name: string, value: unknown) => void;
onValidationChange?: (name: string, error: string | null) => void;
/** Callback when a portableText editor is ready */
onEditorReady?: (editor: Editor) => void;
/** Minimal chrome - hides toolbar, fades labels, removes borders (distraction-free mode) */
Expand All @@ -936,6 +988,26 @@ interface FieldRendererProps {
manifest?: import("../lib/api/client.js").AdminManifest | null;
}

function JsonEditorFallback({
id,
label,
labelClass,
}: Pick<JsonCodeEditorProps, "id" | "label" | "labelClass">) {
return (
<div className="space-y-1.5" aria-live="polite">
<div
id={`${id}-label`}
className={cn("text-sm font-medium leading-none text-kumo-default", labelClass)}
>
{label}
</div>
<div className="rounded-md border border-kumo-line bg-transparent px-3 py-2 text-sm text-kumo-subtle">
Loading editor...
</div>
</div>
);
}

/**
* Render field based on type
*/
Expand All @@ -950,13 +1022,18 @@ function FieldRenderer({
onBlockSidebarOpen,
onBlockSidebarClose,
manifest,
onValidationChange,
}: FieldRendererProps) {
const pluginAdmins = usePluginAdmins();
const label = field.label || name.charAt(0).toUpperCase() + name.slice(1);
const id = `field-${name}`;
const labelClass = minimal ? "text-kumo-subtle/50 text-xs font-normal" : undefined;

const handleChange = React.useCallback((v: unknown) => onChange(name, v), [onChange, name]);
const handleValidationChange = React.useCallback(
(error: string | null) => onValidationChange?.(name, error),
[name, onValidationChange],
);

// Check for plugin field widget override
if (field.widget) {
Expand Down Expand Up @@ -1095,6 +1172,22 @@ function FieldRenderer({
/>
);

case "json":
return (
<React.Suspense
fallback={<JsonEditorFallback id={id} label={label} labelClass={labelClass} />}
>
<LazyJsonCodeEditor
id={id}
label={label}
labelClass={labelClass}
value={value}
onChange={handleChange}
onValidationChange={handleValidationChange}
/>
</React.Suspense>
);

case "select": {
const selectItems: Record<string, string> = {};
for (const opt of field.options ?? []) {
Expand Down
124 changes: 124 additions & 0 deletions packages/admin/src/components/JsonCodeEditor.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
import { json } from "@codemirror/lang-json";
import { defaultHighlightStyle, syntaxHighlighting } from "@codemirror/language";
import { EditorState } from "@codemirror/state";
import { EditorView, type ViewUpdate, placeholder } from "@codemirror/view";
import * as React from "react";

import { cn } from "../lib/utils";

function formatJsonValue(value: unknown): string {
if (value == null) return "";
if (typeof value === "string") {
try {
return JSON.stringify(JSON.parse(value), null, 2);
} catch {
return JSON.stringify(value, null, 2);
}
}
try {
return JSON.stringify(value, null, 2);
} catch {
return "";
}
}

const jsonEditorTheme = EditorView.theme({
"&": {
fontFamily:
'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace',
fontSize: "13px",
},
".cm-scroller": { overflow: "auto", maxHeight: "20rem", minHeight: "10rem" },
".cm-content": { padding: "0.625rem 0.75rem", caretColor: "currentColor" },
".cm-line": { padding: "0" },
"&.cm-focused": { outline: "none" },
".cm-gutters": { display: "none" },
});

export interface JsonCodeEditorProps {
id: string;
label: string;
labelClass?: string;
value: unknown;
onChange: (value: unknown) => void;
onValidationChange?: (error: string | null) => void;
}

export function JsonCodeEditor({
id,
label,
labelClass,
value,
onChange,
onValidationChange,
}: JsonCodeEditorProps) {
const containerRef = React.useRef<HTMLDivElement>(null);
const viewRef = React.useRef<EditorView | null>(null);
const onChangeRef = React.useRef(onChange);
const onValidationRef = React.useRef(onValidationChange);
onChangeRef.current = onChange;
onValidationRef.current = onValidationChange;

React.useEffect(() => {
if (!containerRef.current) return;

const updateListener = EditorView.updateListener.of((update: ViewUpdate) => {
if (!update.docChanged) return;
const content = update.state.doc.toString();
if (!content.trim()) {
onChangeRef.current(null);
onValidationRef.current?.(null);
return;
}
try {
onChangeRef.current(JSON.parse(content));
onValidationRef.current?.(null);
} catch (e) {
onValidationRef.current?.(e instanceof Error ? e.message : "Invalid JSON");
}
});

const view = new EditorView({
state: EditorState.create({
doc: formatJsonValue(value),
extensions: [
json(),
syntaxHighlighting(defaultHighlightStyle),
EditorView.lineWrapping,
EditorView.contentAttributes.of({ "aria-labelledby": `${id}-label` }),
placeholder('{\n "key": "value"\n}'),
jsonEditorTheme,
updateListener,
],
}),
parent: containerRef.current,
});
viewRef.current = view;

return () => {
view.destroy();
viewRef.current = null;
};
// Only runs on mount. The component is intentionally uncontrolled.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);

return (
<div className="space-y-1.5">
<div
id={`${id}-label`}
className={cn("text-sm font-medium leading-none text-kumo-default", labelClass)}
>
{label}
</div>
<div
className={cn(
"overflow-hidden rounded-md border border-kumo-line bg-transparent",
"focus-within:ring-2 focus-within:ring-kumo-ring focus-within:ring-offset-2",
)}
>
<div ref={containerRef} />
</div>
</div>
);
}
Loading
Loading