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 <noreply@anthropic.com>
This commit is contained in:
claude_code
2026-06-22 17:07:37 +03:00
parent 7ce1a24f82
commit 2bd75edacc
4 changed files with 68 additions and 16 deletions

View File

@@ -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 = () => {

View File

@@ -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 (
<Group justify="space-between" wrap="nowrap" gap="md">
<Text size="sm">{message}</Text>
<Button variant="subtle" size="compact-sm" onClick={onUndo}>
{undoLabel}
</Button>
</Group>
);
}

View File

@@ -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<IPageInput>,
@@ -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<IPage>(["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(() => {

View File

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