mirror of
https://github.com/docmost/docmost
synced 2025-03-28 21:13:28 +00:00
Merge 5265323fe24fa7535d4b6ed25f3bc809eb9b814d into a74d3feae425063e559d9d185cdbf2af1fa61885
This commit is contained in:
commit
c0cd55c29e
@ -14,6 +14,9 @@ import {
|
||||
movePage,
|
||||
getPageBreadcrumbs,
|
||||
getRecentChanges,
|
||||
getDeletedPages,
|
||||
restorePage,
|
||||
removePage,
|
||||
} from "@/features/page/services/page-service";
|
||||
import {
|
||||
IMovePage,
|
||||
@ -89,6 +92,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() {
|
||||
const { t } = useTranslation();
|
||||
return useMutation({
|
||||
@ -108,6 +123,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> {
|
||||
@ -160,3 +187,13 @@ export function useRecentChangesQuery(
|
||||
refetchOnMount: true,
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeletedPagesQuery(
|
||||
spaceId: string,
|
||||
): UseQueryResult<IPagination<IPage>, Error> {
|
||||
return useQuery({
|
||||
queryKey: ["deleted-pages", spaceId],
|
||||
queryFn: () => getDeletedPages(spaceId),
|
||||
refetchOnMount: true,
|
||||
});
|
||||
}
|
||||
|
@ -26,10 +26,23 @@ 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 });
|
||||
}
|
||||
|
||||
export async function movePage(data: IMovePage): Promise<void> {
|
||||
await api.post<void>("/pages/move", data);
|
||||
}
|
||||
|
@ -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";
|
||||
@ -28,7 +28,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 removePageMutation = useRemovePageMutation();
|
||||
const movePageMutation = useMovePageMutation();
|
||||
const navigate = useNavigate();
|
||||
const { spaceSlug } = useParams();
|
||||
@ -208,7 +208,7 @@ export function useTreeMutation<T>(spaceId: string) {
|
||||
|
||||
const onDelete: DeleteHandler<T> = async (args: { ids: string[] }) => {
|
||||
try {
|
||||
await deletePageMutation.mutateAsync(args.ids[0]);
|
||||
await removePageMutation.mutateAsync(args.ids[0]);
|
||||
|
||||
const node = tree.find(args.ids[0]);
|
||||
if (!node) {
|
||||
|
@ -0,0 +1,61 @@
|
||||
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"
|
||||
import {
|
||||
SpaceCaslAction,
|
||||
SpaceCaslSubject,
|
||||
} from "@/features/space/permissions/permissions.type.ts";
|
||||
|
||||
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 = useSpaceAbility(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}
|
||||
readOnly={spaceAbility.cannot(
|
||||
SpaceCaslAction.Manage,
|
||||
SpaceCaslSubject.Page
|
||||
)}
|
||||
/>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
</Modal.Body>
|
||||
</Modal.Content>
|
||||
</Modal.Root>
|
||||
</>
|
||||
)
|
||||
}
|
109
apps/client/src/features/space/components/recycled-pages.tsx
Normal file
109
apps/client/src/features/space/components/recycled-pages.tsx
Normal file
@ -0,0 +1,109 @@
|
||||
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,
|
||||
readOnly,
|
||||
}: RecycledPagesProps) {
|
||||
const { data, isLoading } = useDeletedPagesQuery(spaceId);
|
||||
const restorePageMutation = useRestorePageMutation();
|
||||
const removePageMutation = useDeletePageMutation();
|
||||
|
||||
const handleRestorePage = async (pageId: string) => {
|
||||
await restorePageMutation.mutateAsync(pageId);
|
||||
window.location.reload();
|
||||
};
|
||||
|
||||
const handleRemovePage = async (pageId: string) => {
|
||||
await removePageMutation.mutateAsync(pageId);
|
||||
window.location.reload();
|
||||
};
|
||||
|
||||
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 && data.items.length > 0 ? (
|
||||
<Table highlightOnHover verticalSpacing="sm">
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>Deleted Pages</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
|
||||
<Table.Tbody>
|
||||
{data?.items.map((page) => (
|
||||
<Table.Tr key={page.id}>
|
||||
<Table.Td>
|
||||
<Text component="div" fz="sm" fw={500}>
|
||||
{page?.title || "Untitled"}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
|
||||
<Table.Td>
|
||||
{!readOnly && (
|
||||
<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(page.id)}>
|
||||
Delete Page permanently
|
||||
</Menu.Item>
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
)}
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
) : (
|
||||
<Text size="sm" c="dimmed">
|
||||
No deleted pages
|
||||
</Text>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
@ -15,6 +15,7 @@ import {
|
||||
IconPlus,
|
||||
IconSearch,
|
||||
IconSettings,
|
||||
IconTrash,
|
||||
} from "@tabler/icons-react";
|
||||
|
||||
import classes from "./space-sidebar.module.css";
|
||||
@ -26,6 +27,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";
|
||||
@ -198,6 +200,8 @@ function SpaceMenu({ spaceId, onSpaceSettings }: SpaceMenuProps) {
|
||||
useDisclosure(false);
|
||||
const [exportOpened, { open: openExportModal, close: closeExportModal }] =
|
||||
useDisclosure(false);
|
||||
const [openedRecycleBin, { open: openRecycleBin, close: closeRecycleBin }] =
|
||||
useDisclosure(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
@ -241,6 +245,13 @@ function SpaceMenu({ spaceId, onSpaceSettings }: SpaceMenuProps) {
|
||||
>
|
||||
{t("Space settings")}
|
||||
</Menu.Item>
|
||||
|
||||
<Menu.Item
|
||||
onClick={openRecycleBin}
|
||||
leftSection={<IconTrash size={16} />}
|
||||
>
|
||||
{t("Recycle bin")}
|
||||
</Menu.Item>
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
|
||||
@ -256,6 +267,12 @@ function SpaceMenu({ spaceId, onSpaceSettings }: SpaceMenuProps) {
|
||||
open={exportOpened}
|
||||
onClose={closeExportModal}
|
||||
/>
|
||||
|
||||
<RecycleBinModal
|
||||
opened={openedRecycleBin}
|
||||
onClose={closeRecycleBin}
|
||||
spaceId={spaceId}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
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;
|
||||
}
|
@ -12,6 +12,7 @@ import { PageService } from './services/page.service';
|
||||
import { CreatePageDto } from './dto/create-page.dto';
|
||||
import { UpdatePageDto } from './dto/update-page.dto';
|
||||
import { MovePageDto } from './dto/move-page.dto';
|
||||
// import { RestorePageDto } from './dto/restore-page.dto';
|
||||
import { PageHistoryIdDto, PageIdDto, PageInfoDto } from './dto/page.dto';
|
||||
import { PageHistoryService } from './services/page-history.service';
|
||||
import { AuthUser } from '../../common/decorators/auth-user.decorator';
|
||||
@ -27,6 +28,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')
|
||||
@ -115,10 +117,38 @@ 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) {
|
||||
// await this.pageService.restore(deletePageDto.id);
|
||||
async restore(@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.restore(pageIdDto.pageId);
|
||||
}
|
||||
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ -147,6 +177,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')
|
||||
|
@ -162,9 +162,11 @@ export class PageService {
|
||||
'parentPageId',
|
||||
'spaceId',
|
||||
'creatorId',
|
||||
'deletedAt',
|
||||
])
|
||||
.select((eb) => this.withHasChildren(eb))
|
||||
.orderBy('position', 'asc')
|
||||
.where('deletedAt', 'is', null)
|
||||
.where('spaceId', '=', spaceId);
|
||||
|
||||
if (pageId) {
|
||||
@ -228,6 +230,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,9 +284,24 @@ 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);
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
|
@ -116,6 +116,35 @@ export class PageRepo {
|
||||
.executeTakeFirst();
|
||||
}
|
||||
|
||||
async removePage(pageId: string): Promise<void> {
|
||||
const currentDate = new Date();
|
||||
|
||||
const descendants = await this.db
|
||||
.withRecursive('page_descendants', (db) =>
|
||||
db
|
||||
.selectFrom('pages')
|
||||
.select(['id'])
|
||||
.where('id', '=', pageId)
|
||||
.unionAll((exp) =>
|
||||
exp
|
||||
.selectFrom('pages as p')
|
||||
.select(['p.id'])
|
||||
.innerJoin('page_descendants as pd', 'pd.id', 'p.parentPageId')
|
||||
)
|
||||
)
|
||||
.selectFrom('page_descendants')
|
||||
.selectAll()
|
||||
.execute();
|
||||
|
||||
const pageIds = descendants.map((d) => d.id);
|
||||
|
||||
await this.db
|
||||
.updateTable('pages')
|
||||
.set({ deletedAt: currentDate })
|
||||
.where('id', 'in', pageIds)
|
||||
.execute();
|
||||
}
|
||||
|
||||
async deletePage(pageId: string): Promise<void> {
|
||||
let query = this.db.deleteFrom('pages');
|
||||
|
||||
@ -128,12 +157,40 @@ export class PageRepo {
|
||||
await query.execute();
|
||||
}
|
||||
|
||||
async restorePage(pageId: string): Promise<void> {
|
||||
const pages = await this.db
|
||||
.withRecursive('page_descendants', (db) =>
|
||||
db
|
||||
.selectFrom('pages')
|
||||
.select(['id'])
|
||||
.where('id', '=', pageId)
|
||||
.unionAll((exp) =>
|
||||
exp
|
||||
.selectFrom('pages as p')
|
||||
.select(['p.id'])
|
||||
.innerJoin('page_descendants as pd', 'pd.id', 'p.parentPageId')
|
||||
)
|
||||
)
|
||||
.selectFrom('page_descendants')
|
||||
.selectAll()
|
||||
.execute();
|
||||
|
||||
const pageIds = pages.map((p) => p.id);
|
||||
|
||||
await this.db
|
||||
.updateTable('pages')
|
||||
.set({ deletedAt: null, parentPageId: null })
|
||||
.where('id', 'in', pageIds)
|
||||
.execute();
|
||||
}
|
||||
|
||||
async getRecentPagesInSpace(spaceId: string, pagination: PaginationOptions) {
|
||||
const query = this.db
|
||||
.selectFrom('pages')
|
||||
.select(this.baseFields)
|
||||
.select((eb) => this.withSpace(eb))
|
||||
.where('spaceId', '=', spaceId)
|
||||
.where('deletedAt', '=', null)
|
||||
.orderBy('updatedAt', 'desc');
|
||||
|
||||
const result = executeWithPagination(query, {
|
||||
@ -152,6 +209,24 @@ export class PageRepo {
|
||||
.select(this.baseFields)
|
||||
.select((eb) => this.withSpace(eb))
|
||||
.where('spaceId', 'in', userSpaceIds)
|
||||
.where('deletedAt', '=', null)
|
||||
.orderBy('updatedAt', 'desc');
|
||||
|
||||
const result = executeWithPagination(query, {
|
||||
page: pagination.page,
|
||||
perPage: pagination.limit,
|
||||
});
|
||||
|
||||
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, {
|
||||
|
Loading…
x
Reference in New Issue
Block a user