Skip to content

Commit 961dfa0

Browse files
Refine admin editor settings panel actions (emdash-cms#1924)
* refactor(admin): allow routes to opt out of Shell main padding Routes can now declare staticData.fullBleed to drop the Shell <main>'s default p-6 padding and page scroll, managing their own scroll regions instead. No route opts in yet; all existing routes render unchanged. * refactor(admin): extract ContentSettingsPanel from ContentEditor Moves the editor sidebar — all eight sections (Publish, Ownership, Bylines, Translations, Taxonomies, SEO, Document Outline, Revisions) plus the block-panel swap — into ContentSettingsPanel.tsx, along with the sidebar-only BylineCreditsEditor and AuthorSelector components. The panel is memoized: ContentEditor re-renders on every keystroke (formData state), and the panel subtree is expensive. Handler props are made useCallback-stable at both layers (ContentEditor internals and the ContentEditPage/ContentNewPage inline arrows in router.tsx) so the memo actually holds. Verified: zero panel re-renders while typing and across a full autosave cycle. No visual or behavioral change. * feat(admin): structural 3-pane editor layout on desktop Both editor routes (/content/$collection/$id and /new) opt into the Shell's full-bleed mode. The editor form becomes a full-height flex row: a centered max-w-3xl editor column with its own scroll, and a 320px settings panel built on Kumo Sidebar (collapsible="none", contained) with independent scroll. The fields card chrome is gone — fields sit directly on the page. The panel aside stays inside the <form>, preserving native form association for its inputs and submit buttons. Kumo Sidebar's side prop is physical, so it's flipped explicitly for RTL — in Arabic the nav sits right and the panel left. Below lg the panel stacks under the fields (unchanged interim behavior; the sheet lands next). Distraction-free mode is unchanged: the panel stays mounted but hidden so panel-local state survives. Verified: nested Sidebar.Provider does not interfere with the nav sidebar's collapse; independent scroll regions; no layout jump from /new to the edit route; RTL mirror; distraction-free enter/exit. * feat(admin): move editor actions into a settings panel action bar Adds a two-tier action bar pinned above the settings panel body: autosave status and the primary action cluster (Preview, Save, Publish/Unpublish/Publish changes) on top; publish-state badges with Discard changes and Live View below. The status badges move here from the panel's Publish section, giving publish state a single home. The bar is deliberately not memoized — it absorbs the high-frequency props (isDirty, isSaving, isAutosaving) so they never bust the memoized panel body. The normal-mode header cluster and the bottom-of-form duplicate Save button are removed; the editor strip keeps only back/title/locale-badge and the distraction-free toggle. The distraction-free hover overlay keeps its own save/publish/exit controls since the panel is hidden in that mode. Verified end to end: publish, pending changes via manual save, discard from the bar's dialog, unpublish, autosave indicator, distraction-free overlay, and native form association of panel inputs. * feat(admin): settings panel visual hierarchy pass Flattens the settings panel into the structural aside: the rounded card chrome is gone, sections are divided by full-width hairlines, and every section heading drops to a shared muted label style (small, uppercase, subtle) — including the headings rendered inside TranslationsPanel (via a new optional headingClassName prop, since it's shared with other pages), TaxonomySidebar, DocumentOutline, and RevisionHistory, so the panel reads as one system. RevisionHistory also loses its own nested card. Publish keeps its prominence through position (first section, under the action bar) and content weight. Move to Trash moves out of the Publish section to an isolated section at the very bottom of the panel. Verified in light and dark themes and in Arabic (RTL mirror). No new user-facing strings. * feat(admin): settings sheet below lg breakpoint The editor's Sidebar.Provider now wraps the whole editor layout (collapsible="offcanvas", mobileBreakpoint=1024): at lg and above the panel renders as the contained desktop pane exactly as before; below lg Kumo renders it as an end-side slide-in sheet with a backdrop. A Settings button in the editor strip toggles the sheet, and the strip also keeps a Save button and autosave indicator below lg so saving never requires opening the sheet. The contained prop flips with the breakpoint — the desktop pane anchors inside the provider wrapper while the mobile sheet needs viewport-fixed positioning. Kumo's mobile sheet renders inline in the DOM (no portal), so panel inputs and submit buttons keep native form association with zero extra wiring — Save from inside the sheet verified end to end. Opening a portable-text block detail panel below lg auto-opens the sheet and restores its prior state on close (MobileBlockSidebarSync). The panel is unmounted (not hidden) in distraction-free mode since a mounted offcanvas pane would leave its layout gap behind. The test browser viewport moves to 1280x800: the default 414px width now renders the closed sheet, making panel controls unreachable for the tests that exercise them. Known deviation, needs manual QA in a visible browser: Kumo's sheet dismisses on focusout, so opening a confirm dialog from the sheet may close the sheet underneath (modal supersedes sheet); dialog state transitions verified headless, but Escape ordering and focus return depend on rAF-driven transitions that headless preview throttles. * fix(admin): widen settings panel and stop byline credit overflow The desktop settings pane grows from 20rem to 23rem; the mobile sheet stays at 20rem so it still fits small phone viewports. Byline credit cards no longer overflow the panel: the name block truncates, the action buttons wrap when tight, and the panel body clamps horizontal overflow so no section can poke past the panel edge. * patch: spec * test(admin): settings panel component tests + changeset Adds component tests for ContentSettingsPanel (section visibility per capability flags: role gating for Ownership/Bylines, hasSeo, supportsRevisions, portableTextEditor presence, i18n, new-item vs edit, the block detail panel swap, and trash-last ordering) and for SettingsActionBar (publish cluster states across draft / live / pending-changes / scheduled, new-item trimming, Live View link, and autosave indicator states). Adds the changeset for the content editor layout redesign. * fix(admin): editor action bar usability nits Three small intuitiveness fixes from review: - The Preview button gets its text label back ("Preview" / "Preview draft") instead of being icon-only. - Live View moves out of the action bar's context tier into the Publish section as a full-width row under the slug — the bar's second tier now holds just the status badges and Discard, so a busy post no longer crowds three controls into one strip. - Discard changes is available again inside distraction-free mode. The discard confirmation is extracted into a shared DiscardDraftDialog (used by both the action bar and the distraction-free overlay) so the copy and behavior cannot drift — caught by the pre-commit review. * fix(admin): clarify editor action ownership * fix(admin): restore editor publish panel layout * fix(admin): consistent editor panel headings and metadata footer Aligns every settings panel section heading (Ownership, Bylines, Taxonomies, SEO, Outline, Revisions) with the Publish heading style, and moves the Created/Updated timestamps into a separated metadata footer so they read as read-only info instead of publish controls. * fix(admin): stop SEO canonical URL field overflowing the settings panel Wraps the Canonical URL input like the Meta Description field so its long description text wraps within the sidebar instead of forcing horizontal overflow. * fix(admin): stop settings panel inheriting sidebar's nowrap The Kumo Sidebar wrapper sets whitespace-nowrap for its collapse animation, which inherited into the settings panel and stopped long SEO field descriptions (e.g. Canonical URL) from wrapping, overflowing the panel. Resets white-space on the panel root. Reverts the ineffective SeoPanel wrapper div. * fix(admin): drop empty taxonomy section in settings panel TaxonomySidebar returns null when no taxonomies apply to the collection, but the panel always wrapped it in a bordered p-4 block, leaving an empty section. Moves the section chrome into TaxonomySidebar so it renders nothing when empty. * fix(admin): put status label and badges on one line Renders the Status label inline with its badges instead of stacking them, and uses a badge for the no-drafts status for visual consistency. * fix(admin): add breathing room between status label and badges * refactor(admin): merge autosave status into the Save control The settings action bar, the below-lg editor header, and the distraction-free overlay each rendered a passive autosave "Saved" indicator next to a Save button that also read "Saved" when clean -- duplicating the status. Consolidate into the single SaveButton three-state control (Saved / Save / Saving...), fold autosave into its saving state, and remove AutosaveIndicator. * fix(admin): make saved button feedback transient Keep the merged Save control, but make Saved a brief completion confirmation instead of the clean resting state. The button now returns to disabled Save after the confirmation pulse, and new edits or saving progress interrupt the pulse immediately. * fix(admin): smooth Save button state transitions Keep the Kumo button as the control, but render icon and label as a stable overlaid content stack. Crossfade the Save, Saving, and Saved states with a short ease-out blur/scale transition, preserve reduced-motion behavior, and transition only button color properties instead of using transition-all. * fix(admin): stabilize Save button loading feedback Delay the visual Saving state to avoid spinner flashes on fast saves, keep it visible briefly once shown, and preserve the blur/scale transition between every visible SaveButton state. * fix(admin): keep byline actions inline Keep byline credit actions in the card header next to the byline identity by using a fixed two-column header layout and preventing the action group from wrapping below the name. * chore: keep editor sidebar plan local * feat(admin): redesign editor settings actions * fix(admin): color saved feedback as success * feat(admin): update RevisionHistory and DocumentOutline components * fix(admin): align editor sidebar controls * feat(admin): update components to use Text for headings and improve layout * feat(admin): animate editor sidebar accordions * fix(admin): stabilize editor settings interactions * fix(admin): scope save feedback to editor writes, keep panel mounted in distraction-free * fix(admin): keep block-panel sheet suspended in distraction-free, isolate auxiliary writes * fix(admin): consistent corner radius hierarchy in editor settings panel * fix(admin): align editor panel content to a consistent 16px gutter * fix(admin): drop double gap above the trash section * chore(admin): trim excessive comments in editor panel * fix(admin): serialize editor content operations * Revert "fix(admin): serialize editor content operations" This reverts commit 52977b9. * fix(admin): preserve editor operation semantics * fix(admin): preserve editor controls across layouts * refactor(admin): simplify editor save feedback * fix(admin): animate save button state changes * chore(admin): format save button changes * fix(e2e): update selectors for editor settings panel UI Align E2E assertions with the merged SaveButton live region, Save/Saved labels, and updated byline/revision class names. * chore(e2e): format content-types selector update
1 parent 2ef6577 commit 961dfa0

