mirror of
https://github.com/docmost/docmost.git
synced 2025-11-13 04:22:37 +10:00
feat: duplicate page in same space (#1394)
* fix internal links in copies pages * feat: duplicate page in same space * fix children
This commit is contained in:
@ -222,7 +222,9 @@
|
||||
"Anyone with this link can join this workspace.": "Anyone with this link can join this workspace.",
|
||||
"Invite link": "Invite link",
|
||||
"Copy": "Copy",
|
||||
"Copy to space": "Copy to space",
|
||||
"Copied": "Copied",
|
||||
"Duplicate": "Duplicate",
|
||||
"Select a user": "Select a user",
|
||||
"Select a group": "Select a group",
|
||||
"Export all pages and attachments in this space.": "Export all pages and attachments in this space.",
|
||||
@ -390,6 +392,7 @@
|
||||
"Copy page": "Copy page",
|
||||
"Copy page to a different space.": "Copy page to a different space.",
|
||||
"Page copied successfully": "Page copied successfully",
|
||||
"Page duplicated successfully": "Page duplicated successfully",
|
||||
"Find": "Find",
|
||||
"Not found": "Not found",
|
||||
"Previous Match (Shift+Enter)": "Previous Match (Shift+Enter)",
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { Modal, Button, Group, Text } from "@mantine/core";
|
||||
import { copyPageToSpace } from "@/features/page/services/page-service.ts";
|
||||
import { duplicatePage } from "@/features/page/services/page-service.ts";
|
||||
import { useState } from "react";
|
||||
import { notifications } from "@mantine/notifications";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@ -30,7 +30,7 @@ export default function CopyPageModal({
|
||||
if (!targetSpace) return;
|
||||
|
||||
try {
|
||||
const copiedPage = await copyPageToSpace({
|
||||
const copiedPage = await duplicatePage({
|
||||
pageId,
|
||||
spaceId: targetSpace.id,
|
||||
});
|
||||
|
||||
@ -42,8 +42,8 @@ export async function movePageToSpace(data: IMovePageToSpace): Promise<void> {
|
||||
await api.post<void>("/pages/move-to-space", data);
|
||||
}
|
||||
|
||||
export async function copyPageToSpace(data: ICopyPageToSpace): Promise<IPage> {
|
||||
const req = await api.post<IPage>("/pages/copy-to-space", data);
|
||||
export async function duplicatePage(data: ICopyPageToSpace): Promise<IPage> {
|
||||
const req = await api.post<IPage>("/pages/duplicate", data);
|
||||
return req.data;
|
||||
}
|
||||
|
||||
|
||||
@ -1,4 +1,10 @@
|
||||
import { NodeApi, NodeRendererProps, Tree, TreeApi } from "react-arborist";
|
||||
import {
|
||||
NodeApi,
|
||||
NodeRendererProps,
|
||||
Tree,
|
||||
TreeApi,
|
||||
SimpleTree,
|
||||
} from "react-arborist";
|
||||
import { atom, useAtom } from "jotai";
|
||||
import { treeApiAtom } from "@/features/page/tree/atoms/tree-api-atom.ts";
|
||||
import {
|
||||
@ -66,6 +72,7 @@ import MovePageModal from "../../components/move-page-modal.tsx";
|
||||
import { mobileSidebarAtom } from "@/components/layouts/global/hooks/atoms/sidebar-atom.ts";
|
||||
import { useToggleSidebar } from "@/components/layouts/global/hooks/hooks/use-toggle-sidebar.ts";
|
||||
import CopyPageModal from "../../components/copy-page-modal.tsx";
|
||||
import { duplicatePage } from "../../services/page-service.ts";
|
||||
|
||||
interface SpaceTreeProps {
|
||||
spaceId: string;
|
||||
@ -396,7 +403,7 @@ function Node({ node, style, dragHandle, tree }: NodeRendererProps<any>) {
|
||||
<span className={classes.text}>{node.data.name || t("untitled")}</span>
|
||||
|
||||
<div className={classes.actions}>
|
||||
<NodeMenu node={node} treeApi={tree} />
|
||||
<NodeMenu node={node} treeApi={tree} spaceId={node.data.spaceId} />
|
||||
|
||||
{!tree.props.disableEdit && (
|
||||
<CreateNode
|
||||
@ -449,13 +456,16 @@ function CreateNode({ node, treeApi, onExpandTree }: CreateNodeProps) {
|
||||
interface NodeMenuProps {
|
||||
node: NodeApi<SpaceTreeNode>;
|
||||
treeApi: TreeApi<SpaceTreeNode>;
|
||||
spaceId: string;
|
||||
}
|
||||
|
||||
function NodeMenu({ node, treeApi }: NodeMenuProps) {
|
||||
function NodeMenu({ node, treeApi, spaceId }: NodeMenuProps) {
|
||||
const { t } = useTranslation();
|
||||
const clipboard = useClipboard({ timeout: 500 });
|
||||
const { spaceSlug } = useParams();
|
||||
const { openDeleteModal } = useDeletePageModal();
|
||||
const [data, setData] = useAtom(treeDataAtom);
|
||||
const emit = useQueryEmit();
|
||||
const [exportOpened, { open: openExportModal, close: closeExportModal }] =
|
||||
useDisclosure(false);
|
||||
const [
|
||||
@ -474,6 +484,68 @@ function NodeMenu({ node, treeApi }: NodeMenuProps) {
|
||||
notifications.show({ message: t("Link copied") });
|
||||
};
|
||||
|
||||
const handleDuplicatePage = async () => {
|
||||
try {
|
||||
const duplicatedPage = await duplicatePage({
|
||||
pageId: node.id,
|
||||
});
|
||||
|
||||
// Find the index of the current node
|
||||
const parentId =
|
||||
node.parent?.id === "__REACT_ARBORIST_INTERNAL_ROOT__"
|
||||
? null
|
||||
: node.parent?.id;
|
||||
const siblings = parentId ? node.parent.children : treeApi?.props.data;
|
||||
const currentIndex =
|
||||
siblings?.findIndex((sibling) => sibling.id === node.id) || 0;
|
||||
const newIndex = currentIndex + 1;
|
||||
|
||||
// Add the duplicated page to the tree
|
||||
const treeNodeData: SpaceTreeNode = {
|
||||
id: duplicatedPage.id,
|
||||
slugId: duplicatedPage.slugId,
|
||||
name: duplicatedPage.title,
|
||||
position: duplicatedPage.position,
|
||||
spaceId: duplicatedPage.spaceId,
|
||||
parentPageId: duplicatedPage.parentPageId,
|
||||
icon: duplicatedPage.icon,
|
||||
hasChildren: duplicatedPage.hasChildren,
|
||||
children: [],
|
||||
};
|
||||
|
||||
// Update local tree
|
||||
const simpleTree = new SimpleTree(data);
|
||||
simpleTree.create({
|
||||
parentId,
|
||||
index: newIndex,
|
||||
data: treeNodeData,
|
||||
});
|
||||
setData(simpleTree.data);
|
||||
|
||||
// Emit socket event
|
||||
setTimeout(() => {
|
||||
emit({
|
||||
operation: "addTreeNode",
|
||||
spaceId: spaceId,
|
||||
payload: {
|
||||
parentId,
|
||||
index: newIndex,
|
||||
data: treeNodeData,
|
||||
},
|
||||
});
|
||||
}, 50);
|
||||
|
||||
notifications.show({
|
||||
message: t("Page duplicated successfully"),
|
||||
});
|
||||
} catch (err) {
|
||||
notifications.show({
|
||||
message: err.response?.data.message || "An error occurred",
|
||||
color: "red",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Menu shadow="md" width={200}>
|
||||
@ -518,6 +590,17 @@ function NodeMenu({ node, treeApi }: NodeMenuProps) {
|
||||
|
||||
{!(treeApi.props.disableEdit as boolean) && (
|
||||
<>
|
||||
<Menu.Item
|
||||
leftSection={<IconCopy size={16} />}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
handleDuplicatePage();
|
||||
}}
|
||||
>
|
||||
{t("Duplicate")}
|
||||
</Menu.Item>
|
||||
|
||||
<Menu.Item
|
||||
leftSection={<IconArrowRight size={16} />}
|
||||
onClick={(e) => {
|
||||
@ -537,7 +620,7 @@ function NodeMenu({ node, treeApi }: NodeMenuProps) {
|
||||
openCopyPageModal();
|
||||
}}
|
||||
>
|
||||
{t("Copy")}
|
||||
{t("Copy to space")}
|
||||
</Menu.Item>
|
||||
|
||||
<Menu.Divider />
|
||||
|
||||
@ -49,7 +49,7 @@ export interface IMovePageToSpace {
|
||||
|
||||
export interface ICopyPageToSpace {
|
||||
pageId: string;
|
||||
spaceId: string;
|
||||
spaceId?: string;
|
||||
}
|
||||
|
||||
export interface SidebarPagesParams {
|
||||
|
||||
@ -1,13 +1,13 @@
|
||||
import { IsString, IsNotEmpty } from 'class-validator';
|
||||
import { IsString, IsNotEmpty, IsOptional } from 'class-validator';
|
||||
|
||||
export class CopyPageToSpaceDto {
|
||||
export class DuplicatePageDto {
|
||||
@IsNotEmpty()
|
||||
@IsString()
|
||||
pageId: string;
|
||||
|
||||
@IsNotEmpty()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
spaceId: string;
|
||||
spaceId?: string;
|
||||
}
|
||||
|
||||
export type CopyPageMapEntry = {
|
||||
@ -28,7 +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 { CopyPageToSpaceDto } from './dto/copy-page.dto';
|
||||
import { DuplicatePageDto } from './dto/duplicate-page.dto';
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Controller('pages')
|
||||
@ -242,19 +242,15 @@ export class PageController {
|
||||
}
|
||||
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Post('copy-to-space')
|
||||
async copyPageToSpace(
|
||||
@Body() dto: CopyPageToSpaceDto,
|
||||
@AuthUser() user: User,
|
||||
) {
|
||||
@Post('duplicate')
|
||||
async duplicatePage(@Body() dto: DuplicatePageDto, @AuthUser() user: User) {
|
||||
const copiedPage = await this.pageRepo.findById(dto.pageId);
|
||||
if (!copiedPage) {
|
||||
throw new NotFoundException('Page to copy not found');
|
||||
}
|
||||
if (copiedPage.spaceId === dto.spaceId) {
|
||||
throw new BadRequestException('Page is already in this space');
|
||||
}
|
||||
|
||||
// If spaceId is provided, it's a copy to different space
|
||||
if (dto.spaceId) {
|
||||
const abilities = await Promise.all([
|
||||
this.spaceAbility.createForUser(user, copiedPage.spaceId),
|
||||
this.spaceAbility.createForUser(user, dto.spaceId),
|
||||
@ -268,7 +264,19 @@ export class PageController {
|
||||
throw new ForbiddenException();
|
||||
}
|
||||
|
||||
return this.pageService.copyPageToSpace(copiedPage, dto.spaceId, user);
|
||||
return this.pageService.duplicatePage(copiedPage, dto.spaceId, user);
|
||||
} else {
|
||||
// If no spaceId, it's a duplicate in same space
|
||||
const ability = await this.spaceAbility.createForUser(
|
||||
user,
|
||||
copiedPage.spaceId,
|
||||
);
|
||||
if (ability.cannot(SpaceCaslAction.Edit, SpaceCaslSubject.Page)) {
|
||||
throw new ForbiddenException();
|
||||
}
|
||||
|
||||
return this.pageService.duplicatePage(copiedPage, undefined, user);
|
||||
}
|
||||
}
|
||||
|
||||
@HttpCode(HttpStatus.OK)
|
||||
|
||||
@ -31,7 +31,10 @@ import {
|
||||
removeMarkTypeFromDoc,
|
||||
} from '../../../common/helpers/prosemirror/utils';
|
||||
import { jsonToNode, jsonToText } from 'src/collaboration/collaboration.util';
|
||||
import { CopyPageMapEntry, ICopyPageAttachment } from '../dto/copy-page.dto';
|
||||
import {
|
||||
CopyPageMapEntry,
|
||||
ICopyPageAttachment,
|
||||
} from '../dto/duplicate-page.dto';
|
||||
import { Node as PMNode } from '@tiptap/pm/model';
|
||||
import { StorageService } from '../../../integrations/storage/storage.service';
|
||||
|
||||
@ -258,11 +261,52 @@ export class PageService {
|
||||
});
|
||||
}
|
||||
|
||||
async copyPageToSpace(rootPage: Page, spaceId: string, authUser: User) {
|
||||
//TODO:
|
||||
// i. maintain internal links within copied pages
|
||||
async duplicatePage(
|
||||
rootPage: Page,
|
||||
targetSpaceId: string | undefined,
|
||||
authUser: User,
|
||||
) {
|
||||
const spaceId = targetSpaceId || rootPage.spaceId;
|
||||
const isDuplicateInSameSpace =
|
||||
!targetSpaceId || targetSpaceId === rootPage.spaceId;
|
||||
|
||||
const nextPosition = await this.nextPagePosition(spaceId);
|
||||
let nextPosition: string;
|
||||
|
||||
if (isDuplicateInSameSpace) {
|
||||
// For duplicate in same space, position right after the original page
|
||||
let siblingQuery = this.db
|
||||
.selectFrom('pages')
|
||||
.select(['position'])
|
||||
.where('spaceId', '=', rootPage.spaceId)
|
||||
.where('position', '>', rootPage.position);
|
||||
|
||||
if (rootPage.parentPageId) {
|
||||
siblingQuery = siblingQuery.where(
|
||||
'parentPageId',
|
||||
'=',
|
||||
rootPage.parentPageId,
|
||||
);
|
||||
} else {
|
||||
siblingQuery = siblingQuery.where('parentPageId', 'is', null);
|
||||
}
|
||||
|
||||
const nextSibling = await siblingQuery
|
||||
.orderBy('position', 'asc')
|
||||
.limit(1)
|
||||
.executeTakeFirst();
|
||||
|
||||
if (nextSibling) {
|
||||
nextPosition = generateJitteredKeyBetween(
|
||||
rootPage.position,
|
||||
nextSibling.position,
|
||||
);
|
||||
} else {
|
||||
nextPosition = generateJitteredKeyBetween(rootPage.position, null);
|
||||
}
|
||||
} else {
|
||||
// For copy to different space, position at the end
|
||||
nextPosition = await this.nextPagePosition(spaceId);
|
||||
}
|
||||
|
||||
const pages = await this.pageRepo.getPageAndDescendants(rootPage.id, {
|
||||
includeContent: true,
|
||||
@ -326,12 +370,38 @@ export class PageService {
|
||||
});
|
||||
}
|
||||
|
||||
// Update internal page links in mention nodes
|
||||
prosemirrorDoc.descendants((node: PMNode) => {
|
||||
if (
|
||||
node.type.name === 'mention' &&
|
||||
node.attrs.entityType === 'page'
|
||||
) {
|
||||
const referencedPageId = node.attrs.entityId;
|
||||
|
||||
// Check if the referenced page is within the pages being copied
|
||||
if (referencedPageId && pageMap.has(referencedPageId)) {
|
||||
const mappedPage = pageMap.get(referencedPageId);
|
||||
//@ts-ignore
|
||||
node.attrs.entityId = mappedPage.newPageId;
|
||||
//@ts-ignore
|
||||
node.attrs.slugId = mappedPage.newSlugId;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const prosemirrorJson = prosemirrorDoc.toJSON();
|
||||
|
||||
// Add "Copy of " prefix to the root page title only for duplicates in same space
|
||||
let title = page.title;
|
||||
if (isDuplicateInSameSpace && page.id === rootPage.id) {
|
||||
const originalTitle = page.title || 'Untitled';
|
||||
title = `Copy of ${originalTitle}`;
|
||||
}
|
||||
|
||||
return {
|
||||
id: pageFromMap.newPageId,
|
||||
slugId: pageFromMap.newSlugId,
|
||||
title: page.title,
|
||||
title: title,
|
||||
icon: page.icon,
|
||||
content: prosemirrorJson,
|
||||
textContent: jsonToText(prosemirrorJson),
|
||||
@ -401,9 +471,16 @@ export class PageService {
|
||||
}
|
||||
|
||||
const newPageId = pageMap.get(rootPage.id).newPageId;
|
||||
return await this.pageRepo.findById(newPageId, {
|
||||
const duplicatedPage = await this.pageRepo.findById(newPageId, {
|
||||
includeSpace: true,
|
||||
});
|
||||
|
||||
const hasChildren = pages.length > 1;
|
||||
|
||||
return {
|
||||
...duplicatedPage,
|
||||
hasChildren,
|
||||
};
|
||||
}
|
||||
|
||||
async movePage(dto: MovePageDto, movedPage: Page) {
|
||||
|
||||
Reference in New Issue
Block a user