From 4c7a539cc112227e821207e842e757948a831cc4 Mon Sep 17 00:00:00 2001 From: logelog <194732487+logelog@users.noreply.github.com> Date: Sat, 18 Jul 2026 09:02:13 +0200 Subject: [PATCH 1/6] feat(admin): make content settings sections reorderable --- .../content-settings-sortable-sections.md | 5 + .../src/components/ContentSettingsPanel.tsx | 384 +++++++++--------- .../SortableContentSettingsSections.tsx | 202 +++++++++ .../admin/src/components/TaxonomySidebar.tsx | 21 +- .../admin/src/lib/content-settings-layout.ts | 93 +++++ .../components/ContentSettingsPanel.test.tsx | 1 + .../SortableContentSettingsSections.test.tsx | 132 ++++++ .../tests/lib/content-settings-layout.test.ts | 39 ++ 8 files changed, 689 insertions(+), 188 deletions(-) create mode 100644 .changeset/content-settings-sortable-sections.md create mode 100644 packages/admin/src/components/SortableContentSettingsSections.tsx create mode 100644 packages/admin/src/lib/content-settings-layout.ts create mode 100644 packages/admin/tests/components/SortableContentSettingsSections.test.tsx create mode 100644 packages/admin/tests/lib/content-settings-layout.test.ts diff --git a/.changeset/content-settings-sortable-sections.md b/.changeset/content-settings-sortable-sections.md new file mode 100644 index 0000000000..e2a30e1fd2 --- /dev/null +++ b/.changeset/content-settings-sortable-sections.md @@ -0,0 +1,5 @@ +--- +"@emdash-cms/admin": minor +--- + +Allows editors to reorder the built-in content settings sections with accessible drag handles and stores the preferred order per user and collection in the browser. diff --git a/packages/admin/src/components/ContentSettingsPanel.tsx b/packages/admin/src/components/ContentSettingsPanel.tsx index 08a8cd8903..e502a342f1 100644 --- a/packages/admin/src/components/ContentSettingsPanel.tsx +++ b/packages/admin/src/components/ContentSettingsPanel.tsx @@ -38,7 +38,11 @@ import { RevisionHistory } from "./RevisionHistory"; import { RouterLinkButton } from "./RouterLinkButton.js"; import { SaveButton } from "./SaveButton"; import { SeoPanel } from "./SeoPanel"; -import { TaxonomySidebar } from "./TaxonomySidebar"; +import { + SortableContentSettingsSection, + SortableContentSettingsSections, +} from "./SortableContentSettingsSections.js"; +import { TaxonomySidebar, useHasApplicableTaxonomies } from "./TaxonomySidebar"; import { TranslationsPanel } from "./TranslationsPanel.js"; // Editor role level (40) from @emdash-cms/auth @@ -379,6 +383,7 @@ export const ContentSettingsPanel = React.memo(function ContentSettingsPanel({ const [scheduleDate, setScheduleDate] = React.useState(""); const [showScheduler, setShowScheduler] = React.useState(false); const showDiscard = !isNew && supportsDrafts && hasPendingChanges && !!onDiscardDraft; + const hasApplicableTaxonomies = useHasApplicableTaxonomies(collection); const handleScheduleSubmit = () => { if (scheduleDate && onSchedule) { @@ -421,206 +426,223 @@ export const ContentSettingsPanel = React.memo(function ContentSettingsPanel({ // The Kumo Sidebar wrapper sets `whitespace-nowrap` for its collapse // animation, which would stop long field descriptions from wrapping.
-
- - {t`Publish`} - -
- onSlugChange(e.target.value)} - placeholder="my-post-slug" - /> -
-
- - {supportsDrafts ? ( - <> - {isLive && {t`Published`}} - {hasPendingChanges && {t`Pending changes`}} - {!isLive && !hasSchedule && {t`Draft`}} - {hasSchedule && {t`Scheduled`}} - - ) : ( - - {status.charAt(0).toUpperCase() + status.slice(1)} - - )} -
- {showDiscard && ( -
- + + +
+ + {t`Publish`} + +
+ onSlugChange(e.target.value)} + placeholder="my-post-slug" + /> +
+
+ + {supportsDrafts ? ( + <> + {isLive && {t`Published`}} + {hasPendingChanges && {t`Pending changes`}} + {!isLive && !hasSchedule && {t`Draft`}} + {hasSchedule && {t`Scheduled`}} + + ) : ( + + {status.charAt(0).toUpperCase() + status.slice(1)} + + )} +
+ {showDiscard && ( +
+ +
+ )}
- )} -
- {item?.scheduledAt && ( -
-

{t`Scheduled for: ${formatScheduledDate(item.scheduledAt)}`}

- -
- )} + {item?.scheduledAt && ( +
+

{t`Scheduled for: ${formatScheduledDate(item.scheduledAt)}`}

+ +
+ )} - {canSchedule && ( -
- {showScheduler ? ( -
- setScheduleDate(e.target.value)} - min={new Date().toISOString().slice(0, 16)} - /> -
- + {canSchedule && ( +
+ {showScheduler ? ( +
+ setScheduleDate(e.target.value)} + min={new Date().toISOString().slice(0, 16)} + /> +
+ + +
+
+ ) : ( -
+ )}
- ) : ( - )}
- )} -
- {item && ( -
-
-
{t`Created`}
-
{new Date(item.createdAt).toLocaleString()}
-
-
-
{t`Updated`}
-
{new Date(item.updatedAt).toLocaleString()}
+ {item && ( +
+
+
{t`Created`}
+
{new Date(item.createdAt).toLocaleString()}
+
+
+
{t`Updated`}
+
{new Date(item.updatedAt).toLocaleString()}
+
+
+ )} +
+ + + {currentUser && currentUser.role >= ROLE_EDITOR && users && users.length > 0 && ( + +
+ + {t`Ownership`} + +
-
+ )} -
- {currentUser && currentUser.role >= ROLE_EDITOR && users && users.length > 0 && ( -
- - {t`Ownership`} - - -
- )} - - {currentUser && currentUser.role >= ROLE_EDITOR && ( -
- - {t`Bylines`} - - entry.byline)} - bylinesLoaded={availableBylinesLoaded} - onChange={onBylinesChange} - onQuickCreate={onQuickCreateByline} - onQuickEdit={onQuickEditByline} - // Existing entry: use its own locale. New entry: use the - // URL `?locale=` (passed in via `entryLocale`). - entryLocale={item?.locale ?? entryLocale} - i18n={i18n} - /> -
- )} + {currentUser && currentUser.role >= ROLE_EDITOR && ( + +
+ + {t`Bylines`} + + entry.byline)} + bylinesLoaded={availableBylinesLoaded} + onChange={onBylinesChange} + onQuickCreate={onQuickCreateByline} + onQuickEdit={onQuickEditByline} + // Existing entry: use its own locale. New entry: use the + // URL `?locale=` (passed in via `entryLocale`). + entryLocale={item?.locale ?? entryLocale} + i18n={i18n} + /> +
+
+ )} - {i18n && item && !isNew && ( -
- - navigate({ - to: "/content/$collection/$id", - params: { collection, id: tr.id }, - search: { locale: tr.locale }, - }) - } - onCreate={onTranslate} - /> -
- )} + {i18n && item && !isNew && ( + +
+ + navigate({ + to: "/content/$collection/$id", + params: { collection, id: tr.id }, + search: { locale: tr.locale }, + }) + } + onCreate={onTranslate} + /> +
+
+ )} - {/* Taxonomy selector — renders nothing (no chrome) when no taxonomies - apply to this collection, so it owns its own section border. */} - {item && ( - - )} + {/* Do not register an empty sortable row when this collection has no taxonomies. */} + {item && hasApplicableTaxonomies && ( + + + + )} - {hasSeo && !isNew && onSeoChange && ( -
- - {t`SEO`} - - -
- )} + {hasSeo && !isNew && onSeoChange && ( + +
+ + {t`SEO`} + + +
+
+ )} - {portableTextEditor && ( -
- -
- )} + {portableTextEditor && ( + +
+ +
+
+ )} - {!isNew && item && supportsRevisions && ( -
- -
- )} + {!isNew && item && supportsRevisions && ( + +
+ +
+
+ )} +
{!isNew && onDelete && (
diff --git a/packages/admin/src/components/SortableContentSettingsSections.tsx b/packages/admin/src/components/SortableContentSettingsSections.tsx new file mode 100644 index 0000000000..164e4b00b7 --- /dev/null +++ b/packages/admin/src/components/SortableContentSettingsSections.tsx @@ -0,0 +1,202 @@ +import { + closestCenter, + DndContext, + type DragEndEvent, + type DragStartEvent, + KeyboardSensor, + MeasuringStrategy, + PointerSensor, + useSensor, + useSensors, +} from "@dnd-kit/core"; +import { + sortableKeyboardCoordinates, + SortableContext, + useSortable, + verticalListSortingStrategy, +} from "@dnd-kit/sortable"; +import { CSS } from "@dnd-kit/utilities"; +import { useLingui } from "@lingui/react/macro"; +import { DotsSixVertical } from "@phosphor-icons/react"; +import * as React from "react"; + +import { + parseContentSettingsLayout, + reorderContentSettingsLayout, + resolveContentSettingsLayout, + type ContentSettingsLayout, + type ContentSettingsSectionId, +} from "../lib/content-settings-layout.js"; +import { cn } from "../lib/utils.js"; + +const STORAGE_PREFIX = "emdash:content-settings-layout:v1"; + +export interface SortableContentSettingsSectionProps { + id: ContentSettingsSectionId; + label: string; + /** Leaves room for an existing disclosure chevron at the inline end. */ + disclosure?: boolean; + children: React.ReactNode; + /** Internal state supplied by the sortable group while any section is moving. */ + isSorting?: boolean; +} + +interface SortableContentSettingsSectionsProps { + collection: string; + userId?: string; + children: React.ReactNode; +} + +function readStoredLayout(storageKey: string | null): ContentSettingsLayout | null { + if (!storageKey || typeof window === "undefined") return null; + try { + return parseContentSettingsLayout(window.localStorage.getItem(storageKey)); + } catch { + return null; + } +} + +function writeStoredLayout(storageKey: string | null, layout: ContentSettingsLayout): void { + if (!storageKey || typeof window === "undefined") return; + try { + window.localStorage.setItem(storageKey, JSON.stringify(layout)); + } catch { + // Browser storage is optional; the reordered in-memory layout still works. + } +} + +export function SortableContentSettingsSections({ + collection, + userId, + children, +}: SortableContentSettingsSectionsProps) { + const storageKey = userId + ? `${STORAGE_PREFIX}:${encodeURIComponent(userId)}:${encodeURIComponent(collection)}` + : null; + // Keep the server and first client render identical. Browser preferences + // are restored after hydration so a saved order cannot cause a mismatch. + const [storedLayout, setStoredLayout] = React.useState(null); + const [activeId, setActiveId] = React.useState(null); + + React.useEffect(() => { + 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 => + React.isValidElement(child), + ); + return new Map(sections.map((section) => [section.props.id, section])); + }, [children]); + const visibleIds = React.useMemo( + () => layout.order.filter((id) => sectionsById.has(id)), + [layout.order, sectionsById], + ); + const sensors = useSensors( + useSensor(PointerSensor, { activationConstraint: { distance: 6 } }), + useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }), + ); + const handleDragStart = React.useCallback((event: DragStartEvent) => { + setActiveId(String(event.active.id) as ContentSettingsSectionId); + }, []); + + const handleDragEnd = React.useCallback( + (event: DragEndEvent) => { + setActiveId(null); + 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; + setStoredLayout((current) => { + const next = reorderContentSettingsLayout( + resolveContentSettingsLayout(current), + movedId, + overId, + ); + writeStoredLayout(storageKey, next); + return next; + }); + } + }, + [storageKey], + ); + + return ( + setActiveId(null)} + onDragEnd={handleDragEnd} + > + + {visibleIds.map((id) => { + const section = sectionsById.get(id); + return section + ? React.cloneElement(section, { key: id, isSorting: activeId !== null }) + : null; + })} + + + ); +} + +export function SortableContentSettingsSection({ + id, + label, + disclosure = false, + children, + isSorting = false, +}: SortableContentSettingsSectionProps) { + const { t } = useLingui(); + const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ + id, + }); + const style: React.CSSProperties = { + transform: CSS.Transform.toString(transform), + transition, + zIndex: isDragging ? 10 : undefined, + }; + + return ( +
*:not([data-sortable-heading]):not([data-sortable-handle])]:hidden", + isDragging && "bg-kumo-tint opacity-60", + )} + > + {isSorting && ( +
+ {label} +
+ )} + {children} + +
+ ); +} diff --git a/packages/admin/src/components/TaxonomySidebar.tsx b/packages/admin/src/components/TaxonomySidebar.tsx index 6977bb1992..03a2773d9e 100644 --- a/packages/admin/src/components/TaxonomySidebar.tsx +++ b/packages/admin/src/components/TaxonomySidebar.tsx @@ -63,6 +63,19 @@ async function fetchTaxonomyDefs(): Promise { return data.taxonomies; } +function useApplicableTaxonomies(collection: string): TaxonomyDef[] { + const { data: taxonomies = [] } = useQuery({ + queryKey: ["taxonomy-defs"], + queryFn: fetchTaxonomyDefs, + }); + return taxonomies.filter((taxonomy) => taxonomy.collections.includes(collection)); +} + +/** Whether the editor should include a taxonomy settings section. */ +export function useHasApplicableTaxonomies(collection: string): boolean { + return useApplicableTaxonomies(collection).length > 0; +} + /** * Fetch terms for a taxonomy, scoped to the entry's locale so only the matching * translation variants are offered. @@ -524,13 +537,7 @@ export function TaxonomySidebar({ className, }: TaxonomySidebarProps) { const { t } = useLingui(); - const { data: taxonomies = [] } = useQuery({ - queryKey: ["taxonomy-defs"], - queryFn: fetchTaxonomyDefs, - }); - - // Filter to taxonomies that apply to this collection - const applicableTaxonomies = taxonomies.filter((tax) => tax.collections.includes(collection)); + const applicableTaxonomies = useApplicableTaxonomies(collection); if (applicableTaxonomies.length === 0) { return null; diff --git a/packages/admin/src/lib/content-settings-layout.ts b/packages/admin/src/lib/content-settings-layout.ts new file mode 100644 index 0000000000..ee42e4fd5c --- /dev/null +++ b/packages/admin/src/lib/content-settings-layout.ts @@ -0,0 +1,93 @@ +export const CONTENT_SETTINGS_LAYOUT_VERSION = 1 as const; + +export const DEFAULT_CONTENT_SETTINGS_SECTION_ORDER = [ + "publish", + "ownership", + "bylines", + "translations", + "taxonomies", + "seo", + "outline", + "revisions", +] as const; + +export type ContentSettingsSectionId = (typeof DEFAULT_CONTENT_SETTINGS_SECTION_ORDER)[number]; + +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; + + try { + const value: unknown = JSON.parse(raw); + if ( + !isRecord(value) || + value.version !== CONTENT_SETTINGS_LAYOUT_VERSION || + !Array.isArray(value.order) + ) { + return null; + } + + return { + version: CONTENT_SETTINGS_LAYOUT_VERSION, + order: value.order.filter(isKnownSectionId), + }; + } catch { + return null; + } +} + +/** + * Reconcile saved order with the current defaults. Duplicate and unknown ids + * disappear, while sections introduced by a later EmDash version append. + */ +export function resolveContentSettingsLayout( + stored: ContentSettingsLayout | null, +): ContentSettingsLayout { + const seen = new Set(); + const order = (stored?.order ?? []).filter((id) => { + if (seen.has(id)) return false; + seen.add(id); + return true; + }); + + for (const id of DEFAULT_CONTENT_SETTINGS_SECTION_ORDER) { + if (seen.has(id)) continue; + order.push(id); + seen.add(id); + } + + return { version: CONTENT_SETTINGS_LAYOUT_VERSION, order }; +} + +export function reorderContentSettingsLayout( + layout: ContentSettingsLayout, + activeId: ContentSettingsSectionId, + overId: ContentSettingsSectionId, +): ContentSettingsLayout { + if (activeId === overId) return layout; + + const from = layout.order.indexOf(activeId); + const to = layout.order.indexOf(overId); + if (from < 0 || to < 0) return layout; + + const order = [...layout.order]; + const [moved] = order.splice(from, 1); + if (!moved) return layout; + order.splice(to, 0, moved); + return { ...layout, order }; +} diff --git a/packages/admin/tests/components/ContentSettingsPanel.test.tsx b/packages/admin/tests/components/ContentSettingsPanel.test.tsx index 6dcb402cfe..e092ee70fa 100644 --- a/packages/admin/tests/components/ContentSettingsPanel.test.tsx +++ b/packages/admin/tests/components/ContentSettingsPanel.test.tsx @@ -21,6 +21,7 @@ vi.mock("../../src/components/RevisionHistory", () => ({ vi.mock("../../src/components/TaxonomySidebar", () => ({ TaxonomySidebar: () =>
Taxonomy
, + useHasApplicableTaxonomies: () => true, })); vi.mock("../../src/components/editor/DocumentOutline", () => ({ diff --git a/packages/admin/tests/components/SortableContentSettingsSections.test.tsx b/packages/admin/tests/components/SortableContentSettingsSections.test.tsx new file mode 100644 index 0000000000..29c26d3920 --- /dev/null +++ b/packages/admin/tests/components/SortableContentSettingsSections.test.tsx @@ -0,0 +1,132 @@ +import { i18n } from "@lingui/core"; +import { I18nProvider } from "@lingui/react"; +import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import * as React from "react"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import { + SortableContentSettingsSection, + SortableContentSettingsSections, +} from "../../src/components/SortableContentSettingsSections"; + +const STORAGE_KEY = "emdash:content-settings-layout:v1:user-1:posts"; + +function TestSections() { + return ( + + +
Publish
+
+ +
SEO
+
+
+ ); +} + +function renderSections() { + return render( + + + , + ); +} + +async function pressKey(target: Document | HTMLElement, key: string, code: string) { + await act(async () => { + fireEvent.keyDown(target, { key, code }); + // dnd-kit schedules keyboard sensor listeners and state updates on the next task. + await new Promise((resolve) => setTimeout(resolve, 0)); + }); +} + +describe("SortableContentSettingsSections", () => { + beforeEach(() => { + i18n.load("en", {}); + i18n.activate("en"); + window.localStorage.clear(); + }); + afterEach(cleanup); + + it("restores a saved section order and exposes accessible drag handles", () => { + window.localStorage.setItem( + STORAGE_KEY, + JSON.stringify({ version: 1, order: ["seo", "publish"] }), + ); + + const { container } = renderSections(); + const visibleSections = [...container.querySelectorAll("section")]; + + expect(visibleSections.map((section) => section.textContent)).toEqual(["SEO", "Publish"]); + expect(screen.getByRole("button", { name: "Drag to reorder SEO" })).toBeTruthy(); + expect(screen.getByRole("button", { name: "Drag to reorder Publish" })).toBeTruthy(); + }); + + it("falls back to the default order when browser state is malformed", () => { + window.localStorage.setItem(STORAGE_KEY, "not-json"); + + const { container } = renderSections(); + const visibleSections = [...container.querySelectorAll("section")]; + + expect(visibleSections.map((section) => section.textContent)).toEqual(["Publish", "SEO"]); + }); + + it("collapses every section to its heading while keyboard sorting is active", async () => { + const { container } = renderSections(); + const handle = screen.getByRole("button", { name: "Drag to reorder Publish" }); + + handle.focus(); + await pressKey(handle, " ", "Space"); + + const sections = [...container.querySelectorAll("section")]; + expect(sections.every((section) => section.dataset.sorting === "true")).toBe(true); + expect(screen.getByTestId("publish-section").parentElement).toBe(sections[0]); + expect(screen.getByTestId("seo-section").parentElement).toBe(sections[1]); + for (const section of sections) { + const heading = section.querySelector("[data-sortable-heading]"); + const handle = section.querySelector("[data-sortable-handle]"); + + expect(heading?.style.minHeight).toBe("48px"); + expect(section.className).toContain( + "[&>*:not([data-sortable-heading]):not([data-sortable-handle])]:hidden", + ); + expect(handle?.classList.contains("top-1/2")).toBe(true); + expect(handle?.classList.contains("-translate-y-1/2")).toBe(true); + } + + await pressKey(document, "Escape", "Escape"); + + await waitFor(() => { + expect(sections.every((section) => section.dataset.sorting === "false")).toBe(true); + }); + expect(container.querySelector("[data-sortable-heading]")).toBeNull(); + expect(sections[0]?.className).not.toContain( + "[&>*:not([data-sortable-heading]):not([data-sortable-handle])]:hidden", + ); + expect(screen.getByTestId("publish-section").parentElement).toBe(sections[0]); + expect(screen.getByTestId("seo-section").parentElement).toBe(sections[1]); + }); + + it("persists a keyboard reorder and restores expanded section content", async () => { + const { container } = renderSections(); + const handle = screen.getByRole("button", { name: "Drag to reorder Publish" }); + + handle.focus(); + await pressKey(handle, " ", "Space"); + await pressKey(document, "ArrowDown", "ArrowDown"); + await pressKey(document, " ", "Space"); + + await waitFor(() => { + const sections = [...container.querySelectorAll("section")]; + expect(sections.map((section) => section.textContent)).toEqual(["SEO", "Publish"]); + expect(sections.every((section) => section.dataset.sorting === "false")).toBe(true); + }); + + const saved = JSON.parse(window.localStorage.getItem(STORAGE_KEY) ?? "null") as { + order: string[]; + }; + expect(saved.order.indexOf("seo")).toBeLessThan(saved.order.indexOf("publish")); + expect(screen.getByTestId("publish-section").parentElement?.tagName).toBe("SECTION"); + expect(screen.getByTestId("seo-section").parentElement?.tagName).toBe("SECTION"); + }); +}); diff --git a/packages/admin/tests/lib/content-settings-layout.test.ts b/packages/admin/tests/lib/content-settings-layout.test.ts new file mode 100644 index 0000000000..1b685b3ad0 --- /dev/null +++ b/packages/admin/tests/lib/content-settings-layout.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from "vitest"; + +import { + DEFAULT_CONTENT_SETTINGS_SECTION_ORDER, + parseContentSettingsLayout, + reorderContentSettingsLayout, + resolveContentSettingsLayout, +} from "../../src/lib/content-settings-layout"; + +describe("content settings layout", () => { + it("ignores malformed browser state", () => { + expect(parseContentSettingsLayout(null)).toBeNull(); + expect(parseContentSettingsLayout("not-json")).toBeNull(); + expect(parseContentSettingsLayout('{"version":2,"order":[]}')).toBeNull(); + }); + + it("removes unknown and duplicate ids, then appends missing defaults", () => { + const stored = parseContentSettingsLayout( + JSON.stringify({ + version: 1, + order: ["seo", "unknown", "ownership", "seo"], + }), + ); + + expect(resolveContentSettingsLayout(stored).order).toEqual([ + "seo", + "ownership", + ...DEFAULT_CONTENT_SETTINGS_SECTION_ORDER.filter((id) => id !== "seo" && id !== "ownership"), + ]); + }); + + it("moves a section relative to another section", () => { + const layout = resolveContentSettingsLayout(null); + const next = reorderContentSettingsLayout(layout, "seo", "ownership"); + + expect(next.order.indexOf("seo")).toBe(next.order.indexOf("ownership") - 1); + expect(layout.order).toEqual(DEFAULT_CONTENT_SETTINGS_SECTION_ORDER); + }); +}); From 891eb98c3c5813c6e79945eeeb900f07348d9524 Mon Sep 17 00:00:00 2001 From: logelog <194732487+logelog@users.noreply.github.com> Date: Sat, 18 Jul 2026 22:53:27 +0200 Subject: [PATCH 2/6] fix(admin): constrain settings panel dragging --- .../src/components/ContentSettingsPanel.tsx | 4 ++-- .../admin/src/components/RevisionHistory.tsx | 11 ++++++++-- .../SortableContentSettingsSections.tsx | 16 +++++++++++---- .../src/components/editor/DocumentOutline.tsx | 10 ++++++++-- .../SortableContentSettingsSections.test.tsx | 20 +++++++++++++++++++ 5 files changed, 51 insertions(+), 10 deletions(-) diff --git a/packages/admin/src/components/ContentSettingsPanel.tsx b/packages/admin/src/components/ContentSettingsPanel.tsx index e502a342f1..a32556ed66 100644 --- a/packages/admin/src/components/ContentSettingsPanel.tsx +++ b/packages/admin/src/components/ContentSettingsPanel.tsx @@ -630,7 +630,7 @@ export const ContentSettingsPanel = React.memo(function ContentSettingsPanel({ {portableTextEditor && (
- +
)} @@ -638,7 +638,7 @@ export const ContentSettingsPanel = React.memo(function ContentSettingsPanel({ {!isNew && item && supportsRevisions && (
- +
)} diff --git a/packages/admin/src/components/RevisionHistory.tsx b/packages/admin/src/components/RevisionHistory.tsx index 56eda5798a..c3db95d6fe 100644 --- a/packages/admin/src/components/RevisionHistory.tsx +++ b/packages/admin/src/components/RevisionHistory.tsx @@ -71,6 +71,8 @@ interface RevisionHistoryProps { entryId: string; /** Called when a revision is successfully restored */ onRestored?: () => void; + /** Reserve the inline end of the disclosure header for an external control. */ + reserveHeaderEnd?: boolean; } /** @@ -91,7 +93,12 @@ function formatFullDate(dateString: string): string { * RevisionHistory component - displays revision history for a content item * with ability to restore previous versions. */ -export function RevisionHistory({ collection, entryId, onRestored }: RevisionHistoryProps) { +export function RevisionHistory({ + collection, + entryId, + onRestored, + reserveHeaderEnd = false, +}: RevisionHistoryProps) { const { t } = useLingui(); const [isExpanded, setIsExpanded] = React.useState(false); const [selectedRevision, setSelectedRevision] = React.useState(null); @@ -148,7 +155,7 @@ export function RevisionHistory({ collection, entryId, onRestored }: RevisionHis