-
Notifications
You must be signed in to change notification settings - Fork 658
Improve json field admin rendering #299
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
48b8b92
4df8c59
1a30685
d5d68c9
762a052
9a1b920
79ea46e
81b8398
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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. |
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -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; | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
|
|
@@ -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 })) ?? | ||||||||||||||||||||||
| [], | ||||||||||||||||||||||
|
|
@@ -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
|
||||||||||||||||||||||
| setSlug(item.slug || ""); | ||||||||||||||||||||||
| setSlugTouched(!!item.slug); | ||||||||||||||||||||||
| setStatus(item.status); | ||||||||||||||||||||||
|
|
@@ -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
|
||||||||||||||||||||||
| 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; |
There was a problem hiding this comment.
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.
| 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> | ||
| ); | ||
| } |
There was a problem hiding this comment.
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.jsonbecomes inconsistent starting at thedependencieskey (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.There was a problem hiding this comment.
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).