From d426302a1b49d6e738a767ae97bdd59d9eba0412 Mon Sep 17 00:00:00 2001 From: Alice Alexandra Moore <86723305+3mdistal@users.noreply.github.com> Date: Thu, 30 Jul 2026 09:47:54 -0400 Subject: [PATCH] fix(content): keep sidebar interactive after deleting pages --- .../components/editor/database/sidebar.tsx | 35 +- .../sidebar/DocumentSidebar.layout.test.ts | 17 + .../components/sidebar/DocumentSidebar.tsx | 80 +++- .../components/sidebar/DocumentTreeItem.tsx | 215 +---------- ...nger-leaves-the-content-sidebar-unrespo.md | 6 + templates/content/e2e/playwright.config.ts | 3 +- templates/content/e2e/sidebar-delete.spec.ts | 347 ++++++++++++++++++ 7 files changed, 454 insertions(+), 249 deletions(-) create mode 100644 templates/content/changelog/2026-07-30-deleting-a-page-no-longer-leaves-the-content-sidebar-unrespo.md create mode 100644 templates/content/e2e/sidebar-delete.spec.ts diff --git a/templates/content/app/components/editor/database/sidebar.tsx b/templates/content/app/components/editor/database/sidebar.tsx index a6ff68fc5c..7632325553 100644 --- a/templates/content/app/components/editor/database/sidebar.tsx +++ b/templates/content/app/components/editor/database/sidebar.tsx @@ -21,16 +21,6 @@ import { import { useEffect, useState, type MouseEvent, type ReactNode } from "react"; import { Link } from "react-router"; -import { - AlertDialog, - AlertDialogAction, - AlertDialogCancel, - AlertDialogContent, - AlertDialogDescription, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogTitle, -} from "@/components/ui/alert-dialog"; import { Button } from "@/components/ui/button"; import { Collapsible, @@ -727,7 +717,6 @@ function DatabaseSidebarRow({ }; }) { const t = useT(); - const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); const canEdit = item.document.canEdit !== false; const canManage = item.document.canManage === true || @@ -858,7 +847,7 @@ function DatabaseSidebarRow({ {canManage && onDeleteItem ? ( setDeleteDialogOpen(true)} + onSelect={() => onDeleteItem(item)} > {t("database.delete")} @@ -903,28 +892,6 @@ function DatabaseSidebarRow({ )} - - - - - - {t("sidebar.deletePageQuestion")} - - - {t("sidebar.deletePageDescription", { title })} - - - - {t("comments.cancel")} - onDeleteItem?.(item)} - > - {t("database.delete")} - - - - ); } diff --git a/templates/content/app/components/sidebar/DocumentSidebar.layout.test.ts b/templates/content/app/components/sidebar/DocumentSidebar.layout.test.ts index 1538010ebd..82c04c630c 100644 --- a/templates/content/app/components/sidebar/DocumentSidebar.layout.test.ts +++ b/templates/content/app/components/sidebar/DocumentSidebar.layout.test.ts @@ -438,6 +438,23 @@ describe("document sidebar layout", () => { expect(sidebar).not.toContain("!localFileMode && favorites.length > 0"); }); + it("keeps delete confirmation owned by the stable sidebar", () => { + const sidebar = readSidebarSource("./DocumentSidebar.tsx"); + const treeItem = readSidebarSource("./DocumentTreeItem.tsx"); + const databaseSidebar = readSidebarSource("../editor/database/sidebar.tsx"); + + expect(sidebar).toContain("const [pendingDelete, setPendingDelete]"); + expect(sidebar).toContain("open={pendingDelete !== null}"); + expect(sidebar).toContain("confirmedDeleteIdRef"); + expect(sidebar).toContain("window.requestAnimationFrame"); + expect(sidebar).toContain('document.body.style.pointerEvents === "none"'); + expect(sidebar).toContain("void handleDelete(confirmedDeleteId)"); + expect(treeItem).not.toContain("deleteDialogOpen"); + expect(treeItem).not.toContain(" { const sidebar = readSidebarSource("./DocumentSidebar.tsx"); diff --git a/templates/content/app/components/sidebar/DocumentSidebar.tsx b/templates/content/app/components/sidebar/DocumentSidebar.tsx index ed881294a2..6df91134d3 100644 --- a/templates/content/app/components/sidebar/DocumentSidebar.tsx +++ b/templates/content/app/components/sidebar/DocumentSidebar.tsx @@ -222,6 +222,18 @@ const SIDEBAR_SECTION_COLLAPSE_STORAGE_KEY = const TRASH_COLLAPSED_DEFAULT_MIGRATION_KEY = "content-sidebar-trash-collapsed-default-v2"; const CONTENT_SIDEBAR_STATE_VERSION = 1 as const; + +function afterBodyPointerUnlock(callback: () => void) { + const run = () => { + if (document.body.style.pointerEvents === "none") { + window.requestAnimationFrame(run); + return; + } + callback(); + }; + window.requestAnimationFrame(run); +} + interface ContentSidebarStateSnapshot { version: typeof CONTENT_SIDEBAR_STATE_VERSION; expandedWorkspaceIds: string[]; @@ -906,6 +918,11 @@ export function DocumentSidebar({ }, [setStoredCollapsedSections]); const [removeLocalFilesDialogOpen, setRemoveLocalFilesDialogOpen] = useState(false); + const [pendingDelete, setPendingDelete] = useState<{ + id: string; + title: string; + } | null>(null); + const confirmedDeleteIdRef = useRef(null); const settingsActive = location.pathname.startsWith("/settings"); const sensors = useSensors( useSensor(PointerSensor, { @@ -1258,6 +1275,12 @@ export function DocumentSidebar({ ], ); + const requestDelete = useCallback((id: string, title: string) => { + afterBodyPointerUnlock(() => { + setPendingDelete({ id, title }); + }); + }, []); + const handleReorderPage = useCallback( async (id: string, overId: string) => { if (id === overId) return; @@ -1520,7 +1543,7 @@ export function DocumentSidebar({ }} onCreateChildPage={(parentId) => handleCreatePage(parentId)} onCreateChildDatabase={(parentId) => handleCreateDatabase(parentId)} - onDelete={handleDelete} + onDelete={requestDelete} onToggleFavorite={handleToggleFavorite} /> ))} @@ -1800,7 +1823,12 @@ export function DocumentSidebar({ onCreateChildDatabase={(nextSpace, item) => void handleCreateDatabase(item.document.id, nextSpace.id) } - onDeleteItem={(item) => void handleDelete(item.document.id)} + onDeleteItem={(item) => + requestDelete( + item.document.id, + item.document.title || t("sidebar.untitled"), + ) + } onToggleFavorite={(item) => handleToggleFavorite(item.document.id, !item.document.isFavorite) } @@ -2331,7 +2359,10 @@ export function DocumentSidebar({ void handleCreateDatabase(item.document.id) } onDeleteItem={(item) => - void handleDelete(item.document.id) + requestDelete( + item.document.id, + item.document.title || t("sidebar.untitled"), + ) } onToggleFavorite={(item) => handleToggleFavorite(item.document.id, false) @@ -2399,6 +2430,49 @@ export function DocumentSidebar({ onMouseDown={handleMouseDown} /> )} + { + if (open) return; + setPendingDelete(null); + const confirmedDeleteId = confirmedDeleteIdRef.current; + confirmedDeleteIdRef.current = null; + if (confirmedDeleteId) { + afterBodyPointerUnlock(() => { + void handleDelete(confirmedDeleteId); + }); + } + }} + > + + + + {t("sidebar.deletePageQuestion")} + + + {pendingDelete + ? t("sidebar.deletePageDescription", { + title: pendingDelete.title, + }) + : null} + + + + {t("comments.cancel")} + { + confirmedDeleteIdRef.current = pendingDelete?.id ?? null; + }} + > + {t("database.delete")} + + + + void; onCreateChildPage: (parentId: string) => void; onCreateChildDatabase: (parentId: string) => void; - onDelete: (id: string) => void; + onDelete: (id: string, title: string) => void; onToggleFavorite: (id: string, isFavorite: boolean) => void; } @@ -88,178 +78,6 @@ export function DocumentSidebarIcon({ return ; } -export function FavoriteDocumentItem({ - document, - active, - sidebarWidth, - onSelect, - onCreateChildPage, - onCreateChildDatabase, - onRemoveFavorite, - onDelete, -}: { - document: Document; - active: boolean; - sidebarWidth?: number; - onSelect: () => void; - onCreateChildPage: () => void; - onCreateChildDatabase: () => void; - onRemoveFavorite: () => void; - onDelete: () => void; -}) { - const t = useT(); - const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); - const canEdit = document.canEdit !== false; - const canManage = - document.canManage === true || - document.accessRole === "owner" || - document.accessRole === "admin"; - const canCreateChild = canEdit && document.source?.mode !== "local-files"; - const title = document.title || t("sidebar.untitled"); - - return ( -
- - - - {canEdit && ( - { - event.stopPropagation(); - onRemoveFavorite(); - }} - > - - {t("sidebar.unpinFromSidebar")} - - )} - {canEdit && canManage && } - {canManage && ( - { - event.stopPropagation(); - setDeleteDialogOpen(true); - }} - > - - {t("database.delete")} - - )} - - - )} - {canCreateChild && ( - - - - - - - - {t("sidebar.addChild")} - - - { - event.stopPropagation(); - onCreateChildPage(); - }} - > - - {t("sidebar.page")} - - { - event.stopPropagation(); - onCreateChildDatabase(); - }} - > - - {t("sidebar.database")} - - - - )} -
- - - - - - {t("sidebar.deletePageQuestion")} - - - {t("sidebar.deletePageDescription", { title })} - - - - {t("comments.cancel")} - - {t("database.delete")} - - - - - - ); -} - export function DocumentTreeItem({ node, depth, @@ -286,7 +104,6 @@ export function DocumentTreeItem({ node.accessRole === "admin"; const hasMenuActions = canEdit || canManage; const canCreateChild = canEdit && !isLocalFileNode; - const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); const [contextSheetOpen, setContextSheetOpen] = useState(false); const indent = depth * 12 + 12; const rowWidth = @@ -439,9 +256,9 @@ export function DocumentTreeItem({ {canManage && ( { + onSelect={(e) => { e.stopPropagation(); - setDeleteDialogOpen(true); + onDelete(node.id, node.title || t("sidebar.untitled")); }} > @@ -534,30 +351,6 @@ export function DocumentTreeItem({ ))} )} - - - - - - {t("sidebar.deletePageQuestion")} - - - {t("sidebar.deletePageDescription", { - title: node.title || t("sidebar.untitled"), - })} - - - - {t("comments.cancel")} - onDelete(node.id)} - > - {t("database.delete")} - - - - ); } diff --git a/templates/content/changelog/2026-07-30-deleting-a-page-no-longer-leaves-the-content-sidebar-unrespo.md b/templates/content/changelog/2026-07-30-deleting-a-page-no-longer-leaves-the-content-sidebar-unrespo.md new file mode 100644 index 0000000000..6fd5dc72c8 --- /dev/null +++ b/templates/content/changelog/2026-07-30-deleting-a-page-no-longer-leaves-the-content-sidebar-unrespo.md @@ -0,0 +1,6 @@ +--- +type: fixed +date: 2026-07-30 +--- + +Deleting a page no longer leaves the Content sidebar unresponsive. diff --git a/templates/content/e2e/playwright.config.ts b/templates/content/e2e/playwright.config.ts index 62470b182d..813ddc2e6f 100644 --- a/templates/content/e2e/playwright.config.ts +++ b/templates/content/e2e/playwright.config.ts @@ -14,7 +14,8 @@ import { defineConfig, devices } from "@playwright/test"; */ export default defineConfig({ testDir: ".", - testMatch: /(registry-blocks|local-files|database-preview-menu)\.spec\.ts/, + testMatch: + /(registry-blocks|local-files|database-preview-menu|sidebar-delete)\.spec\.ts/, fullyParallel: true, workers: process.env.CI ? 2 : 3, retries: 2, diff --git a/templates/content/e2e/sidebar-delete.spec.ts b/templates/content/e2e/sidebar-delete.spec.ts new file mode 100644 index 0000000000..4f75722c58 --- /dev/null +++ b/templates/content/e2e/sidebar-delete.spec.ts @@ -0,0 +1,347 @@ +import { + expect, + test, + type APIResponse, + type Locator, + type Page, +} from "@playwright/test"; + +const ACTION_HEADERS = { + "X-Agent-Native-Frontend": "1", + "X-Agent-Native-Client-Compatibility": "content-spaces-v1", + "X-Agent-Native-Build-Id": "development", +}; +const FIXTURE_PREFIX = "Sidebar delete owner E2E"; + +type ActionResult = Record; + +type Fixture = { + documentId: string; + databaseId?: string; + title: string; +}; + +async function readJson(response: APIResponse): Promise { + try { + return (await response.json()) as ActionResult; + } catch { + return {}; + } +} + +async function runAction( + page: Page, + name: string, + data: Record, +): Promise { + const response = await page.request.post(`/_agent-native/actions/${name}`, { + data, + headers: ACTION_HEADERS, + }); + const result = await readJson(response); + expect( + response.ok(), + `${name} should succeed (${response.status()}): ${JSON.stringify(result).slice(0, 500)}`, + ).toBeTruthy(); + return result; +} + +async function readAction(page: Page, name: string): Promise { + const response = await page.request.get(`/_agent-native/actions/${name}`, { + headers: ACTION_HEADERS, + }); + const result = await readJson(response); + expect( + response.ok(), + `${name} should succeed (${response.status()}): ${JSON.stringify(result).slice(0, 500)}`, + ).toBeTruthy(); + return result; +} + +function uniqueTitle(label: string): string { + return `${FIXTURE_PREFIX} ${label} ${Date.now()}-${Math.floor(Math.random() * 1e6)}`; +} + +async function createPageFixture( + page: Page, + fixtures: Fixture[], + label: string, + parentId?: string, +): Promise { + const title = uniqueTitle(label); + const created = await runAction(page, "create-document", { + title, + content: `Task-owned fixture for ${title}.`, + parentId, + }); + expect(created.id, "create-document returns id").toEqual(expect.any(String)); + const fixture = { documentId: created.id as string, title }; + fixtures.push(fixture); + return fixture; +} + +async function createDatabaseFixture( + page: Page, + fixtures: Fixture[], + label: string, +): Promise { + const title = uniqueTitle(label); + const created = await runAction(page, "create-content-database", { title }); + expect( + created.database?.id, + "create-content-database returns database.id", + ).toEqual(expect.any(String)); + expect( + created.database?.documentId, + "create-content-database returns database.documentId", + ).toEqual(expect.any(String)); + const fixture = { + databaseId: created.database.id as string, + documentId: created.database.documentId as string, + title, + }; + fixtures.push(fixture); + return fixture; +} + +async function activeDocumentIds(page: Page): Promise> { + const listed = await readAction(page, "list-documents"); + const documents = Array.isArray(listed.documents) + ? listed.documents + : Array.isArray(listed) + ? listed + : []; + return new Set( + documents + .map((document: { id?: unknown }) => document.id) + .filter((id: unknown): id is string => typeof id === "string"), + ); +} + +async function cleanupFixtures(page: Page, fixtures: Fixture[]) { + for (const fixture of [...fixtures].reverse()) { + const activeIds = await activeDocumentIds(page); + if (activeIds.has(fixture.documentId)) { + if (fixture.databaseId) { + await runAction(page, "delete-content-database", { + databaseId: fixture.databaseId, + }); + } else { + await runAction(page, "delete-document", { id: fixture.documentId }); + } + } + + const permanentlyDeleted = await page.request.post( + "/_agent-native/actions/permanently-delete-document", + { + data: { id: fixture.documentId }, + headers: ACTION_HEADERS, + }, + ); + if (!permanentlyDeleted.ok()) { + const [active, trashedPages, trashedDatabases] = await Promise.all([ + activeDocumentIds(page), + readAction(page, "list-trashed-documents"), + readAction(page, "list-trashed-content-databases"), + ]); + const stillExists = + active.has(fixture.documentId) || + (trashedPages.documents ?? []).some( + (item: { documentId?: string }) => + item.documentId === fixture.documentId, + ) || + (trashedDatabases.databases ?? []).some( + (item: { documentId?: string }) => + item.documentId === fixture.documentId, + ); + expect( + stillExists, + `cleanup should remove ${fixture.title}: ${permanentlyDeleted.status()} ${JSON.stringify(await readJson(permanentlyDeleted)).slice(0, 500)}`, + ).toBeFalsy(); + } + } + + const [active, trashedPages, trashedDatabases] = await Promise.all([ + activeDocumentIds(page), + readAction(page, "list-trashed-documents"), + readAction(page, "list-trashed-content-databases"), + ]); + for (const fixture of fixtures) { + expect( + active.has(fixture.documentId), + `${fixture.title} is not active`, + ).toBe(false); + expect( + (trashedPages.documents ?? []).some( + (item: { documentId?: string }) => + item.documentId === fixture.documentId, + ), + `${fixture.title} is not in page Trash`, + ).toBe(false); + expect( + (trashedDatabases.databases ?? []).some( + (item: { documentId?: string }) => + item.documentId === fixture.documentId, + ), + `${fixture.title} is not in database Trash`, + ).toBe(false); + } +} + +async function sidebarItem(page: Page, title: string): Promise { + const link = page.getByRole("link", { name: title, exact: true }).first(); + if (await link.isVisible().catch(() => false)) return link; + + const labelledItem = page.getByLabel(title, { exact: true }).first(); + await expect(labelledItem).toBeVisible(); + return labelledItem; +} + +async function deleteActiveSidebarItem( + page: Page, + fixture: Fixture, + ancestorTitles: string[] = [], +) { + await page.goto(`/page/${fixture.documentId}`, { + waitUntil: "domcontentloaded", + }); + await expect(page).toHaveURL(new RegExp(`/page/${fixture.documentId}$`)); + + for (const ancestorTitle of ancestorTitles) { + const ancestorItem = await sidebarItem(page, ancestorTitle); + await ancestorItem.hover(); + const expand = page.getByRole("button", { + name: new RegExp(`^Expand (?:sidebar )?${ancestorTitle}$`), + }); + if ((await expand.count()) > 0) { + await expand.evaluate((element) => { + (element as HTMLButtonElement).click(); + }); + await expect( + page.getByRole("button", { + name: new RegExp(`^Collapse (?:sidebar )?${ancestorTitle}$`), + }), + ).toBeAttached(); + } + } + + const item = await sidebarItem(page, fixture.title); + await item.hover(); + const moreActions = page.getByRole("button", { + name: `More actions for ${fixture.title}`, + exact: true, + }); + await moreActions.focus(); + await moreActions.press("Enter"); + await page.getByRole("menuitem", { name: "Delete", exact: true }).click(); + + const dialog = page.getByRole("alertdialog"); + await expect(dialog).toContainText("Move page to Trash?"); + await dialog.getByRole("button", { name: "Delete", exact: true }).click(); + await expect(dialog).toBeHidden(); + + await expect + .poll(async () => (await activeDocumentIds(page)).has(fixture.documentId)) + .toBe(false); +} + +async function expectPointerInteractionRestored(page: Page) { + await expect + .poll(() => + page.evaluate(() => ({ + computed: window.getComputedStyle(document.body).pointerEvents, + inline: document.body.style.pointerEvents, + })), + ) + .toEqual({ computed: "auto", inline: "" }); +} + +async function realClickDifferentSurvivor( + page: Page, + survivors: [Fixture, Fixture], +) { + const survivor = + survivors.find( + (fixture) => + !new URL(page.url()).pathname.endsWith(`/page/${fixture.documentId}`), + ) ?? survivors[0]; + const item = await sidebarItem(page, survivor.title); + await item.click(); + await expect(page).toHaveURL(new RegExp(`/page/${survivor.documentId}$`)); +} + +test("deleting an active ordinary page restores real sidebar pointer navigation", async ({ + page, +}) => { + const fixtures: Fixture[] = []; + try { + const survivors: [Fixture, Fixture] = [ + await createPageFixture(page, fixtures, "ordinary survivor A"), + await createPageFixture(page, fixtures, "ordinary survivor B"), + ]; + const deleted = await createPageFixture( + page, + fixtures, + "active ordinary page", + ); + + await deleteActiveSidebarItem(page, deleted); + await expectPointerInteractionRestored(page); + await realClickDifferentSurvivor(page, survivors); + } finally { + await cleanupFixtures(page, fixtures); + } +}); + +test("deleting an active database page restores real sidebar pointer navigation", async ({ + page, +}) => { + const fixtures: Fixture[] = []; + try { + const survivors: [Fixture, Fixture] = [ + await createPageFixture(page, fixtures, "database survivor A"), + await createPageFixture(page, fixtures, "database survivor B"), + ]; + const deleted = await createDatabaseFixture( + page, + fixtures, + "active database page", + ); + + await deleteActiveSidebarItem(page, deleted); + await expectPointerInteractionRestored(page); + await realClickDifferentSurvivor(page, survivors); + } finally { + await cleanupFixtures(page, fixtures); + } +}); + +test("deleting an active nested page subtree restores real sidebar pointer navigation", async ({ + page, +}) => { + const fixtures: Fixture[] = []; + try { + const survivor = await createPageFixture(page, fixtures, "nested survivor"); + const parent = await createPageFixture(page, fixtures, "nested parent"); + const deleted = await createPageFixture( + page, + fixtures, + "active nested folder", + parent.documentId, + ); + await createPageFixture( + page, + fixtures, + "nested folder child", + deleted.documentId, + ); + + await deleteActiveSidebarItem(page, deleted, [parent.title]); + await expectPointerInteractionRestored(page); + const survivorItem = await sidebarItem(page, survivor.title); + await survivorItem.click(); + await expect(page).toHaveURL(new RegExp(`/page/${survivor.documentId}$`)); + } finally { + await cleanupFixtures(page, fixtures); + } +});