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:
@@ -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 = () => {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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")}
|
||||
|
||||
Reference in New Issue
Block a user