mirror of
https://github.com/docmost/docmost.git
synced 2026-05-15 05:04:06 +08:00
Better trash (#2190)
* Better trash I recently lost a bunch of time editing and searching for pages that were actually in the Trash. Docmost intentionally tries to not link to Trashed pages, but the url of that Trashed page and any inbound links still work. This makes it clearer when a page you are interacting with is in the Trash. - /trash - Refactored banner into `trash-banner.tsx` - Refactored "Restore" modal into `use-restore-page-modal.tsx` - Page (when isDeleted) - Add: `trash-banner.tsx` - Add breadcrumbs: `Parent / Child / Page (Deleted)` - Change: Deleted Pages are read-only - Replace "Move to Trash" with "Restore" in page menu (invokes `use-restore-page-modal`) I tried very hard to keep this simple and re-use existing translation strings wherever possible. * cleanup --------- Co-authored-by: Philipinho <16838612+Philipinho@users.noreply.github.com>
This commit is contained in:
@@ -71,6 +71,7 @@
|
||||
"Export": "Export",
|
||||
"Failed to create page": "Failed to create page",
|
||||
"Failed to delete page": "Failed to delete page",
|
||||
"Failed to restore page": "Failed to restore page",
|
||||
"Failed to fetch recent pages": "Failed to fetch recent pages",
|
||||
"Failed to import pages": "Failed to import pages",
|
||||
"Failed to load page. An error occurred.": "Failed to load page. An error occurred.",
|
||||
@@ -581,6 +582,8 @@
|
||||
"Move to trash": "Move to trash",
|
||||
"Move this page to trash?": "Move this page to trash?",
|
||||
"Restore page": "Restore page",
|
||||
"Permanently delete": "Permanently delete",
|
||||
"<b>{{name}}</b> moved this page to Trash {{time}}.": "<b>{{name}}</b> moved this page to Trash {{time}}.",
|
||||
"Page moved to trash": "Page moved to trash",
|
||||
"Page restored successfully": "Page restored successfully",
|
||||
"Deleted by": "Deleted by",
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Box, ScrollArea, Text } from "@mantine/core";
|
||||
import { ActionIcon, Box, Group, ScrollArea, Text, Tooltip } from "@mantine/core";
|
||||
import { IconX } from "@tabler/icons-react";
|
||||
import CommentListWithTabs from "@/features/comment/components/comment-list-with-tabs.tsx";
|
||||
import { useAtom } from "jotai";
|
||||
import { asideStateAtom } from "@/components/layouts/global/hooks/atoms/sidebar-atom.ts";
|
||||
@@ -11,9 +12,10 @@ import AsideChatPanel from "@/ee/ai-chat/components/aside-chat-panel";
|
||||
import { PageDetailsAside } from "@/features/page-details/components/page-details-aside.tsx";
|
||||
|
||||
export default function Aside() {
|
||||
const [{ tab }] = useAtom(asideStateAtom);
|
||||
const [{ tab }, setAsideState] = useAtom(asideStateAtom);
|
||||
const { t } = useTranslation();
|
||||
const pageEditor = useAtomValue(pageEditorAtom);
|
||||
const closeAside = () => setAsideState((s) => ({ ...s, isAsideOpen: false }));
|
||||
|
||||
let title: string;
|
||||
let component: ReactNode;
|
||||
@@ -45,9 +47,19 @@ export default function Aside() {
|
||||
{component && (
|
||||
<>
|
||||
{tab !== "chat" && (
|
||||
<Text mb="md" fw={500}>
|
||||
{t(title)}
|
||||
</Text>
|
||||
<Group justify="space-between" wrap="nowrap" mb="md">
|
||||
<Text fw={500}>{t(title)}</Text>
|
||||
<Tooltip label={t("Close")} withArrow>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
onClick={closeAside}
|
||||
aria-label={t("Close")}
|
||||
>
|
||||
<IconX size={18} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
)}
|
||||
|
||||
{tab === "comments" || tab === "chat" ? (
|
||||
|
||||
@@ -23,13 +23,16 @@ import { IContributor } from "@/features/page/types/page.types.ts";
|
||||
import { FixedToolbar } from "@/features/editor/components/fixed-toolbar/fixed-toolbar";
|
||||
import { PageEditMode } from "@/features/user/types/user.types.ts";
|
||||
import useToggleAside from "@/hooks/use-toggle-aside.tsx";
|
||||
import { DeletedPageBanner } from "@/features/page/trash/components/deleted-page-banner.tsx";
|
||||
import clsx from "clsx";
|
||||
import { currentPageEditModeAtom } from "@/features/editor/atoms/editor-atoms.ts";
|
||||
|
||||
const MemoizedTitleEditor = React.memo(TitleEditor);
|
||||
const MemoizedPageEditor = React.memo(PageEditor);
|
||||
const MemoizedFixedToolbar = React.memo(FixedToolbar);
|
||||
const MemoizedDeletedPageBanner = React.memo(DeletedPageBanner);
|
||||
|
||||
type PageCreator = {
|
||||
type PageUser = {
|
||||
id: string;
|
||||
name: string;
|
||||
avatarUrl: string;
|
||||
@@ -46,7 +49,7 @@ export interface FullEditorProps {
|
||||
content: string;
|
||||
spaceSlug: string;
|
||||
editable: boolean;
|
||||
creator?: PageCreator;
|
||||
creator?: PageUser;
|
||||
contributors?: IContributor[];
|
||||
canComment?: boolean;
|
||||
}
|
||||
@@ -86,7 +89,8 @@ export function FullEditor({
|
||||
size={!fullPageWidth && 900}
|
||||
className={classes.editor}
|
||||
>
|
||||
{editorToolbarEnabled && editable && isEditMode && <FixedToolbar />}
|
||||
{editorToolbarEnabled && editable && isEditMode && <MemoizedFixedToolbar />}
|
||||
<MemoizedDeletedPageBanner slugId={slugId} />
|
||||
<MemoizedTitleEditor
|
||||
pageId={pageId}
|
||||
slugId={slugId}
|
||||
@@ -110,7 +114,7 @@ export function FullEditor({
|
||||
}
|
||||
|
||||
type PageBylineProps = {
|
||||
creator?: PageCreator;
|
||||
creator?: PageUser;
|
||||
contributors?: IContributor[];
|
||||
readOnly?: boolean;
|
||||
};
|
||||
|
||||
@@ -65,6 +65,11 @@ interface PageHeaderMenuProps {
|
||||
export default function PageHeaderMenu({ readOnly }: PageHeaderMenuProps) {
|
||||
const { t } = useTranslation();
|
||||
const toggleAside = useToggleAside();
|
||||
const { pageSlug } = useParams();
|
||||
const { data: page } = usePageQuery({
|
||||
pageId: extractPageSlugId(pageSlug),
|
||||
});
|
||||
const isDeleted = !!page?.deletedAt;
|
||||
|
||||
useHotkeys(
|
||||
[
|
||||
@@ -87,6 +92,10 @@ export default function PageHeaderMenu({ readOnly }: PageHeaderMenuProps) {
|
||||
[],
|
||||
);
|
||||
|
||||
if (isDeleted) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<ConnectionWarning />
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { modals } from "@mantine/modals";
|
||||
import { Text } from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
type UseRestoreModalProps = {
|
||||
title?: string | null;
|
||||
onConfirm: () => void;
|
||||
};
|
||||
|
||||
export function useRestorePageModal() {
|
||||
const { t } = useTranslation();
|
||||
const openRestoreModal = ({ title, onConfirm }: UseRestoreModalProps) => {
|
||||
modals.openConfirmModal({
|
||||
title: t("Restore page"),
|
||||
children: (
|
||||
<Text size="sm">
|
||||
{t("Restore '{{title}}' and its sub-pages?", {
|
||||
title: title || t("Untitled"),
|
||||
})}
|
||||
</Text>
|
||||
),
|
||||
centered: true,
|
||||
labels: { confirm: t("Restore"), cancel: t("Cancel") },
|
||||
confirmProps: { color: "blue" },
|
||||
onConfirm,
|
||||
});
|
||||
};
|
||||
|
||||
return { openRestoreModal } as const;
|
||||
}
|
||||
@@ -117,10 +117,20 @@ export function useUpdatePageMutation() {
|
||||
}
|
||||
|
||||
export function useRemovePageMutation() {
|
||||
const { t } = useTranslation();
|
||||
return useMutation({
|
||||
mutationFn: (pageId: string) => deletePage(pageId, false),
|
||||
onSuccess: (_, pageId) => {
|
||||
notifications.show({ message: "Page moved to trash" });
|
||||
notifications.show({ message: t("Page moved to trash") });
|
||||
|
||||
// Stamp deletedAt so a re-visit shows the trash banner, not stale state.
|
||||
const cached = queryClient.getQueryData<IPage>(["pages", pageId]);
|
||||
if (cached) {
|
||||
const stamped = { ...cached, deletedAt: new Date() };
|
||||
queryClient.setQueryData(["pages", cached.id], stamped);
|
||||
queryClient.setQueryData(["pages", cached.slugId], stamped);
|
||||
}
|
||||
|
||||
invalidateOnDeletePage(pageId);
|
||||
queryClient.invalidateQueries({
|
||||
predicate: (item) =>
|
||||
@@ -128,7 +138,7 @@ export function useRemovePageMutation() {
|
||||
});
|
||||
},
|
||||
onError: (error) => {
|
||||
notifications.show({ message: "Failed to delete page", color: "red" });
|
||||
notifications.show({ message: t("Failed to delete page"), color: "red" });
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -162,13 +172,14 @@ export function useMovePageMutation() {
|
||||
}
|
||||
|
||||
export function useRestorePageMutation() {
|
||||
const { t } = useTranslation();
|
||||
const [treeData, setTreeData] = useAtom(treeDataAtom);
|
||||
const emit = useQueryEmit();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (pageId: string) => restorePage(pageId),
|
||||
onSuccess: async (restoredPage) => {
|
||||
notifications.show({ message: "Page restored successfully" });
|
||||
notifications.show({ message: t("Page restored successfully") });
|
||||
|
||||
// Check if the page already exists in the tree (it shouldn't)
|
||||
if (!treeModel.find(treeData, restoredPage.id)) {
|
||||
@@ -222,9 +233,16 @@ export function useRestorePageMutation() {
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: ["trash-list", restoredPage.spaceId],
|
||||
});
|
||||
|
||||
// Merge — restore endpoint returns a skinny page;
|
||||
// Replace would strip space/permissions/content and break the editor.
|
||||
const merge = (cached: IPage | undefined) =>
|
||||
cached ? { ...cached, ...restoredPage } : cached;
|
||||
queryClient.setQueryData<IPage>(["pages", restoredPage.id], merge);
|
||||
queryClient.setQueryData<IPage>(["pages", restoredPage.slugId], merge);
|
||||
},
|
||||
onError: (error) => {
|
||||
notifications.show({ message: "Failed to restore page", color: "red" });
|
||||
notifications.show({ message: t("Failed to restore page"), color: "red" });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
import { ActionIcon, Button, Group, Paper, Text, Tooltip } from "@mantine/core";
|
||||
import { IconRestore, IconTrash } from "@tabler/icons-react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import { useTimeAgo } from "@/hooks/use-time-ago.tsx";
|
||||
import { useRestorePageModal } from "@/features/page/hooks/use-restore-page-modal.tsx";
|
||||
import { useDeletePageModal } from "@/features/page/hooks/use-delete-page-modal.tsx";
|
||||
import {
|
||||
useDeletePageMutation,
|
||||
usePageQuery,
|
||||
useRestorePageMutation,
|
||||
} from "@/features/page/queries/page-query.ts";
|
||||
import { getSpaceUrl } from "@/lib/config.ts";
|
||||
import { useGetSpaceBySlugQuery } from "@/features/space/queries/space-query.ts";
|
||||
import { useSpaceAbility } from "@/features/space/permissions/use-space-ability.ts";
|
||||
import {
|
||||
SpaceCaslAction,
|
||||
SpaceCaslSubject,
|
||||
} from "@/features/space/permissions/permissions.type.ts";
|
||||
|
||||
type DeletedPageBannerProps = {
|
||||
slugId: string;
|
||||
};
|
||||
|
||||
export function DeletedPageBanner({ slugId }: DeletedPageBannerProps) {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const { data: page } = usePageQuery({ pageId: slugId });
|
||||
const { data: space } = useGetSpaceBySlugQuery(page?.space?.slug);
|
||||
const spaceAbility = useSpaceAbility(space?.membership?.permissions);
|
||||
const deletedTimeAgo = useTimeAgo(page?.deletedAt);
|
||||
const restorePageMutation = useRestorePageMutation();
|
||||
const deletePageMutation = useDeletePageMutation();
|
||||
const { openRestoreModal } = useRestorePageModal();
|
||||
const { openDeleteModal } = useDeletePageModal();
|
||||
|
||||
if (!page?.deletedAt) return null;
|
||||
|
||||
const canRestore = spaceAbility.can(
|
||||
SpaceCaslAction.Edit,
|
||||
SpaceCaslSubject.Page,
|
||||
);
|
||||
const canPermanentlyDelete = spaceAbility.can(
|
||||
SpaceCaslAction.Manage,
|
||||
SpaceCaslSubject.Settings,
|
||||
);
|
||||
const actorName = page.deletedBy?.name ?? t("Someone");
|
||||
|
||||
const handleRestore = () => {
|
||||
openRestoreModal({
|
||||
title: page.title,
|
||||
onConfirm: () => restorePageMutation.mutate(page.id),
|
||||
});
|
||||
};
|
||||
|
||||
const handlePermanentDelete = () => {
|
||||
openDeleteModal({
|
||||
isPermanent: true,
|
||||
onConfirm: async () => {
|
||||
await deletePageMutation.mutateAsync(page.id);
|
||||
navigate(getSpaceUrl(page.space?.slug));
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const hasAnyAction = canRestore || canPermanentlyDelete;
|
||||
|
||||
return (
|
||||
<Paper radius="sm" mb="md" px="md" py="xs" bg="red.0">
|
||||
<Group justify="space-between" wrap="wrap" gap="sm">
|
||||
<Text size="sm" style={{ flex: 1, minWidth: 0 }}>
|
||||
<Trans
|
||||
i18nKey="<b>{{name}}</b> moved this page to Trash {{time}}."
|
||||
values={{ name: actorName, time: deletedTimeAgo }}
|
||||
components={{ b: <Text span fw={600} inherit /> }}
|
||||
/>
|
||||
</Text>
|
||||
{hasAnyAction && (
|
||||
<>
|
||||
<Group gap="xs" wrap="nowrap" visibleFrom="sm">
|
||||
{canRestore && (
|
||||
<Button
|
||||
size="xs"
|
||||
variant="light"
|
||||
color="red"
|
||||
leftSection={<IconRestore size={16} />}
|
||||
onClick={handleRestore}
|
||||
loading={restorePageMutation.isPending}
|
||||
>
|
||||
{t("Restore page")}
|
||||
</Button>
|
||||
)}
|
||||
{canPermanentlyDelete && (
|
||||
<Button
|
||||
size="xs"
|
||||
variant="light"
|
||||
color="red"
|
||||
leftSection={<IconTrash size={16} />}
|
||||
onClick={handlePermanentDelete}
|
||||
loading={deletePageMutation.isPending}
|
||||
>
|
||||
{t("Permanently delete")}
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
<Group gap="xs" wrap="nowrap" hiddenFrom="sm">
|
||||
{canRestore && (
|
||||
<Tooltip label={t("Restore page")} withArrow>
|
||||
<ActionIcon
|
||||
size="lg"
|
||||
variant="default"
|
||||
onClick={handleRestore}
|
||||
loading={restorePageMutation.isPending}
|
||||
aria-label={t("Restore page")}
|
||||
>
|
||||
<IconRestore size={18} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
)}
|
||||
{canPermanentlyDelete && (
|
||||
<Tooltip label={t("Permanently delete")} withArrow>
|
||||
<ActionIcon
|
||||
size="lg"
|
||||
variant="light"
|
||||
color="red"
|
||||
onClick={handlePermanentDelete}
|
||||
loading={deletePageMutation.isPending}
|
||||
aria-label={t("Permanently delete")}
|
||||
>
|
||||
<IconTrash size={18} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Group>
|
||||
</>
|
||||
)}
|
||||
</Group>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { Alert, Text } from "@mantine/core";
|
||||
import { IconInfoCircle } from "@tabler/icons-react";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
|
||||
|
||||
export function TrashBanner() {
|
||||
const { t } = useTranslation();
|
||||
const workspace = useAtomValue(workspaceAtom);
|
||||
const retentionDays = workspace?.trashRetentionDays ?? 30;
|
||||
|
||||
return (
|
||||
<Alert icon={<IconInfoCircle size={16} />} variant="light" color="red">
|
||||
<Text size="sm" lh={1.35}>
|
||||
{t("Pages in trash will be permanently deleted after {{count}} days.", {
|
||||
count: retentionDays,
|
||||
})}
|
||||
</Text>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
@@ -7,17 +7,16 @@ import {
|
||||
Group,
|
||||
ActionIcon,
|
||||
Text,
|
||||
Alert,
|
||||
Stack,
|
||||
Menu,
|
||||
} from "@mantine/core";
|
||||
import {
|
||||
IconInfoCircle,
|
||||
IconDots,
|
||||
IconRestore,
|
||||
IconTrash,
|
||||
IconFileDescription,
|
||||
} from "@tabler/icons-react";
|
||||
import { TrashBanner } from "@/features/page/trash/components/trash-banner.tsx";
|
||||
import {
|
||||
useDeletedPagesQuery,
|
||||
useRestorePageMutation,
|
||||
@@ -31,12 +30,10 @@ import TrashPageContentModal from "@/features/page/trash/components/trash-page-c
|
||||
import { UserInfo } from "@/components/common/user-info.tsx";
|
||||
import Paginate from "@/components/common/paginate.tsx";
|
||||
import { useCursorPaginate } from "@/hooks/use-cursor-paginate";
|
||||
import { useAtom } from "jotai";
|
||||
import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
|
||||
import { useRestorePageModal } from "@/features/page/hooks/use-restore-page-modal.tsx";
|
||||
|
||||
export default function Trash() {
|
||||
const { t } = useTranslation();
|
||||
const [workspace] = useAtom(workspaceAtom);
|
||||
const { spaceSlug } = useParams();
|
||||
const { cursor, goNext, goPrev } = useCursorPaginate();
|
||||
const { data: space } = useGetSpaceBySlugQuery(spaceSlug);
|
||||
@@ -45,6 +42,7 @@ export default function Trash() {
|
||||
});
|
||||
const restorePageMutation = useRestorePageMutation();
|
||||
const deletePageMutation = useDeletePageMutation();
|
||||
const { openRestoreModal } = useRestorePageModal();
|
||||
|
||||
const [selectedPage, setSelectedPage] = useState<{
|
||||
title: string;
|
||||
@@ -78,23 +76,6 @@ export default function Trash() {
|
||||
});
|
||||
};
|
||||
|
||||
const openRestoreModal = (pageId: string, pageTitle: string) => {
|
||||
modals.openConfirmModal({
|
||||
title: t("Restore page"),
|
||||
children: (
|
||||
<Text size="sm">
|
||||
{t("Restore '{{title}}' and its sub-pages?", {
|
||||
title: pageTitle || "Untitled",
|
||||
})}
|
||||
</Text>
|
||||
),
|
||||
centered: true,
|
||||
labels: { confirm: t("Restore"), cancel: t("Cancel") },
|
||||
confirmProps: { color: "blue" },
|
||||
onConfirm: () => handleRestorePage(pageId),
|
||||
});
|
||||
};
|
||||
|
||||
const hasPages = deletedPages && deletedPages.items.length > 0;
|
||||
|
||||
const handlePageClick = (page: any) => {
|
||||
@@ -109,11 +90,7 @@ export default function Trash() {
|
||||
<Title order={2}>{t("Trash")}</Title>
|
||||
</Group>
|
||||
|
||||
<Alert icon={<IconInfoCircle size={16} />} variant="light" color="red">
|
||||
<Text size="sm">
|
||||
{t("Pages in trash will be permanently deleted after {{count}} days.", { count: workspace?.trashRetentionDays ?? 30 })}
|
||||
</Text>
|
||||
</Alert>
|
||||
<TrashBanner />
|
||||
|
||||
{isLoading || !deletedPages ? (
|
||||
<></>
|
||||
@@ -181,7 +158,10 @@ export default function Trash() {
|
||||
<Menu.Item
|
||||
leftSection={<IconRestore size={16} />}
|
||||
onClick={() =>
|
||||
openRestoreModal(page.id, page.title)
|
||||
openRestoreModal({
|
||||
title: page.title,
|
||||
onConfirm: () => handleRestorePage(page.id),
|
||||
})
|
||||
}
|
||||
>
|
||||
{t("Restore")}
|
||||
|
||||
@@ -52,7 +52,7 @@ function PageContent({ pageSlug }: { pageSlug: string | undefined }) {
|
||||
} = usePageQuery({ pageId: extractPageSlugId(pageSlug) });
|
||||
const { data: space } = useGetSpaceBySlugQuery(page?.space?.slug);
|
||||
|
||||
const canEdit = page?.permissions?.canEdit ?? false;
|
||||
const canEdit = !page?.deletedAt && (page?.permissions?.canEdit ?? false);
|
||||
const canComment =
|
||||
canEdit ||
|
||||
(space?.settings?.comments?.allowViewerComments === true);
|
||||
|
||||
@@ -76,6 +76,7 @@ export class PageController {
|
||||
includeCreator: true,
|
||||
includeLastUpdatedBy: true,
|
||||
includeContributors: true,
|
||||
includeDeletedBy: true,
|
||||
});
|
||||
|
||||
if (!page) {
|
||||
|
||||
@@ -54,6 +54,7 @@ export class PageRepo {
|
||||
includeCreator?: boolean;
|
||||
includeLastUpdatedBy?: boolean;
|
||||
includeContributors?: boolean;
|
||||
includeDeletedBy?: boolean;
|
||||
includeHasChildren?: boolean;
|
||||
withLock?: boolean;
|
||||
trx?: KyselyTransaction;
|
||||
@@ -83,6 +84,10 @@ export class PageRepo {
|
||||
query = query.select((eb) => this.withContributors(eb));
|
||||
}
|
||||
|
||||
if (opts?.includeDeletedBy) {
|
||||
query = query.select((eb) => this.withDeletedBy(eb));
|
||||
}
|
||||
|
||||
if (opts?.includeSpace) {
|
||||
query = query.select((eb) => this.withSpace(eb));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user