From 2bd75edacc6a632a6c3e519225592f85ffb552bf Mon Sep 17 00:00:00 2001 From: claude_code Date: Mon, 22 Jun 2026 17:07:37 +0300 Subject: [PATCH] feat(page): replace move-to-trash confirm dialog with undo toast Soft-deleting a page no longer opens a "Move this page to trash?" confirmation modal. The page is moved to trash immediately and the "Page moved to trash" toast now exposes an inline Undo action that restores the page via the existing restore flow. - Add move-to-trash-notification.tsx helper that builds the toast body (status text + Undo button) as a ReactNode, so it can be used from the non-TSX page-query module. - useRemovePageMutation: show the toast with a stable id, 8s autoClose, and an Undo handler that hides the toast and triggers restore. - space-tree-node-menu / page-header-menu: call handleDelete directly and drop the now-unused useDeletePageModal usage. Permanent delete keeps its confirmation modal. - useRestorePageMutation: read the live tree from the jotai store at execution time and insert via a functional updater, so Undo restores child pages correctly even after the originating component unmounted. Co-Authored-By: Claude Opus 4.8 --- .../components/header/page-header-menu.tsx | 4 +- .../components/move-to-trash-notification.tsx | 27 +++++++++++ .../src/features/page/queries/page-query.ts | 47 +++++++++++++++---- .../tree/components/space-tree-node-menu.tsx | 6 +-- 4 files changed, 68 insertions(+), 16 deletions(-) create mode 100644 apps/client/src/features/page/components/move-to-trash-notification.tsx diff --git a/apps/client/src/features/page/components/header/page-header-menu.tsx b/apps/client/src/features/page/components/header/page-header-menu.tsx index 38281b86..ab7827d6 100644 --- a/apps/client/src/features/page/components/header/page-header-menu.tsx +++ b/apps/client/src/features/page/components/header/page-header-menu.tsx @@ -30,7 +30,6 @@ import { notifications } from "@mantine/notifications"; import { getAppUrl } from "@/lib/config.ts"; import { extractPageSlugId } from "@/lib"; import { useTreeMutation } from "@/features/page/tree/hooks/use-tree-mutation.ts"; -import { useDeletePageModal } from "@/features/page/hooks/use-delete-page-modal.tsx"; import { PageWidthToggle } from "@/features/user/components/page-width-pref.tsx"; import { Trans, useTranslation } from "react-i18next"; import ExportModal from "@/components/common/export-modal"; @@ -143,7 +142,6 @@ function PageActionMenu({ readOnly }: PageActionMenuProps) { const { data: page, isLoading } = usePageQuery({ pageId: extractPageSlugId(pageSlug), }); - const { openDeleteModal } = useDeletePageModal(); const { handleDelete } = useTreeMutation(page?.spaceId ?? ""); const [exportOpened, { open: openExportModal, close: closeExportModal }] = useDisclosure(false); @@ -189,7 +187,7 @@ function PageActionMenu({ readOnly }: PageActionMenuProps) { }; const handleDeletePage = () => { - openDeleteModal({ onConfirm: () => handleDelete(page.id) }); + handleDelete(page.id); }; const handleToggleFavorite = () => { diff --git a/apps/client/src/features/page/components/move-to-trash-notification.tsx b/apps/client/src/features/page/components/move-to-trash-notification.tsx new file mode 100644 index 00000000..bfe06d1e --- /dev/null +++ b/apps/client/src/features/page/components/move-to-trash-notification.tsx @@ -0,0 +1,27 @@ +import { Button, Group, Text } from "@mantine/core"; +import type { ReactNode } from "react"; + +type MoveToTrashNotificationProps = { + message: string; + undoLabel: string; + onUndo: () => void; +}; + +// Builds the body of the "page moved to trash" toast: the status text plus an +// inline Undo action that restores the page from trash. Returned as a ReactNode +// so it can be passed as the `message` of a Mantine notification from a +// non-TSX module (page-query.ts). +export function moveToTrashNotificationMessage({ + message, + undoLabel, + onUndo, +}: MoveToTrashNotificationProps): ReactNode { + return ( + + {message} + + + ); +} diff --git a/apps/client/src/features/page/queries/page-query.ts b/apps/client/src/features/page/queries/page-query.ts index c631f892..eaa871f5 100644 --- a/apps/client/src/features/page/queries/page-query.ts +++ b/apps/client/src/features/page/queries/page-query.ts @@ -35,11 +35,12 @@ import { buildTree } from "@/features/page/tree/utils"; import { useEffect } from "react"; import { validate as isValidUuid } from "uuid"; import { useTranslation } from "react-i18next"; -import { useAtom } from "jotai"; +import { useSetAtom, useStore } from "jotai"; import { treeDataAtom } from "@/features/page/tree/atoms/tree-data-atom"; import { treeModel } from "@/features/page/tree/model/tree-model"; import { SpaceTreeNode } from "@/features/page/tree/types"; import { useQueryEmit } from "@/features/websocket/use-query-emit"; +import { moveToTrashNotificationMessage } from "@/features/page/components/move-to-trash-notification"; export function usePageQuery( pageInput: Partial, @@ -118,10 +119,29 @@ export function useUpdatePageMutation() { export function useRemovePageMutation() { const { t } = useTranslation(); + // Reuse the existing restore flow for the toast's Undo action. Its side + // effects (tree re-insert, cache updates, websocket emit, success toast) live + // in its useMutation-level onSuccess, so they still run after the originating + // tree node / page header has unmounted by the time Undo is clicked. + const restorePageMutation = useRestorePageMutation(); return useMutation({ mutationFn: (pageId: string) => deletePage(pageId, false), onSuccess: (_, pageId) => { - notifications.show({ message: t("Page moved to trash") }); + // Replace the former pre-delete confirmation dialog with an Undo action + // surfaced directly in the "moved to trash" toast. + const notificationId = `page-moved-to-trash-${pageId}`; + notifications.show({ + id: notificationId, + autoClose: 8000, + message: moveToTrashNotificationMessage({ + message: t("Page moved to trash"), + undoLabel: t("Undo"), + onUndo: () => { + notifications.hide(notificationId); + restorePageMutation.mutate(pageId); + }, + }), + }); // Stamp deletedAt so a re-visit shows the trash banner, not stale state. const cached = queryClient.getQueryData(["pages", pageId]); @@ -173,7 +193,8 @@ export function useMovePageMutation() { export function useRestorePageMutation() { const { t } = useTranslation(); - const [treeData, setTreeData] = useAtom(treeDataAtom); + const setTreeData = useSetAtom(treeDataAtom); + const store = useStore(); const emit = useQueryEmit(); return useMutation({ @@ -181,8 +202,13 @@ export function useRestorePageMutation() { onSuccess: async (restoredPage) => { notifications.show({ message: t("Page restored successfully") }); + // Undo can fire from the trash toast after the originating tree node / + // page header has unmounted, so a render-time `treeData` closure would be + // stale. Read the live tree imperatively from the store at execution time. + const currentTree = store.get(treeDataAtom); + // Check if the page already exists in the tree (it shouldn't) - if (!treeModel.find(treeData, restoredPage.id)) { + if (!treeModel.find(currentTree, restoredPage.id)) { // Create the tree node data with hasChildren from backend const nodeData: SpaceTreeNode = { id: restoredPage.id, @@ -201,17 +227,22 @@ export function useRestorePageMutation() { let index = 0; if (parentId) { - const parentNode = treeModel.find(treeData, parentId); + const parentNode = treeModel.find(currentTree, parentId); if (parentNode) { index = parentNode.children?.length || 0; } } else { // Root level page - index = treeData.length; + index = currentTree.length; } - // Add the node to the tree - setTreeData(treeModel.insert(treeData, parentId, nodeData, index)); + // Add the node to the tree via a functional updater, re-checking + // existence against the freshest state for idempotency. + setTreeData((prev) => + treeModel.find(prev, restoredPage.id) + ? prev + : treeModel.insert(prev, parentId, nodeData, index), + ); // Emit websocket event to sync with other users setTimeout(() => { diff --git a/apps/client/src/features/page/tree/components/space-tree-node-menu.tsx b/apps/client/src/features/page/tree/components/space-tree-node-menu.tsx index d21305a3..54d75f3f 100644 --- a/apps/client/src/features/page/tree/components/space-tree-node-menu.tsx +++ b/apps/client/src/features/page/tree/components/space-tree-node-menu.tsx @@ -19,7 +19,6 @@ import { import ExportModal from "@/components/common/export-modal"; import MovePageModal from "@/features/page/components/move-page-modal.tsx"; import CopyPageModal from "@/features/page/components/copy-page-modal.tsx"; -import { useDeletePageModal } from "@/features/page/hooks/use-delete-page-modal.tsx"; import { buildPageUrl } from "@/features/page/page.utils.ts"; import { duplicatePage } from "@/features/page/services/page-service.ts"; import { useClipboard } from "@/hooks/use-clipboard"; @@ -47,7 +46,6 @@ export function NodeMenu({ node, canEdit }: NodeMenuProps) { const { t } = useTranslation(); const clipboard = useClipboard({ timeout: 500 }); const { spaceSlug } = useParams(); - const { openDeleteModal } = useDeletePageModal(); const { handleDelete } = useTreeMutation(node.spaceId); const [data, setData] = useAtom(treeDataAtom); const emit = useQueryEmit(); @@ -257,9 +255,7 @@ export function NodeMenu({ node, canEdit }: NodeMenuProps) { onClick={(e) => { e.preventDefault(); e.stopPropagation(); - openDeleteModal({ - onConfirm: () => handleDelete(node.id), - }); + handleDelete(node.id); }} > {t("Move to trash")}