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")}