30 files changed

Lines changed: 3280 additions & 1187 deletions
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@emdash-cms/admin": minor
3+
---
4+
5+
Redesigns the content editor layout. The editor now fills the viewport with a distraction-lighter writing column, and all publish actions live in a structural settings panel on the end side: a single Save control transitions between Save, Saving, and Saved alongside Live View, Preview, and publish controls in an action bar pinned above the panel's sections. Publish-state badges now live in the Publish section with slug, scheduling, ownership, bylines, translations, taxonomies, SEO, outline, and revisions, with Move to Trash isolated at the bottom. Below the `lg` breakpoint the panel becomes a slide-in sheet behind a Settings button, while Save, preview/live-view access, and publish controls stay visible in the editor header. The layout mirrors correctly in RTL locales.

e2e/tests/admin-fixes.spec.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -331,8 +331,8 @@ test.describe("Autosave after perf optimizations", () => {
331331
// Autosave should succeed (200)
332332
expect(response.status()).toBe(200);
333333

334-
// The autosave indicator should show "Saved"
335-
await expect(page.getByRole("status", { name: "Autosave status" })).toContainText("Saved", {
334+
// SaveButton live region should settle on "Saved" after autosave completes
335+
await expect(page.getByRole("status").filter({ hasText: "Saved" }).first()).toBeVisible({
336336
timeout: 5000,
337337
});
338338

e2e/tests/bylines.spec.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -204,7 +204,7 @@ test.describe("Bylines", () => {
204204
// Move the secondary credit above the primary via its own row's "Up" button,
205205
// then confirm the reorder committed before saving.
206206
const secondaryCreditRow = bylinesSidebar
207-
.locator("div.rounded.border.p-2")
207+
.locator("div.rounded-lg.border.p-2")
208208
.filter({ hasText: secondaryName });
209209
await secondaryCreditRow.getByLabel("Role label").fill("Co-author");
210210
await secondaryCreditRow.getByRole("button", { name: "Up" }).click();

e2e/tests/content-types.spec.ts

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -117,22 +117,24 @@ test.describe("Content Types", () => {
117117
await admin.waitForLoading();
118118

119119
const toggleLabel = admin.page.locator("label", { hasText: "Enable comments" });
120-
const saveButton = admin.page.getByRole("button", { name: "Save Changes" });
120+
const savedButton = () => admin.page.getByRole("button", { name: "Saved" }).first();
121+
const saveButton = () =>
122+
admin.page.getByRole("button", { name: "Save", exact: true }).first();
121123

122124
// On initial load there are no unsaved changes
123-
await expect(saveButton).toBeDisabled();
125+
await expect(savedButton()).toBeDisabled();
124126

125127
// Flip the toggle -- Save should enable
126128
await toggleLabel.click();
127-
await expect(saveButton).toBeEnabled();
129+
await expect(saveButton()).toBeEnabled();
128130

129131
// Save: the PUT must return 200 and no failure toast should render
130132
const savePut = admin.page.waitForResponse(
131133
(res) =>
132134
res.url().includes("/api/schema/collections/posts") && res.request().method() === "PUT",
133135
{ timeout: 10000 },
134136
);
135-
await saveButton.click();
137+
await saveButton().click();
136138
expect((await savePut).status()).toBe(200);
137139
await expect(admin.page.getByText("Failed to save")).not.toBeVisible();
138140

@@ -141,24 +143,24 @@ test.describe("Content Types", () => {
141143
await admin.page.reload();
142144
await admin.waitForShell();
143145
await admin.waitForLoading();
144-
await expect(saveButton).toBeDisabled();
146+
await expect(savedButton()).toBeDisabled();
145147

146148
// Restore the original toggle state so the shared DB used by other E2E
147149
// tests (e.g. comments.spec.ts) isn't left with commentsEnabled flipped.
148150
await toggleLabel.click();
149-
await expect(saveButton).toBeEnabled();
151+
await expect(saveButton()).toBeEnabled();
150152
const restorePut = admin.page.waitForResponse(
151153
(res) =>
152154
res.url().includes("/api/schema/collections/posts") && res.request().method() === "PUT",
153155
{ timeout: 10000 },
154156
);
155-
await saveButton.click();
157+
await saveButton().click();
156158
expect((await restorePut).status()).toBe(200);
157159
await expect(admin.page.getByText("Failed to save")).not.toBeVisible();
158160
await admin.page.reload();
159161
await admin.waitForShell();
160162
await admin.waitForLoading();
161-
await expect(saveButton).toBeDisabled();
163+
await expect(savedButton()).toBeDisabled();
162164
});
163165
});
164166

e2e/tests/revisions.spec.ts

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -162,8 +162,8 @@ test.describe("Revisions", () => {
162162
const response = await autosavePut;
163163
expect(response.status()).toBe(200);
164164

165-
// Wait for autosave indicator
166-
await expect(page.getByRole("status", { name: "Autosave status" })).toContainText("Saved", {
165+
// Wait for SaveButton live region to settle on "Saved" after autosave
166+
await expect(page.getByRole("status").filter({ hasText: "Saved" }).first()).toBeVisible({
167167
timeout: 5000,
168168
});
169169

@@ -218,8 +218,8 @@ test.describe("Revisions", () => {
218218
timeout: 10000,
219219
});
220220

221-
// There should be multiple revision items (rounded-md border entries)
222-
const revisionItems = page.locator(".rounded-md.border.p-3");
221+
// There should be multiple revision items (rounded-lg border entries)
222+
const revisionItems = page.locator(".rounded-lg.border.p-3");
223223
const count = await revisionItems.count();
224224
expect(count).toBeGreaterThanOrEqual(2);
225225

@@ -283,7 +283,7 @@ test.describe("Revisions", () => {
283283
.catch(() => {});
284284

285285
// Wait for revision items to render
286-
const revisionItems = page.locator(".rounded-md.border.p-3");
286+
const revisionItems = page.locator(".rounded-lg.border.p-3");
287287
await expect(revisionItems.first()).toBeVisible({ timeout: 10000 });
288288

289289
// Find the restore button on the older revision (not the "Current" one).
@@ -297,7 +297,7 @@ test.describe("Revisions", () => {
297297
await restoreButton.click();
298298

299299
// ConfirmDialog should appear
300-
const confirmDialog = page.getByRole("dialog", { name: "Restore Revision" });
300+
const confirmDialog = page.getByRole("dialog", { name: /Restore Revision/ });
301301
await expect(confirmDialog).toBeVisible({ timeout: 5000 });
302302

303303
// Confirm the restore

0 commit comments

Comments
 (0)