added recycle bin modal, updated api routes

This commit is contained in:
Eddy Oyieko
2024-09-18 11:18:32 +03:00
parent 4e69f91420
commit bda2dda12d
10 changed files with 316 additions and 3 deletions

View File

@ -14,6 +14,9 @@ import {
movePage, movePage,
getPageBreadcrumbs, getPageBreadcrumbs,
getRecentChanges, getRecentChanges,
getDeletedPages,
restorePage,
removePage,
} from "@/features/page/services/page-service"; } from "@/features/page/services/page-service";
import { import {
IMovePage, 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() { export function useDeletePageMutation() {
return useMutation({ return useMutation({
mutationFn: (pageId: string) => deletePage(pageId), 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( export function useGetSidebarPagesQuery(
data: SidebarPagesParams, data: SidebarPagesParams,
): UseQueryResult<IPagination<IPage>, Error> { ): UseQueryResult<IPagination<IPage>, Error> {
@ -143,3 +170,12 @@ export function useRecentChangesQuery(
refetchOnMount: true, refetchOnMount: true,
}); });
} }
export function useDeletedPagesQuery(
spaceId: string,
): UseQueryResult<IPagination<IPage>, Error> {
return useQuery({
queryKey: ["deleted-pages", spaceId],
queryFn: () => getDeletedPages(spaceId),
});
}

View File

@ -26,10 +26,19 @@ export async function updatePage(data: Partial<IPageInput>): Promise<IPage> {
return req.data; 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> { export async function deletePage(pageId: string): Promise<void> {
await api.post("/pages/delete", { pageId }); 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> { export async function restorePage(pageId: string): Promise<void> {
await api.post("/pages/restore", { pageId }); await api.post("/pages/restore", { pageId });
} }

View File

@ -13,7 +13,7 @@ import { IMovePage, IPage } from "@/features/page/types/page.types.ts";
import { useNavigate, useParams } from "react-router-dom"; import { useNavigate, useParams } from "react-router-dom";
import { import {
useCreatePageMutation, useCreatePageMutation,
useDeletePageMutation, useRemovePageMutation,
useMovePageMutation, useMovePageMutation,
useUpdatePageMutation, useUpdatePageMutation,
} from "@/features/page/queries/page-query.ts"; } 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 tree = useMemo(() => new SimpleTree<SpaceTreeNode>(data), [data]);
const createPageMutation = useCreatePageMutation(); const createPageMutation = useCreatePageMutation();
const updatePageMutation = useUpdatePageMutation(); const updatePageMutation = useUpdatePageMutation();
const deletePageMutation = useDeletePageMutation(); const deletePageMutation = useRemovePageMutation();
const movePageMutation = useMovePageMutation(); const movePageMutation = useMovePageMutation();
const navigate = useNavigate(); const navigate = useNavigate();
const { spaceSlug } = useParams(); const { spaceSlug } = useParams();

View File

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

View 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>
)}
</>
);
}

View File

@ -14,6 +14,7 @@ import {
IconPlus, IconPlus,
IconSearch, IconSearch,
IconSettings, IconSettings,
IconTrash,
} from '@tabler/icons-react'; } from '@tabler/icons-react';
import classes from './space-sidebar.module.css'; import classes from './space-sidebar.module.css';
@ -25,6 +26,7 @@ import { Link, useLocation, useParams } from 'react-router-dom';
import clsx from 'clsx'; import clsx from 'clsx';
import { useDisclosure } from '@mantine/hooks'; import { useDisclosure } from '@mantine/hooks';
import SpaceSettingsModal from '@/features/space/components/settings-modal.tsx'; 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 { useGetSpaceBySlugQuery } from '@/features/space/queries/space-query.ts';
import { getSpaceUrl } from '@/lib/config.ts'; import { getSpaceUrl } from '@/lib/config.ts';
import SpaceTree from '@/features/page/tree/components/space-tree.tsx'; import SpaceTree from '@/features/page/tree/components/space-tree.tsx';
@ -41,6 +43,8 @@ export function SpaceSidebar() {
const location = useLocation(); const location = useLocation();
const [opened, { open: openSettings, close: closeSettings }] = const [opened, { open: openSettings, close: closeSettings }] =
useDisclosure(false); useDisclosure(false);
const [openedRecycleBin, { open: openRecycleBin, close: closeRecycleBin }] =
useDisclosure(false);
const { spaceSlug } = useParams(); const { spaceSlug } = useParams();
const { data: space, isLoading, isError } = useGetSpaceBySlugQuery(spaceSlug); const { data: space, isLoading, isError } = useGetSpaceBySlugQuery(spaceSlug);
@ -113,6 +117,17 @@ export function SpaceSidebar() {
</div> </div>
</UnstyledButton> </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( {spaceAbility.can(
SpaceCaslAction.Manage, SpaceCaslAction.Manage,
SpaceCaslSubject.Page SpaceCaslSubject.Page
@ -179,6 +194,12 @@ export function SpaceSidebar() {
spaceId={space?.slug} spaceId={space?.slug}
/> />
<RecycleBinModal
opened={openedRecycleBin}
onClose={closeRecycleBin}
spaceId={space?.slug}
/>
<SearchSpotlight spaceId={space.id} /> <SearchSpotlight spaceId={space.id} />
</> </>
); );

View File

@ -0,0 +1,7 @@
import { IsOptional, IsString } from 'class-validator';
export class DeletedPageDto {
@IsOptional()
@IsString()
spaceId: string;
}

View File

@ -27,6 +27,7 @@ import {
import SpaceAbilityFactory from '../casl/abilities/space-ability.factory'; import SpaceAbilityFactory from '../casl/abilities/space-ability.factory';
import { PageRepo } from '@docmost/db/repos/page/page.repo'; import { PageRepo } from '@docmost/db/repos/page/page.repo';
import { RecentPageDto } from './dto/recent-page.dto'; import { RecentPageDto } from './dto/recent-page.dto';
import { DeletedPageDto } from './dto/deleted-page.dto';
@UseGuards(JwtAuthGuard) @UseGuards(JwtAuthGuard)
@Controller('pages') @Controller('pages')
@ -112,6 +113,23 @@ export class PageController {
await this.pageService.forceDelete(pageIdDto.pageId); 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) @HttpCode(HttpStatus.OK)
@Post('restore') @Post('restore')
async restore(@Body() pageIdDto: PageIdDto) { async restore(@Body() pageIdDto: PageIdDto) {
@ -144,6 +162,30 @@ export class PageController {
return this.pageService.getRecentPages(user.id, pagination); 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 // TODO: scope to workspaces
@HttpCode(HttpStatus.OK) @HttpCode(HttpStatus.OK)
@Post('/history') @Post('/history')

View File

@ -228,6 +228,7 @@ export class PageService {
]) ])
.select((eb) => this.withHasChildren(eb)) .select((eb) => this.withHasChildren(eb))
.where('id', '=', childPageId) .where('id', '=', childPageId)
.where('deletedAt', 'is not', null)
.unionAll((exp) => .unionAll((exp) =>
exp exp
.selectFrom('pages as p') .selectFrom('pages as p')
@ -281,10 +282,21 @@ export class PageService {
return await this.pageRepo.getRecentPages(userId, pagination); 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> { async forceDelete(pageId: string): Promise<void> {
await this.pageRepo.deletePage(pageId); await this.pageRepo.deletePage(pageId);
} }
async remove(pageId: string): Promise<void> {
await this.pageRepo.removePage(pageId);
}
async restore(pageId: string): Promise<void> { async restore(pageId: string): Promise<void> {
await this.pageRepo.restorePage(pageId); await this.pageRepo.restorePage(pageId);
} }

View File

@ -106,7 +106,7 @@ export class PageRepo {
.executeTakeFirst(); .executeTakeFirst();
} }
async deletePage(pageId: string): Promise<void> { async removePage(pageId: string): Promise<void> {
let query = this.db.updateTable('pages').set({ deletedAt: new Date() }); let query = this.db.updateTable('pages').set({ deletedAt: new Date() });
if (isValidUUID(pageId)) { if (isValidUUID(pageId)) {
@ -118,6 +118,18 @@ export class PageRepo {
await query.execute(); 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> { async restorePage(pageId: string): Promise<void> {
let query = this.db.updateTable('pages').set({ deletedAt: null }); let query = this.db.updateTable('pages').set({ deletedAt: null });
@ -164,6 +176,23 @@ export class PageRepo {
return result; 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'>) { withSpace(eb: ExpressionBuilder<DB, 'pages'>) {
return jsonObjectFrom( return jsonObjectFrom(
eb eb