mirror of
https://github.com/docmost/docmost.git
synced 2025-11-12 23:02:36 +10:00
added recycle bin modal, updated api routes
This commit is contained in:
@ -14,6 +14,9 @@ import {
|
||||
movePage,
|
||||
getPageBreadcrumbs,
|
||||
getRecentChanges,
|
||||
getDeletedPages,
|
||||
restorePage,
|
||||
removePage,
|
||||
} from "@/features/page/services/page-service";
|
||||
import {
|
||||
IMovePage,
|
||||
@ -73,6 +76,18 @@ export function useUpdatePageMutation() {
|
||||
});
|
||||
}
|
||||
|
||||
export function useRemovePageMutation() {
|
||||
return useMutation({
|
||||
mutationFn: (pageId: string) => removePage(pageId),
|
||||
onSuccess: () => {
|
||||
notifications.show({ message: "Page deleted successfully" });
|
||||
},
|
||||
onError: (error) => {
|
||||
notifications.show({ message: "Failed to delete page", color: "red" });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeletePageMutation() {
|
||||
return useMutation({
|
||||
mutationFn: (pageId: string) => deletePage(pageId),
|
||||
@ -91,6 +106,18 @@ export function useMovePageMutation() {
|
||||
});
|
||||
}
|
||||
|
||||
export function useRestorePageMutation() {
|
||||
return useMutation({
|
||||
mutationFn: (pageId: string) => restorePage(pageId),
|
||||
onSuccess: () => {
|
||||
notifications.show({ message: "Page restored successfully" });
|
||||
},
|
||||
onError: (error) => {
|
||||
notifications.show({ message: "Failed to restore page", color: "red" });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useGetSidebarPagesQuery(
|
||||
data: SidebarPagesParams,
|
||||
): UseQueryResult<IPagination<IPage>, Error> {
|
||||
@ -143,3 +170,12 @@ export function useRecentChangesQuery(
|
||||
refetchOnMount: true,
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeletedPagesQuery(
|
||||
spaceId: string,
|
||||
): UseQueryResult<IPagination<IPage>, Error> {
|
||||
return useQuery({
|
||||
queryKey: ["deleted-pages", spaceId],
|
||||
queryFn: () => getDeletedPages(spaceId),
|
||||
});
|
||||
}
|
||||
|
||||
@ -26,10 +26,19 @@ export async function updatePage(data: Partial<IPageInput>): Promise<IPage> {
|
||||
return req.data;
|
||||
}
|
||||
|
||||
export async function removePage(pageId: string): Promise<void> {
|
||||
await api.post("/pages/remove", { pageId });
|
||||
}
|
||||
|
||||
export async function deletePage(pageId: string): Promise<void> {
|
||||
await api.post("/pages/delete", { pageId });
|
||||
}
|
||||
|
||||
export async function getDeletedPages(spaceId: string): Promise<IPagination<IPage>> {
|
||||
const req = await api.post("/pages/deleted", { spaceId });
|
||||
return req.data;
|
||||
}
|
||||
|
||||
export async function restorePage(pageId: string): Promise<void> {
|
||||
await api.post("/pages/restore", { pageId });
|
||||
}
|
||||
|
||||
@ -13,7 +13,7 @@ import { IMovePage, IPage } from "@/features/page/types/page.types.ts";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import {
|
||||
useCreatePageMutation,
|
||||
useDeletePageMutation,
|
||||
useRemovePageMutation,
|
||||
useMovePageMutation,
|
||||
useUpdatePageMutation,
|
||||
} from "@/features/page/queries/page-query.ts";
|
||||
@ -27,7 +27,7 @@ export function useTreeMutation<T>(spaceId: string) {
|
||||
const tree = useMemo(() => new SimpleTree<SpaceTreeNode>(data), [data]);
|
||||
const createPageMutation = useCreatePageMutation();
|
||||
const updatePageMutation = useUpdatePageMutation();
|
||||
const deletePageMutation = useDeletePageMutation();
|
||||
const deletePageMutation = useRemovePageMutation();
|
||||
const movePageMutation = useMovePageMutation();
|
||||
const navigate = useNavigate();
|
||||
const { spaceSlug } = useParams();
|
||||
|
||||
@ -0,0 +1,51 @@
|
||||
import { Modal, ScrollArea, rem } from "@mantine/core";
|
||||
import React, { useMemo } from "react";
|
||||
import { useSpaceQuery } from "@/features/space/queries/space-query.ts";
|
||||
import { useSpaceAbility } from "@/features/space/permissions/use-space-ability.ts";
|
||||
import RecycledPagesList from "@/features/space/components/recycled-pages.tsx"
|
||||
|
||||
interface RecycleBinModalProps {
|
||||
spaceId: string;
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export default function RecycleBinModal({
|
||||
spaceId,
|
||||
opened,
|
||||
onClose,
|
||||
}: RecycleBinModalProps) {
|
||||
const { data: space, isLoading } = useSpaceQuery(spaceId);
|
||||
|
||||
const spaceRules = space?.membership?.permissions;
|
||||
const spaceAbility = useMemo(() => useSpaceAbility(spaceRules), [spaceRules]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Modal.Root
|
||||
opened={opened}
|
||||
onClose={onClose}
|
||||
size={600}
|
||||
padding="xl"
|
||||
yOffset="10vh"
|
||||
xOffset={0}
|
||||
mah={400}
|
||||
>
|
||||
<Modal.Overlay />
|
||||
<Modal.Content style={{ overflow: "hidden" }}>
|
||||
<Modal.Header py={0}>
|
||||
<Modal.Title fw={500}>{space?.name}</Modal.Title>
|
||||
<Modal.CloseButton />
|
||||
</Modal.Header>
|
||||
<Modal.Body>
|
||||
<div style={{ height: rem("600px") }}>
|
||||
<ScrollArea h="600" w="100%" scrollbarSize={5}>
|
||||
<RecycledPagesList spaceId={space.id} />
|
||||
</ScrollArea>
|
||||
</div>
|
||||
</Modal.Body>
|
||||
</Modal.Content>
|
||||
</Modal.Root>
|
||||
</>
|
||||
)
|
||||
}
|
||||
106
apps/client/src/features/space/components/recycled-pages.tsx
Normal file
106
apps/client/src/features/space/components/recycled-pages.tsx
Normal file
@ -0,0 +1,106 @@
|
||||
import { useDeletedPagesQuery, useRestorePageMutation, useDeletePageMutation } from "@/features/page/queries/page-query.ts";
|
||||
import { modals } from "@mantine/modals";
|
||||
import { ActionIcon, Menu, Table, Text } from "@mantine/core";
|
||||
import { IconDots } from "@tabler/icons-react";
|
||||
|
||||
interface RecycledPagesProps {
|
||||
spaceId: string;
|
||||
readOnly?: boolean;
|
||||
}
|
||||
|
||||
export default function RecycledPagesList({
|
||||
spaceId,
|
||||
}: RecycledPagesProps) {
|
||||
const { data, isLoading } = useDeletedPagesQuery(spaceId);
|
||||
const restorePageMutation = useRestorePageMutation();
|
||||
const removePageMutation = useDeletePageMutation();
|
||||
|
||||
const handleRestorePage = async (pageId: string) => {
|
||||
await restorePageMutation.mutateAsync(pageId);
|
||||
};
|
||||
|
||||
const handleRemovePage = async (pageId: string) => {
|
||||
await removePageMutation.mutateAsync(pageId);
|
||||
};
|
||||
|
||||
const openRemovePageModal = (pageId: string) =>
|
||||
modals.openConfirmModal({
|
||||
title: "Delete page permanently",
|
||||
children: (
|
||||
<Text size="sm">
|
||||
Are you sure you want to permanently delete this page ?
|
||||
</Text>
|
||||
),
|
||||
centered: true,
|
||||
labels: { confirm: "Delete", cancel: "Cancel" },
|
||||
confirmProps: { color: "red" },
|
||||
onConfirm: () => handleRemovePage(pageId),
|
||||
});
|
||||
|
||||
const openRestorePageModal = (pageId: string) =>
|
||||
modals.openConfirmModal({
|
||||
title: "Restore page",
|
||||
children: (
|
||||
<Text size="sm">
|
||||
"Restore this page ?"
|
||||
</Text>
|
||||
),
|
||||
centered: true,
|
||||
labels: { confirm: "Restore", cancel: "Cancel" },
|
||||
confirmProps: { color: "blue" },
|
||||
onConfirm: () => handleRestorePage(pageId)
|
||||
})
|
||||
|
||||
return (
|
||||
<>
|
||||
{data && (
|
||||
<Table>
|
||||
<Table.Thead>
|
||||
<Table.Th>Deleted Pages</Table.Th>
|
||||
</Table.Thead>
|
||||
|
||||
<Table.Tbody>
|
||||
{data?.items.map((page) => (
|
||||
<Table.Tr>
|
||||
<Table.Td>
|
||||
<div>
|
||||
<Text fz="sm" fw={500}>
|
||||
{page?.title}
|
||||
</Text>
|
||||
</div>
|
||||
</Table.Td>
|
||||
|
||||
<Table.Td>
|
||||
{(
|
||||
<Menu>
|
||||
<Menu.Target>
|
||||
<ActionIcon variant="subtle" c="gray">
|
||||
<IconDots size={20} stroke={2} />
|
||||
</ActionIcon>
|
||||
</Menu.Target>
|
||||
|
||||
<Menu.Dropdown>
|
||||
<Menu.Item
|
||||
onClick={() =>
|
||||
openRestorePageModal(page.id)
|
||||
}>
|
||||
Restore Page
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
onClick={() =>
|
||||
openRemovePageModal
|
||||
}>
|
||||
Delete Page permanently
|
||||
</Menu.Item>
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
)}
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@ -14,6 +14,7 @@ import {
|
||||
IconPlus,
|
||||
IconSearch,
|
||||
IconSettings,
|
||||
IconTrash,
|
||||
} from '@tabler/icons-react';
|
||||
|
||||
import classes from './space-sidebar.module.css';
|
||||
@ -25,6 +26,7 @@ import { Link, useLocation, useParams } from 'react-router-dom';
|
||||
import clsx from 'clsx';
|
||||
import { useDisclosure } from '@mantine/hooks';
|
||||
import SpaceSettingsModal from '@/features/space/components/settings-modal.tsx';
|
||||
import RecycleBinModal from "@/features/space/components/recycle-bin-modal.tsx";
|
||||
import { useGetSpaceBySlugQuery } from '@/features/space/queries/space-query.ts';
|
||||
import { getSpaceUrl } from '@/lib/config.ts';
|
||||
import SpaceTree from '@/features/page/tree/components/space-tree.tsx';
|
||||
@ -41,6 +43,8 @@ export function SpaceSidebar() {
|
||||
const location = useLocation();
|
||||
const [opened, { open: openSettings, close: closeSettings }] =
|
||||
useDisclosure(false);
|
||||
const [openedRecycleBin, { open: openRecycleBin, close: closeRecycleBin }] =
|
||||
useDisclosure(false);
|
||||
const { spaceSlug } = useParams();
|
||||
const { data: space, isLoading, isError } = useGetSpaceBySlugQuery(spaceSlug);
|
||||
|
||||
@ -113,6 +117,17 @@ export function SpaceSidebar() {
|
||||
</div>
|
||||
</UnstyledButton>
|
||||
|
||||
<UnstyledButton className={classes.menu} onClick={openRecycleBin}>
|
||||
<div className={classes.menuItemInner}>
|
||||
<IconTrash
|
||||
size={18}
|
||||
className={classes.menuItemIcon}
|
||||
stroke={2}
|
||||
/>
|
||||
<span>Recycle Bin</span>
|
||||
</div>
|
||||
</UnstyledButton>
|
||||
|
||||
{spaceAbility.can(
|
||||
SpaceCaslAction.Manage,
|
||||
SpaceCaslSubject.Page
|
||||
@ -179,6 +194,12 @@ export function SpaceSidebar() {
|
||||
spaceId={space?.slug}
|
||||
/>
|
||||
|
||||
<RecycleBinModal
|
||||
opened={openedRecycleBin}
|
||||
onClose={closeRecycleBin}
|
||||
spaceId={space?.slug}
|
||||
/>
|
||||
|
||||
<SearchSpotlight spaceId={space.id} />
|
||||
</>
|
||||
);
|
||||
|
||||
7
apps/server/src/core/page/dto/deleted-page.dto.ts
Normal file
7
apps/server/src/core/page/dto/deleted-page.dto.ts
Normal file
@ -0,0 +1,7 @@
|
||||
import { IsOptional, IsString } from 'class-validator';
|
||||
|
||||
export class DeletedPageDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
spaceId: string;
|
||||
}
|
||||
@ -27,6 +27,7 @@ import {
|
||||
import SpaceAbilityFactory from '../casl/abilities/space-ability.factory';
|
||||
import { PageRepo } from '@docmost/db/repos/page/page.repo';
|
||||
import { RecentPageDto } from './dto/recent-page.dto';
|
||||
import { DeletedPageDto } from './dto/deleted-page.dto';
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Controller('pages')
|
||||
@ -112,6 +113,23 @@ export class PageController {
|
||||
await this.pageService.forceDelete(pageIdDto.pageId);
|
||||
}
|
||||
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Post('remove')
|
||||
async remove(@Body() pageIdDto: PageIdDto, @AuthUser() user: User) {
|
||||
const page = await this.pageRepo.findById(pageIdDto.pageId);
|
||||
|
||||
if (!page) {
|
||||
throw new NotFoundException('Page not found');
|
||||
}
|
||||
|
||||
const ability = await this.spaceAbility.createForUser(user, page.spaceId);
|
||||
if (ability.cannot(SpaceCaslAction.Manage, SpaceCaslSubject.Page)) {
|
||||
throw new ForbiddenException();
|
||||
}
|
||||
|
||||
await this.pageService.remove(pageIdDto.pageId);
|
||||
}
|
||||
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Post('restore')
|
||||
async restore(@Body() pageIdDto: PageIdDto) {
|
||||
@ -144,6 +162,30 @@ export class PageController {
|
||||
return this.pageService.getRecentPages(user.id, pagination);
|
||||
}
|
||||
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Post('deleted')
|
||||
async getDeletedPages(
|
||||
@Body() deletedPageDto: DeletedPageDto,
|
||||
@Body() pagination: PaginationOptions,
|
||||
@AuthUser() user: User,
|
||||
) {
|
||||
if (deletedPageDto.spaceId) {
|
||||
const ability = await this.spaceAbility.createForUser(
|
||||
user,
|
||||
deletedPageDto.spaceId,
|
||||
);
|
||||
|
||||
if (ability.cannot(SpaceCaslAction.Read, SpaceCaslSubject.Page)) {
|
||||
throw new ForbiddenException();
|
||||
}
|
||||
|
||||
return this.pageService.getDeletedSpacePages(
|
||||
deletedPageDto.spaceId,
|
||||
pagination,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: scope to workspaces
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Post('/history')
|
||||
|
||||
@ -228,6 +228,7 @@ export class PageService {
|
||||
])
|
||||
.select((eb) => this.withHasChildren(eb))
|
||||
.where('id', '=', childPageId)
|
||||
.where('deletedAt', 'is not', null)
|
||||
.unionAll((exp) =>
|
||||
exp
|
||||
.selectFrom('pages as p')
|
||||
@ -281,10 +282,21 @@ export class PageService {
|
||||
return await this.pageRepo.getRecentPages(userId, pagination);
|
||||
}
|
||||
|
||||
async getDeletedSpacePages(
|
||||
spaceId: string,
|
||||
pagination: PaginationOptions,
|
||||
): Promise<PaginationResult<Page>> {
|
||||
return await this.pageRepo.getDeletedPagesInSpace(spaceId, pagination);
|
||||
}
|
||||
|
||||
async forceDelete(pageId: string): Promise<void> {
|
||||
await this.pageRepo.deletePage(pageId);
|
||||
}
|
||||
|
||||
async remove(pageId: string): Promise<void> {
|
||||
await this.pageRepo.removePage(pageId);
|
||||
}
|
||||
|
||||
async restore(pageId: string): Promise<void> {
|
||||
await this.pageRepo.restorePage(pageId);
|
||||
}
|
||||
|
||||
@ -106,7 +106,7 @@ export class PageRepo {
|
||||
.executeTakeFirst();
|
||||
}
|
||||
|
||||
async deletePage(pageId: string): Promise<void> {
|
||||
async removePage(pageId: string): Promise<void> {
|
||||
let query = this.db.updateTable('pages').set({ deletedAt: new Date() });
|
||||
|
||||
if (isValidUUID(pageId)) {
|
||||
@ -118,6 +118,18 @@ export class PageRepo {
|
||||
await query.execute();
|
||||
}
|
||||
|
||||
async deletePage(pageId: string): Promise<void> {
|
||||
let query = this.db.deleteFrom('pages');
|
||||
|
||||
if (isValidUUID(pageId)) {
|
||||
query = query.where('id', '=', pageId);
|
||||
} else {
|
||||
query = query.where('slugId', '=', pageId);
|
||||
}
|
||||
|
||||
await query.execute();
|
||||
}
|
||||
|
||||
async restorePage(pageId: string): Promise<void> {
|
||||
let query = this.db.updateTable('pages').set({ deletedAt: null });
|
||||
|
||||
@ -164,6 +176,23 @@ export class PageRepo {
|
||||
return result;
|
||||
}
|
||||
|
||||
async getDeletedPagesInSpace(spaceId: string, pagination: PaginationOptions) {
|
||||
const query = this.db
|
||||
.selectFrom('pages')
|
||||
.select(this.baseFields)
|
||||
.select((eb) => this.withSpace(eb))
|
||||
.where('spaceId', '=', spaceId)
|
||||
.where('deletedAt', 'is not', null)
|
||||
.orderBy('updatedAt', 'desc');
|
||||
|
||||
const result = executeWithPagination(query, {
|
||||
page: pagination.page,
|
||||
perPage: pagination.limit,
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
withSpace(eb: ExpressionBuilder<DB, 'pages'>) {
|
||||
return jsonObjectFrom(
|
||||
eb
|
||||
|
||||
Reference in New Issue
Block a user