From 7449da065089448da666c685dce764991d876a69 Mon Sep 17 00:00:00 2001 From: logelog <194732487+logelog@users.noreply.github.com> Date: Wed, 22 Jul 2026 01:01:48 +0200 Subject: [PATCH 1/4] feat(admin): allow trusted plugins to add editor panels --- .changeset/calm-cats-edit.md | 6 + .../creating-native-plugins/react-admin.mdx | 39 +++- .../admin/src/components/ContentEditor.tsx | 1 + .../src/components/ContentSettingsPanel.tsx | 47 ++++ .../SortableContentSettingsSections.tsx | 10 +- packages/admin/src/index.ts | 5 + .../admin/src/lib/content-editor-panels.tsx | 203 ++++++++++++++++++ .../admin/src/lib/content-settings-layout.ts | 20 +- packages/admin/src/lib/plugin-context.tsx | 5 +- .../components/ContentSettingsPanel.test.tsx | 116 +++++++++- .../tests/lib/content-editor-panels.test.tsx | 126 +++++++++++ .../tests/lib/content-settings-layout.test.ts | 14 ++ packages/core/src/virtual-modules.d.ts | 2 + 13 files changed, 575 insertions(+), 19 deletions(-) create mode 100644 .changeset/calm-cats-edit.md create mode 100644 packages/admin/src/lib/content-editor-panels.tsx create mode 100644 packages/admin/tests/lib/content-editor-panels.test.tsx diff --git a/.changeset/calm-cats-edit.md b/.changeset/calm-cats-edit.md new file mode 100644 index 0000000000..c444e6fdf3 --- /dev/null +++ b/.changeset/calm-cats-edit.md @@ -0,0 +1,6 @@ +--- +"emdash": minor +"@emdash-cms/admin": minor +--- + +Add a trusted plugin extension point for contributing isolated, host-framed panels to the saved content editor settings sidebar. Panels support collection and role filtering, deterministic ordering, manifest lifecycle checks, and render-error recovery. diff --git a/docs/src/content/docs/plugins/creating-native-plugins/react-admin.mdx b/docs/src/content/docs/plugins/creating-native-plugins/react-admin.mdx index a44c92c934..0a511245bf 100644 --- a/docs/src/content/docs/plugins/creating-native-plugins/react-admin.mdx +++ b/docs/src/content/docs/plugins/creating-native-plugins/react-admin.mdx @@ -224,9 +224,46 @@ export function SEOWidget() { Widgets wrap automatically based on screen width. +## Content editor panels + +A trusted React plugin can add host-framed sections to the settings sidebar for saved content entries. EmDash owns the section heading and placement, applies collection and role filters, and isolates rendering failures so one plugin panel cannot unmount the editor. + +Export a `contentEditorPanels` array from the plugin admin entry: + +```tsx title="src/admin.tsx" +import type { ContentEditorPanelContext } from "@emdash-cms/admin"; + +function ContentInsights({ entry, locale }: ContentEditorPanelContext) { + return ( +

+ Analysis for {entry.slug} in {locale ?? "the default locale"} +

+ ); +} + +export const contentEditorPanels = [ + { + id: "content-insights", + title: "Content insights", + component: ContentInsights, + collections: ["posts", "pages"], + minRole: 40, + order: 10, + }, +]; +``` + +Each panel receives the saved `entry`, its `collection`, and resolved `locale`. Panels are not mounted for new, unsaved entries. `collections` may be an array or a predicate and can be omitted to support every collection. Lower `order` values render first among contributed panels; ties are resolved deterministically by plugin and panel ID. + +Panel IDs must be unique within the plugin. Keep panel content responsive to the narrow settings sidebar and perform authorization in plugin API routes rather than relying on `minRole`, which only controls visibility. + + + ## Export structure -The admin entry point exports two objects: +The admin entry point exports the objects used by the features it implements: ```typescript title="src/admin.tsx" import { SettingsPage } from "./components/SettingsPage"; diff --git a/packages/admin/src/components/ContentEditor.tsx b/packages/admin/src/components/ContentEditor.tsx index 8607665641..dff368847d 100644 --- a/packages/admin/src/components/ContentEditor.tsx +++ b/packages/admin/src/components/ContentEditor.tsx @@ -847,6 +847,7 @@ export function ContentEditor({ collection={collection} item={item} isNew={isNew} + manifest={manifest} entryLocale={entryLocale} slug={slug} onSlugChange={handleSlugChange} diff --git a/packages/admin/src/components/ContentSettingsPanel.tsx b/packages/admin/src/components/ContentSettingsPanel.tsx index 9516fa7778..5195c5869b 100644 --- a/packages/admin/src/components/ContentSettingsPanel.tsx +++ b/packages/admin/src/components/ContentSettingsPanel.tsx @@ -25,8 +25,13 @@ import type { UserListItem, } from "../lib/api"; import { fetchBylines } from "../lib/api"; +import { + ContentEditorPanelBoundary, + resolveContentEditorPanels, +} from "../lib/content-editor-panels"; import { fromDatetimeLocalInputValue, toDatetimeLocalInputValue } from "../lib/datetime-local.js"; import { useDebouncedValue } from "../lib/hooks.js"; +import { usePluginAdmins } from "../lib/plugin-context"; import { cn, slugify } from "../lib/utils"; import type { CurrentUserInfo } from "./ContentEditor.js"; import { ContentStatusBadge, isContentStatusState } from "./ContentStatusBadge.js"; @@ -385,6 +390,19 @@ export const ContentSettingsPanel = React.memo(function ContentSettingsPanel({ }: ContentSettingsPanelProps) { const { t } = useLingui(); const navigate = useNavigate(); + const pluginAdmins = usePluginAdmins(); + const extensionPanels = React.useMemo( + () => + !isNew && item + ? resolveContentEditorPanels( + pluginAdmins, + collection, + currentUser?.role ?? 0, + manifest?.plugins, + ) + : [], + [collection, currentUser?.role, isNew, item, manifest?.plugins, pluginAdmins], + ); const [scheduleDate, setScheduleDate] = React.useState(""); const [showScheduler, setShowScheduler] = React.useState(false); @@ -680,6 +698,35 @@ export const ContentSettingsPanel = React.memo(function ContentSettingsPanel({ )} + {item && + extensionPanels.map(({ pluginId, extension }) => { + const Panel = extension.component; + const sectionId = `plugin:${pluginId}:${extension.id}`; + + return ( + +
+ + {extension.title} + + +
+ +
+
+
+
+ ); + })} + {portableTextEditor && (
diff --git a/packages/admin/src/components/SortableContentSettingsSections.tsx b/packages/admin/src/components/SortableContentSettingsSections.tsx index dabd3dc030..be65d543e3 100644 --- a/packages/admin/src/components/SortableContentSettingsSections.tsx +++ b/packages/admin/src/components/SortableContentSettingsSections.tsx @@ -90,7 +90,6 @@ export function SortableContentSettingsSections({ setStoredLayout(readStoredLayout(storageKey)); }, [storageKey]); - const layout = React.useMemo(() => resolveContentSettingsLayout(storedLayout), [storedLayout]); const sectionsById = React.useMemo(() => { const sections = React.Children.toArray(children).filter( (child): child is React.ReactElement => @@ -98,6 +97,11 @@ export function SortableContentSettingsSections({ ); return new Map(sections.map((section) => [section.props.id, section])); }, [children]); + const sectionIds = React.useMemo(() => Array.from(sectionsById.keys()), [sectionsById]); + const layout = React.useMemo( + () => resolveContentSettingsLayout(storedLayout, sectionIds), + [sectionIds, storedLayout], + ); const visibleIds = React.useMemo( () => layout.order.filter((id) => sectionsById.has(id)), [layout.order, sectionsById], @@ -127,7 +131,7 @@ export function SortableContentSettingsSections({ const overId = String(event.over.id) as ContentSettingsSectionId; setStoredLayout((current) => { const next = reorderContentSettingsLayout( - resolveContentSettingsLayout(current), + resolveContentSettingsLayout(current, sectionIds), movedId, overId, ); @@ -136,7 +140,7 @@ export function SortableContentSettingsSections({ }); } }, - [onSortingChange, storageKey], + [onSortingChange, sectionIds, storageKey], ); return ( diff --git a/packages/admin/src/index.ts b/packages/admin/src/index.ts index 08b943ba2a..2af80a5a52 100644 --- a/packages/admin/src/index.ts +++ b/packages/admin/src/index.ts @@ -13,6 +13,11 @@ export * from "./lib/api"; // Utilities export { cn } from "./lib/utils"; +export { + type ContentEditorPanelContext, + type ContentEditorPanelExtension, +} from "./lib/content-editor-panels"; + // Plugin admin context (for accessing plugin components) export { PluginAdminProvider, diff --git a/packages/admin/src/lib/content-editor-panels.tsx b/packages/admin/src/lib/content-editor-panels.tsx new file mode 100644 index 0000000000..8d1acebe65 --- /dev/null +++ b/packages/admin/src/lib/content-editor-panels.tsx @@ -0,0 +1,203 @@ +import { Trans } from "@lingui/react/macro"; +import * as React from "react"; + +import type { AdminManifest, ContentItem } from "./api"; + +export interface ContentEditorPanelContext { + collection: string; + entry: ContentItem; + locale?: string; +} + +export interface ContentEditorPanelExtension { + /** Stable identifier, unique within this plugin's editor panels. */ + id: string; + /** Host-rendered section heading. */ + title: string; + component: React.ComponentType; + /** Restrict this panel to selected collections. Omit to support every collection. */ + collections?: readonly string[] | ((collection: string) => boolean); + /** Minimum numeric admin role required to see this panel. */ + minRole?: number; + /** Lower values render first among contributed panels. */ + order?: number; +} + +export interface ResolvedContentEditorPanel { + pluginId: string; + extension: ContentEditorPanelExtension; +} + +type ContentEditorPanelRegistry = Record< + string, + { contentEditorPanels?: readonly ContentEditorPanelExtension[] } | undefined +>; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} + +function warn(pluginId: string, message: string): void { + console.warn(`[content-editor-panels] Plugin "${pluginId}": ${message}`); +} + +function isValidCollections(value: unknown): boolean { + return ( + value === undefined || + typeof value === "function" || + (Array.isArray(value) && value.every((collection) => typeof collection === "string")) + ); +} + +function isValidPanel(value: unknown, pluginId: string): value is ContentEditorPanelExtension { + if (!isRecord(value)) { + warn(pluginId, "ignored a panel that is not an object."); + return false; + } + if (typeof value.id !== "string" || value.id.trim() === "") { + warn(pluginId, "ignored a panel without a non-empty id."); + return false; + } + if (typeof value.title !== "string" || value.title.trim() === "") { + warn(pluginId, `ignored panel "${value.id}" because its title is invalid.`); + return false; + } + if (typeof value.component !== "function") { + warn(pluginId, `ignored panel "${value.id}" because its component is invalid.`); + return false; + } + if (!isValidCollections(value.collections)) { + warn( + pluginId, + `ignored panel "${value.id}" because collections must be an array of strings or a predicate.`, + ); + return false; + } + if (value.minRole !== undefined && !Number.isFinite(value.minRole)) { + warn(pluginId, `ignored panel "${value.id}" because minRole must be finite.`); + return false; + } + if (value.order !== undefined && !Number.isFinite(value.order)) { + warn(pluginId, `ignored panel "${value.id}" because order must be finite.`); + return false; + } + return true; +} + +function appliesToCollection( + pluginId: string, + panel: ContentEditorPanelExtension, + collection: string, +): boolean { + if (panel.collections === undefined) return true; + if (typeof panel.collections !== "function") { + return panel.collections.includes(collection); + } + + try { + return panel.collections(collection); + } catch (error) { + console.error( + `Plugin "${pluginId}" failed while checking content editor panel "${panel.id}".`, + error, + ); + return false; + } +} + +/** + * Select valid trusted-plugin panels for one saved content editor. + * Invalid, disabled, unauthorized, and inapplicable contributions are omitted + * so the host settings sidebar remains usable. + */ +export function resolveContentEditorPanels( + pluginAdmins: ContentEditorPanelRegistry, + collection: string, + userRole: number, + pluginStates?: AdminManifest["plugins"], +): ResolvedContentEditorPanel[] { + const resolved: ResolvedContentEditorPanel[] = []; + const seen = new Set(); + + for (const pluginId of Object.keys(pluginAdmins).toSorted()) { + const pluginState = pluginStates?.[pluginId]; + if (pluginStates && (!pluginState || pluginState.enabled === false)) continue; + + const panels: unknown = pluginAdmins[pluginId]?.contentEditorPanels; + if (panels === undefined) continue; + if (!Array.isArray(panels)) { + warn(pluginId, "ignored contentEditorPanels because it is not an array."); + continue; + } + + for (const candidate of panels) { + if (!isValidPanel(candidate, pluginId)) continue; + const identity = `${pluginId}:${candidate.id}`; + if (seen.has(identity)) { + warn(pluginId, `ignored duplicate panel id "${candidate.id}".`); + continue; + } + seen.add(identity); + + if (!appliesToCollection(pluginId, candidate, collection)) continue; + if (candidate.minRole !== undefined && userRole < candidate.minRole) continue; + resolved.push({ pluginId, extension: candidate }); + } + } + + return resolved.toSorted( + (a, b) => + (a.extension.order ?? 0) - (b.extension.order ?? 0) || + a.pluginId.localeCompare(b.pluginId) || + a.extension.id.localeCompare(b.extension.id), + ); +} + +interface ContentEditorPanelBoundaryProps { + pluginId: string; + panelId: string; + children: React.ReactNode; +} + +interface ContentEditorPanelBoundaryState { + hasError: boolean; +} + +/** Prevents one faulty trusted panel from unmounting the content editor. */ +export class ContentEditorPanelBoundary extends React.Component< + ContentEditorPanelBoundaryProps, + ContentEditorPanelBoundaryState +> { + override state: ContentEditorPanelBoundaryState = { hasError: false }; + + static getDerivedStateFromError(): ContentEditorPanelBoundaryState { + return { hasError: true }; + } + + override componentDidCatch(error: Error, info: React.ErrorInfo): void { + console.error( + `Plugin "${this.props.pluginId}" failed while rendering content editor panel "${this.props.panelId}".`, + error, + info, + ); + } + + override render(): React.ReactNode { + if (!this.state.hasError) return this.props.children; + + return ( +
+

+ Plugin panel unavailable. +

+ +
+ ); + } +} diff --git a/packages/admin/src/lib/content-settings-layout.ts b/packages/admin/src/lib/content-settings-layout.ts index ee42e4fd5c..58be005b29 100644 --- a/packages/admin/src/lib/content-settings-layout.ts +++ b/packages/admin/src/lib/content-settings-layout.ts @@ -11,23 +11,17 @@ export const DEFAULT_CONTENT_SETTINGS_SECTION_ORDER = [ "revisions", ] as const; -export type ContentSettingsSectionId = (typeof DEFAULT_CONTENT_SETTINGS_SECTION_ORDER)[number]; +export type ContentSettingsSectionId = string; export interface ContentSettingsLayout { version: typeof CONTENT_SETTINGS_LAYOUT_VERSION; order: ContentSettingsSectionId[]; } -const KNOWN_SECTION_IDS = new Set(DEFAULT_CONTENT_SETTINGS_SECTION_ORDER); - function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null; } -function isKnownSectionId(value: unknown): value is ContentSettingsSectionId { - return typeof value === "string" && KNOWN_SECTION_IDS.has(value); -} - /** Parse a browser preference without allowing malformed state to break the editor. */ export function parseContentSettingsLayout(raw: string | null): ContentSettingsLayout | null { if (!raw) return null; @@ -44,7 +38,7 @@ export function parseContentSettingsLayout(raw: string | null): ContentSettingsL return { version: CONTENT_SETTINGS_LAYOUT_VERSION, - order: value.order.filter(isKnownSectionId), + order: value.order.filter((id): id is ContentSettingsSectionId => typeof id === "string"), }; } catch { return null; @@ -52,20 +46,22 @@ export function parseContentSettingsLayout(raw: string | null): ContentSettingsL } /** - * Reconcile saved order with the current defaults. Duplicate and unknown ids - * disappear, while sections introduced by a later EmDash version append. + * Reconcile saved order with the sections available in the current editor. + * Duplicate and unavailable ids disappear, while newly registered sections append. */ export function resolveContentSettingsLayout( stored: ContentSettingsLayout | null, + availableSectionIds: readonly ContentSettingsSectionId[] = DEFAULT_CONTENT_SETTINGS_SECTION_ORDER, ): ContentSettingsLayout { const seen = new Set(); + const available = new Set(availableSectionIds); const order = (stored?.order ?? []).filter((id) => { - if (seen.has(id)) return false; + if (!available.has(id) || seen.has(id)) return false; seen.add(id); return true; }); - for (const id of DEFAULT_CONTENT_SETTINGS_SECTION_ORDER) { + for (const id of availableSectionIds) { if (seen.has(id)) continue; order.push(id); seen.add(id); diff --git a/packages/admin/src/lib/plugin-context.tsx b/packages/admin/src/lib/plugin-context.tsx index f07e807830..fb0e6e2a12 100644 --- a/packages/admin/src/lib/plugin-context.tsx +++ b/packages/admin/src/lib/plugin-context.tsx @@ -1,7 +1,7 @@ /** * Plugin Admin Context * - * Provides plugin admin modules (widgets, pages, fields) to the admin UI + * Provides plugin admin modules (widgets, pages, fields, editor panels) to the admin UI * via React context. This avoids cross-module registry issues by keeping * everything in React's component tree. */ @@ -9,11 +9,14 @@ import * as React from "react"; import { createContext, useContext } from "react"; +import type { ContentEditorPanelExtension } from "./content-editor-panels"; + /** Shape of a plugin's admin exports */ export interface PluginAdminModule { widgets?: Record; pages?: Record; fields?: Record; + contentEditorPanels?: readonly ContentEditorPanelExtension[]; } /** All plugin admin modules keyed by plugin ID */ diff --git a/packages/admin/tests/components/ContentSettingsPanel.test.tsx b/packages/admin/tests/components/ContentSettingsPanel.test.tsx index 24f9e95bbe..1532be8008 100644 --- a/packages/admin/tests/components/ContentSettingsPanel.test.tsx +++ b/packages/admin/tests/components/ContentSettingsPanel.test.tsx @@ -2,7 +2,7 @@ import { i18n } from "@lingui/core"; import { act, fireEvent } from "@testing-library/react"; import type { Editor } from "@tiptap/react"; import * as React from "react"; -import { describe, it, expect, vi, beforeEach } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { ContentEditorProps } from "../../src/components/ContentEditor"; import { @@ -12,7 +12,9 @@ import { type SettingsActionBarProps, } from "../../src/components/ContentSettingsPanel"; import type { BlockSidebarPanel } from "../../src/components/PortableTextEditor"; -import type { ContentItem } from "../../src/lib/api"; +import type { AdminManifest, ContentItem } from "../../src/lib/api"; +import type { ContentEditorPanelContext } from "../../src/lib/content-editor-panels"; +import { PluginAdminProvider, type PluginAdmins } from "../../src/lib/plugin-context"; import { render } from "../utils/render.tsx"; // Mock child components with their own data fetching so the panel tests @@ -79,6 +81,27 @@ const USERS = [ { id: "u1", name: "Editor One", email: "editor@example.com", role: 40 }, ] as ContentSettingsPanelProps["users"]; +const TEST_MANIFEST: AdminManifest = { + version: "0.30.0", + hash: "test", + collections: {}, + plugins: { insights: { enabled: true } }, +}; + +function InsightsPanel({ entry, locale }: ContentEditorPanelContext) { + return ( +
+ Insights for {entry.slug} ({locale}) +
+ ); +} + +function pluginWrapper(pluginAdmins: PluginAdmins) { + return function PluginWrapper({ children }: React.PropsWithChildren) { + return {children}; + }; +} + function makePanelProps( overrides: Partial = {}, ): ContentSettingsPanelProps { @@ -120,6 +143,9 @@ describe("ContentSettingsPanel", () => { beforeEach(() => { vi.clearAllMocks(); }); + afterEach(() => { + vi.restoreAllMocks(); + }); it("renders all eight sections when every capability is enabled", async () => { const screen = await render(); @@ -173,6 +199,92 @@ describe("ContentSettingsPanel", () => { expect(statusRow.querySelector("svg")).toBeNull(); }); + it("renders applicable trusted plugin panels in host-owned sections", async () => { + const pluginAdmins: PluginAdmins = { + insights: { + contentEditorPanels: [ + { + id: "summary", + title: "Content insights", + component: InsightsPanel, + collections: ["posts"], + }, + ], + }, + }; + const screen = await render( + , + { wrapper: pluginWrapper(pluginAdmins) }, + ); + + await expect + .element(screen.getByRole("heading", { name: "Content insights" })) + .toBeInTheDocument(); + await expect + .element(screen.getByTestId("insights-panel")) + .toHaveTextContent("Insights for my-post (en)"); + }); + + it("omits panels when their plugin is disabled or the entry is new", async () => { + const pluginAdmins: PluginAdmins = { + insights: { + contentEditorPanels: [ + { id: "summary", title: "Content insights", component: InsightsPanel }, + ], + }, + }; + const disabledManifest: AdminManifest = { + ...TEST_MANIFEST, + plugins: { insights: { enabled: false } }, + }; + const disabledScreen = await render( + , + { wrapper: pluginWrapper(pluginAdmins) }, + ); + expect(disabledScreen.container.querySelector('[data-testid="insights-panel"]')).toBeNull(); + + const newScreen = await render( + , + { wrapper: pluginWrapper(pluginAdmins) }, + ); + expect(newScreen.container.querySelector('[data-testid="insights-panel"]')).toBeNull(); + }); + + it("contains plugin panel render failures", async () => { + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => undefined); + function BrokenPanel(): React.ReactNode { + throw new Error("render failed"); + } + function HealthyPanel(): React.ReactNode { + return
Healthy panel
; + } + const pluginAdmins: PluginAdmins = { + insights: { + contentEditorPanels: [ + { id: "broken", title: "Broken insights", component: BrokenPanel }, + { id: "healthy", title: "Healthy insights", component: HealthyPanel }, + ], + }, + }; + const screen = await render( + , + { wrapper: pluginWrapper(pluginAdmins) }, + ); + + await expect.element(screen.getByRole("alert")).toHaveTextContent("Plugin panel unavailable."); + await expect.element(screen.getByRole("button", { name: "Retry" })).toBeInTheDocument(); + await expect.element(screen.getByTestId("healthy-panel")).toHaveTextContent("Healthy panel"); + await expect.element(screen.getByTestId("doc-outline")).toBeInTheDocument(); + expect(errorSpy).toHaveBeenCalled(); + }); + it("hides Ownership and Bylines for users below the editor role", async () => { const screen = await render( , diff --git a/packages/admin/tests/lib/content-editor-panels.test.tsx b/packages/admin/tests/lib/content-editor-panels.test.tsx new file mode 100644 index 0000000000..9ea35d7c50 --- /dev/null +++ b/packages/admin/tests/lib/content-editor-panels.test.tsx @@ -0,0 +1,126 @@ +import * as React from "react"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { + resolveContentEditorPanels, + type ContentEditorPanelContext, +} from "../../src/lib/content-editor-panels"; +import type { PluginAdmins } from "../../src/lib/plugin-context"; + +function Panel(_props: ContentEditorPanelContext) { + return
Panel
; +} + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe("resolveContentEditorPanels", () => { + it("selects enabled panels by collection and role", () => { + const pluginAdmins: PluginAdmins = { + insights: { + contentEditorPanels: [ + { id: "summary", title: "Summary", component: Panel, collections: ["posts"] }, + { id: "pages", title: "Pages", component: Panel, collections: ["pages"] }, + { id: "admin", title: "Admin", component: Panel, minRole: 50 }, + ], + }, + }; + + expect( + resolveContentEditorPanels(pluginAdmins, "posts", 40, { + insights: { enabled: true }, + }), + ).toEqual([ + expect.objectContaining({ + pluginId: "insights", + extension: expect.objectContaining({ id: "summary" }), + }), + ]); + }); + + it("ignores disabled and stale registry modules", () => { + const pluginAdmins: PluginAdmins = { + enabled: { + contentEditorPanels: [{ id: "kept", title: "Kept", component: Panel }], + }, + disabled: { + contentEditorPanels: [{ id: "disabled", title: "Disabled", component: Panel }], + }, + stale: { + contentEditorPanels: [{ id: "stale", title: "Stale", component: Panel }], + }, + }; + + expect( + resolveContentEditorPanels(pluginAdmins, "posts", 50, { + enabled: { enabled: true }, + disabled: { enabled: false }, + }), + ).toEqual([ + expect.objectContaining({ + pluginId: "enabled", + extension: expect.objectContaining({ id: "kept" }), + }), + ]); + }); + + it("orders panels deterministically and ignores duplicate ids per plugin", () => { + const warningSpy = vi.spyOn(console, "warn").mockImplementation(() => undefined); + const pluginAdmins: PluginAdmins = { + zeta: { + contentEditorPanels: [{ id: "later", title: "Later", component: Panel, order: 5 }], + }, + alpha: { + contentEditorPanels: [ + { id: "first", title: "First", component: Panel, order: -1 }, + { id: "first", title: "Duplicate", component: Panel, order: -2 }, + ], + }, + }; + + expect(resolveContentEditorPanels(pluginAdmins, "posts", 50)).toMatchObject([ + { pluginId: "alpha", extension: { id: "first" } }, + { pluginId: "zeta", extension: { id: "later" } }, + ]); + expect(warningSpy).toHaveBeenCalledOnce(); + }); + + it("contains collection predicate failures", () => { + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => undefined); + const pluginAdmins: PluginAdmins = { + broken: { + contentEditorPanels: [ + { + id: "broken", + title: "Broken", + component: Panel, + collections: () => { + throw new Error("predicate failed"); + }, + }, + ], + }, + }; + + expect(resolveContentEditorPanels(pluginAdmins, "posts", 50)).toEqual([]); + expect(errorSpy).toHaveBeenCalledOnce(); + }); + + it("ignores malformed panel exports without throwing", () => { + const warningSpy = vi.spyOn(console, "warn").mockImplementation(() => undefined); + const pluginAdmins = { + broken: { + contentEditorPanels: [ + { id: "", title: "Missing id", component: Panel }, + { id: "title", title: "", component: Panel }, + { id: "component", title: "Component" }, + { id: "order", title: "Order", component: Panel, order: Number.NaN }, + ], + }, + } as unknown as PluginAdmins; + + expect(resolveContentEditorPanels(pluginAdmins, "posts", 50)).toEqual([]); + expect(warningSpy).toHaveBeenCalledTimes(4); + }); +}); diff --git a/packages/admin/tests/lib/content-settings-layout.test.ts b/packages/admin/tests/lib/content-settings-layout.test.ts index 1b685b3ad0..83b7252801 100644 --- a/packages/admin/tests/lib/content-settings-layout.test.ts +++ b/packages/admin/tests/lib/content-settings-layout.test.ts @@ -36,4 +36,18 @@ describe("content settings layout", () => { expect(next.order.indexOf("seo")).toBe(next.order.indexOf("ownership") - 1); expect(layout.order).toEqual(DEFAULT_CONTENT_SETTINGS_SECTION_ORDER); }); + + it("preserves and reorders dynamically registered sections", () => { + const pluginSection = "plugin:example:insights"; + const available = [...DEFAULT_CONTENT_SETTINGS_SECTION_ORDER, pluginSection]; + const stored = parseContentSettingsLayout( + JSON.stringify({ version: 1, order: [pluginSection, "seo"] }), + ); + const layout = resolveContentSettingsLayout(stored, available); + + expect(layout.order.slice(0, 2)).toEqual([pluginSection, "seo"]); + + const next = reorderContentSettingsLayout(layout, pluginSection, "ownership"); + expect(next.order.indexOf(pluginSection)).toBe(next.order.indexOf("ownership") - 1); + }); }); diff --git a/packages/core/src/virtual-modules.d.ts b/packages/core/src/virtual-modules.d.ts index 9ce6519842..3041c2b326 100644 --- a/packages/core/src/virtual-modules.d.ts +++ b/packages/core/src/virtual-modules.d.ts @@ -189,6 +189,7 @@ declare module "virtual:emdash/admin-registry" { * - pages: Record * - widgets: Record * - fields: Record (field widget renderers) + * - contentEditorPanels: Trusted content editor sidebar panels */ export const pluginAdmins: Record< string, @@ -196,6 +197,7 @@ declare module "virtual:emdash/admin-registry" { pages?: Record; widgets?: Record; fields?: Record; + contentEditorPanels?: readonly unknown[]; } >; } From 5ad53b7513c3ae1ca19ee71ea31ced233c1f2d9a Mon Sep 17 00:00:00 2001 From: logelog <194732487+logelog@users.noreply.github.com> Date: Wed, 22 Jul 2026 01:54:25 +0200 Subject: [PATCH 2/4] fix(admin): use Kumo button in panel fallback --- packages/admin/src/lib/content-editor-panels.tsx | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/packages/admin/src/lib/content-editor-panels.tsx b/packages/admin/src/lib/content-editor-panels.tsx index 8d1acebe65..8b09c0e40b 100644 --- a/packages/admin/src/lib/content-editor-panels.tsx +++ b/packages/admin/src/lib/content-editor-panels.tsx @@ -1,3 +1,4 @@ +import { Button } from "@cloudflare/kumo"; import { Trans } from "@lingui/react/macro"; import * as React from "react"; @@ -190,13 +191,15 @@ export class ContentEditorPanelBoundary extends React.Component<

Plugin panel unavailable.

- +
); } From a1d877cacf2d58e174925e7adf443a5879f59740 Mon Sep 17 00:00:00 2001 From: logelog <194732487+logelog@users.noreply.github.com> Date: Wed, 22 Jul 2026 14:53:30 +0200 Subject: [PATCH 3/4] fix(admin): integrate plugin panels with sortable settings --- packages/admin/src/components/ContentSettingsPanel.tsx | 3 +++ .../src/components/SortableContentSettingsSections.tsx | 8 ++++---- packages/admin/tests/lib/content-settings-layout.test.ts | 2 +- 3 files changed, 8 insertions(+), 5 deletions(-) diff --git a/packages/admin/src/components/ContentSettingsPanel.tsx b/packages/admin/src/components/ContentSettingsPanel.tsx index 5195c5869b..4a2cf80795 100644 --- a/packages/admin/src/components/ContentSettingsPanel.tsx +++ b/packages/admin/src/components/ContentSettingsPanel.tsx @@ -17,6 +17,7 @@ import type { Editor } from "@tiptap/react"; import * as React from "react"; import type { + AdminManifest, BylineCreditInput, BylineSummary, ContentItem, @@ -295,6 +296,7 @@ export interface ContentSettingsPanelProps { collection: string; item?: ContentItem | null; isNew?: boolean; + manifest?: AdminManifest | null; /** Locale this entry is bound to (URL `?locale=` for new entries). */ entryLocale?: string | null; slug: string; @@ -351,6 +353,7 @@ export const ContentSettingsPanel = React.memo(function ContentSettingsPanel({ collection, item, isNew, + manifest, entryLocale, slug, onSlugChange, diff --git a/packages/admin/src/components/SortableContentSettingsSections.tsx b/packages/admin/src/components/SortableContentSettingsSections.tsx index be65d543e3..090c6c1636 100644 --- a/packages/admin/src/components/SortableContentSettingsSections.tsx +++ b/packages/admin/src/components/SortableContentSettingsSections.tsx @@ -97,7 +97,7 @@ export function SortableContentSettingsSections({ ); return new Map(sections.map((section) => [section.props.id, section])); }, [children]); - const sectionIds = React.useMemo(() => Array.from(sectionsById.keys()), [sectionsById]); + const sectionIds = React.useMemo(() => [...sectionsById.keys()], [sectionsById]); const layout = React.useMemo( () => resolveContentSettingsLayout(storedLayout, sectionIds), [sectionIds, storedLayout], @@ -112,7 +112,7 @@ export function SortableContentSettingsSections({ ); const handleDragStart = React.useCallback( (event: DragStartEvent) => { - setActiveId(String(event.active.id) as ContentSettingsSectionId); + setActiveId(String(event.active.id)); onSortingChange?.(true); }, [onSortingChange], @@ -127,8 +127,8 @@ export function SortableContentSettingsSections({ setActiveId(null); onSortingChange?.(false); if (event.over && event.active.id !== event.over.id) { - const movedId = String(event.active.id) as ContentSettingsSectionId; - const overId = String(event.over.id) as ContentSettingsSectionId; + const movedId = String(event.active.id); + const overId = String(event.over.id); setStoredLayout((current) => { const next = reorderContentSettingsLayout( resolveContentSettingsLayout(current, sectionIds), diff --git a/packages/admin/tests/lib/content-settings-layout.test.ts b/packages/admin/tests/lib/content-settings-layout.test.ts index 83b7252801..b10db6f291 100644 --- a/packages/admin/tests/lib/content-settings-layout.test.ts +++ b/packages/admin/tests/lib/content-settings-layout.test.ts @@ -48,6 +48,6 @@ describe("content settings layout", () => { expect(layout.order.slice(0, 2)).toEqual([pluginSection, "seo"]); const next = reorderContentSettingsLayout(layout, pluginSection, "ownership"); - expect(next.order.indexOf(pluginSection)).toBe(next.order.indexOf("ownership") - 1); + expect(next.order.indexOf(pluginSection)).toBe(next.order.indexOf("ownership") + 1); }); }); From a9614d9ba7e1415f914c242c09bdabf0dd832a11 Mon Sep 17 00:00:00 2001 From: logelog <194732487+logelog@users.noreply.github.com> Date: Mon, 3 Aug 2026 02:39:01 +0200 Subject: [PATCH 4/4] test(admin): cover plugin panel retry recovery --- .../components/ContentSettingsPanel.test.tsx | 30 +++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/packages/admin/tests/components/ContentSettingsPanel.test.tsx b/packages/admin/tests/components/ContentSettingsPanel.test.tsx index 1532be8008..d165022d03 100644 --- a/packages/admin/tests/components/ContentSettingsPanel.test.tsx +++ b/packages/admin/tests/components/ContentSettingsPanel.test.tsx @@ -285,6 +285,36 @@ describe("ContentSettingsPanel", () => { expect(errorSpy).toHaveBeenCalled(); }); + it("recovers a failed plugin panel when Retry is pressed", async () => { + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => undefined); + // Driven by the test rather than a render counter: React invokes the + // component more than once per mount, so a counter would recover on its + // own and never reach the boundary. + let shouldFail = true; + function FlakyPanel(): React.ReactNode { + if (shouldFail) throw new Error("panel failed"); + return
Recovered
; + } + const pluginAdmins: PluginAdmins = { + insights: { + contentEditorPanels: [{ id: "flaky", title: "Flaky insights", component: FlakyPanel }], + }, + }; + const screen = await render( + , + { wrapper: pluginWrapper(pluginAdmins) }, + ); + + await expect.element(screen.getByRole("alert")).toHaveTextContent("Plugin panel unavailable."); + + shouldFail = false; + await screen.getByRole("button", { name: "Retry" }).click(); + + await expect.element(screen.getByTestId("flaky-panel")).toBeInTheDocument(); + expect(screen.getByRole("alert").query()).toBeNull(); + expect(errorSpy).toHaveBeenCalled(); + }); + it("hides Ownership and Bylines for users below the editor role", async () => { const screen = await render( ,