mirror of
https://github.com/docmost/docmost.git
synced 2025-11-13 08:14:06 +10:00
feat: move page between spaces (#988)
* feat: Move the page to another space - The ability to move a page to another space has been added * feat: Move the page to another space * feat: Move the page to another space - Correction of the visibility attribute of elements that extend beyond the boundaries of the space selection modal window * feat: Move the page to another space - Added removal of query keys when moving pages * feat: Move the page to another space - Fix locales * feat: Move the page to another space * feat: Move the page to another space - Fix docker compose * feat: Move the page to another space * feat: Move the page to another space - Some refactor * feat: Move the page to another space - Attachments update * feat: Move the page to another space - The function of searching for attachments by page ID and updating attachments has been combined * feat: Move the page to another space - Fix variable name * feat: Move the page to another space - Move current space to parameter of component SpaceSelectionModal * refactor ui --------- Co-authored-by: plekhanov <astecom@mail.ru>
This commit is contained in:
@ -353,5 +353,9 @@
|
|||||||
"Word count: {{wordCount}}": "Word count: {{wordCount}}",
|
"Word count: {{wordCount}}": "Word count: {{wordCount}}",
|
||||||
"Character count: {{characterCount}}": "Character count: {{characterCount}}",
|
"Character count: {{characterCount}}": "Character count: {{characterCount}}",
|
||||||
"New update": "New update",
|
"New update": "New update",
|
||||||
"{{latestVersion}} is available": "{{latestVersion}} is available"
|
"{{latestVersion}} is available": "{{latestVersion}} is available",
|
||||||
|
"Move": "Move",
|
||||||
|
"Move page": "Move page",
|
||||||
|
"Move page to a different space.": "Move page to a different space.",
|
||||||
|
"Real-time editor connection lost. Retrying...": "Real-time editor connection lost. Retrying..."
|
||||||
}
|
}
|
||||||
|
|||||||
@ -65,11 +65,12 @@ export default function ExportModal({
|
|||||||
yOffset="10vh"
|
yOffset="10vh"
|
||||||
xOffset={0}
|
xOffset={0}
|
||||||
mah={400}
|
mah={400}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
<Modal.Overlay />
|
<Modal.Overlay />
|
||||||
<Modal.Content style={{ overflow: "hidden" }}>
|
<Modal.Content style={{ overflow: "hidden" }}>
|
||||||
<Modal.Header py={0}>
|
<Modal.Header py={0}>
|
||||||
<Modal.Title fw={500}>Export {type}</Modal.Title>
|
<Modal.Title fw={500}>{t(`Export ${type}`)}</Modal.Title>
|
||||||
<Modal.CloseButton />
|
<Modal.CloseButton />
|
||||||
</Modal.Header>
|
</Modal.Header>
|
||||||
<Modal.Body>
|
<Modal.Body>
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import { ActionIcon, Group, Menu, Text, Tooltip } from "@mantine/core";
|
import { ActionIcon, Group, Menu, Text, Tooltip } from "@mantine/core";
|
||||||
import {
|
import {
|
||||||
|
IconArrowRight,
|
||||||
IconArrowsHorizontal,
|
IconArrowsHorizontal,
|
||||||
IconDots,
|
IconDots,
|
||||||
IconFileExport,
|
IconFileExport,
|
||||||
@ -31,11 +32,13 @@ import {
|
|||||||
yjsConnectionStatusAtom,
|
yjsConnectionStatusAtom,
|
||||||
} from "@/features/editor/atoms/editor-atoms.ts";
|
} from "@/features/editor/atoms/editor-atoms.ts";
|
||||||
import { formattedDate, timeAgo } from "@/lib/time.ts";
|
import { formattedDate, timeAgo } from "@/lib/time.ts";
|
||||||
|
import MovePageModal from "@/features/page/components/move-page-modal.tsx";
|
||||||
|
|
||||||
interface PageHeaderMenuProps {
|
interface PageHeaderMenuProps {
|
||||||
readOnly?: boolean;
|
readOnly?: boolean;
|
||||||
}
|
}
|
||||||
export default function PageHeaderMenu({ readOnly }: PageHeaderMenuProps) {
|
export default function PageHeaderMenu({ readOnly }: PageHeaderMenuProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
const toggleAside = useToggleAside();
|
const toggleAside = useToggleAside();
|
||||||
const [yjsConnectionStatus] = useAtom(yjsConnectionStatusAtom);
|
const [yjsConnectionStatus] = useAtom(yjsConnectionStatusAtom);
|
||||||
|
|
||||||
@ -43,7 +46,7 @@ export default function PageHeaderMenu({ readOnly }: PageHeaderMenuProps) {
|
|||||||
<>
|
<>
|
||||||
{yjsConnectionStatus === "disconnected" && (
|
{yjsConnectionStatus === "disconnected" && (
|
||||||
<Tooltip
|
<Tooltip
|
||||||
label="Real-time editor connection lost. Retrying..."
|
label={t("Real-time editor connection lost. Retrying...")}
|
||||||
openDelay={250}
|
openDelay={250}
|
||||||
withArrow
|
withArrow
|
||||||
>
|
>
|
||||||
@ -83,6 +86,10 @@ function PageActionMenu({ readOnly }: PageActionMenuProps) {
|
|||||||
const [tree] = useAtom(treeApiAtom);
|
const [tree] = useAtom(treeApiAtom);
|
||||||
const [exportOpened, { open: openExportModal, close: closeExportModal }] =
|
const [exportOpened, { open: openExportModal, close: closeExportModal }] =
|
||||||
useDisclosure(false);
|
useDisclosure(false);
|
||||||
|
const [
|
||||||
|
movePageModalOpened,
|
||||||
|
{ open: openMovePageModal, close: closeMoveSpaceModal },
|
||||||
|
] = useDisclosure(false);
|
||||||
const [pageEditor] = useAtom(pageEditorAtom);
|
const [pageEditor] = useAtom(pageEditorAtom);
|
||||||
|
|
||||||
const handleCopyLink = () => {
|
const handleCopyLink = () => {
|
||||||
@ -147,6 +154,15 @@ function PageActionMenu({ readOnly }: PageActionMenuProps) {
|
|||||||
|
|
||||||
<Menu.Divider />
|
<Menu.Divider />
|
||||||
|
|
||||||
|
{!readOnly && (
|
||||||
|
<Menu.Item
|
||||||
|
leftSection={<IconArrowRight size={16} />}
|
||||||
|
onClick={openMovePageModal}
|
||||||
|
>
|
||||||
|
{t("Move")}
|
||||||
|
</Menu.Item>
|
||||||
|
)}
|
||||||
|
|
||||||
<Menu.Item
|
<Menu.Item
|
||||||
leftSection={<IconFileExport size={16} />}
|
leftSection={<IconFileExport size={16} />}
|
||||||
onClick={openExportModal}
|
onClick={openExportModal}
|
||||||
@ -217,6 +233,14 @@ function PageActionMenu({ readOnly }: PageActionMenuProps) {
|
|||||||
open={exportOpened}
|
open={exportOpened}
|
||||||
onClose={closeExportModal}
|
onClose={closeExportModal}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<MovePageModal
|
||||||
|
pageId={page.id}
|
||||||
|
slugId={page.slugId}
|
||||||
|
currentSpaceSlug={spaceSlug}
|
||||||
|
onClose={closeMoveSpaceModal}
|
||||||
|
open={movePageModalOpened}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
98
apps/client/src/features/page/components/move-page-modal.tsx
Normal file
98
apps/client/src/features/page/components/move-page-modal.tsx
Normal file
@ -0,0 +1,98 @@
|
|||||||
|
import { Modal, Button, Group, Text } from "@mantine/core";
|
||||||
|
import { movePageToSpace } from "@/features/page/services/page-service.ts";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { notifications } from "@mantine/notifications";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { ISpace } from "@/features/space/types/space.types.ts";
|
||||||
|
import { queryClient } from "@/main.tsx";
|
||||||
|
import { SpaceSelect } from "@/features/space/components/sidebar/space-select.tsx";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import { buildPageUrl } from "@/features/page/page.utils.ts";
|
||||||
|
|
||||||
|
interface MovePageModalProps {
|
||||||
|
pageId: string;
|
||||||
|
slugId: string;
|
||||||
|
currentSpaceSlug: string;
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function MovePageModal({
|
||||||
|
pageId,
|
||||||
|
slugId,
|
||||||
|
currentSpaceSlug,
|
||||||
|
open,
|
||||||
|
onClose,
|
||||||
|
}: MovePageModalProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [targetSpace, setTargetSpace] = useState<ISpace>(null);
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const handlePageMove = async () => {
|
||||||
|
if (!targetSpace) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await movePageToSpace({ pageId, spaceId: targetSpace.id });
|
||||||
|
queryClient.removeQueries({
|
||||||
|
predicate: (item) =>
|
||||||
|
["pages", "sidebar-pages", "root-sidebar-pages"].includes(
|
||||||
|
item.queryKey[0] as string,
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
const pageUrl = buildPageUrl(targetSpace.slug, slugId, undefined);
|
||||||
|
navigate(pageUrl);
|
||||||
|
notifications.show({
|
||||||
|
message: t("Page moved successfully"),
|
||||||
|
});
|
||||||
|
onClose();
|
||||||
|
} catch (err) {
|
||||||
|
notifications.show({
|
||||||
|
message: err.response?.data.message || "An error occurred",
|
||||||
|
color: "red",
|
||||||
|
});
|
||||||
|
console.log(err);
|
||||||
|
}
|
||||||
|
setTargetSpace(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleChange = (space: ISpace) => {
|
||||||
|
setTargetSpace(space);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal.Root
|
||||||
|
opened={open}
|
||||||
|
onClose={onClose}
|
||||||
|
size={500}
|
||||||
|
padding="xl"
|
||||||
|
yOffset="10vh"
|
||||||
|
xOffset={0}
|
||||||
|
mah={400}
|
||||||
|
onClick={e => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<Modal.Overlay />
|
||||||
|
<Modal.Content style={{ overflow: "hidden" }}>
|
||||||
|
<Modal.Header py={0}>
|
||||||
|
<Modal.Title fw={500}>{t("Move page")}</Modal.Title>
|
||||||
|
<Modal.CloseButton />
|
||||||
|
</Modal.Header>
|
||||||
|
<Modal.Body>
|
||||||
|
<Text mb="xs" c="dimmed" size="sm">{t("Move page to a different space.")}</Text>
|
||||||
|
|
||||||
|
<SpaceSelect
|
||||||
|
value={currentSpaceSlug}
|
||||||
|
clearable={false}
|
||||||
|
onChange={handleChange}
|
||||||
|
/>
|
||||||
|
<Group justify="end" mt="md">
|
||||||
|
<Button onClick={onClose} variant="default">
|
||||||
|
{t("Cancel")}
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handlePageMove}>{t("Move")}</Button>
|
||||||
|
</Group>
|
||||||
|
</Modal.Body>
|
||||||
|
</Modal.Content>
|
||||||
|
</Modal.Root>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -2,6 +2,7 @@ import api from "@/lib/api-client";
|
|||||||
import {
|
import {
|
||||||
IExportPageParams,
|
IExportPageParams,
|
||||||
IMovePage,
|
IMovePage,
|
||||||
|
IMovePageToSpace,
|
||||||
IPage,
|
IPage,
|
||||||
IPageInput,
|
IPageInput,
|
||||||
SidebarPagesParams,
|
SidebarPagesParams,
|
||||||
@ -34,6 +35,10 @@ export async function movePage(data: IMovePage): Promise<void> {
|
|||||||
await api.post<void>("/pages/move", data);
|
await api.post<void>("/pages/move", data);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function movePageToSpace(data: IMovePageToSpace): Promise<void> {
|
||||||
|
await api.post<void>("/pages/move-to-space", data);
|
||||||
|
}
|
||||||
|
|
||||||
export async function getSidebarPages(
|
export async function getSidebarPages(
|
||||||
params: SidebarPagesParams,
|
params: SidebarPagesParams,
|
||||||
): Promise<IPagination<IPage>> {
|
): Promise<IPagination<IPage>> {
|
||||||
|
|||||||
@ -7,11 +7,12 @@ import {
|
|||||||
usePageQuery,
|
usePageQuery,
|
||||||
useUpdatePageMutation,
|
useUpdatePageMutation,
|
||||||
} from "@/features/page/queries/page-query.ts";
|
} from "@/features/page/queries/page-query.ts";
|
||||||
import React, { useEffect, useRef } from "react";
|
import { useEffect, useRef } from "react";
|
||||||
import { useNavigate, useParams } from "react-router-dom";
|
import { useNavigate, useParams } from "react-router-dom";
|
||||||
import classes from "@/features/page/tree/styles/tree.module.css";
|
import classes from "@/features/page/tree/styles/tree.module.css";
|
||||||
import { ActionIcon, Menu, rem } from "@mantine/core";
|
import { ActionIcon, Menu, rem } from "@mantine/core";
|
||||||
import {
|
import {
|
||||||
|
IconArrowRight,
|
||||||
IconChevronDown,
|
IconChevronDown,
|
||||||
IconChevronRight,
|
IconChevronRight,
|
||||||
IconDotsVertical,
|
IconDotsVertical,
|
||||||
@ -56,6 +57,7 @@ import { extractPageSlugId } from "@/lib";
|
|||||||
import { useDeletePageModal } from "@/features/page/hooks/use-delete-page-modal.tsx";
|
import { useDeletePageModal } from "@/features/page/hooks/use-delete-page-modal.tsx";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import ExportModal from "@/components/common/export-modal";
|
import ExportModal from "@/components/common/export-modal";
|
||||||
|
import MovePageModal from "../../components/move-page-modal.tsx";
|
||||||
|
|
||||||
interface SpaceTreeProps {
|
interface SpaceTreeProps {
|
||||||
spaceId: string;
|
spaceId: string;
|
||||||
@ -234,6 +236,7 @@ function Node({ node, style, dragHandle, tree }: NodeRendererProps<any>) {
|
|||||||
const emit = useQueryEmit();
|
const emit = useQueryEmit();
|
||||||
const { spaceSlug } = useParams();
|
const { spaceSlug } = useParams();
|
||||||
const timerRef = useRef(null);
|
const timerRef = useRef(null);
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
const prefetchPage = () => {
|
const prefetchPage = () => {
|
||||||
timerRef.current = setTimeout(() => {
|
timerRef.current = setTimeout(() => {
|
||||||
@ -369,7 +372,7 @@ function Node({ node, style, dragHandle, tree }: NodeRendererProps<any>) {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<span className={classes.text}>{node.data.name || "untitled"}</span>
|
<span className={classes.text}>{node.data.name || t("untitled")}</span>
|
||||||
|
|
||||||
<div className={classes.actions}>
|
<div className={classes.actions}>
|
||||||
<NodeMenu node={node} treeApi={tree} />
|
<NodeMenu node={node} treeApi={tree} />
|
||||||
@ -434,6 +437,10 @@ function NodeMenu({ node, treeApi }: NodeMenuProps) {
|
|||||||
const { openDeleteModal } = useDeletePageModal();
|
const { openDeleteModal } = useDeletePageModal();
|
||||||
const [exportOpened, { open: openExportModal, close: closeExportModal }] =
|
const [exportOpened, { open: openExportModal, close: closeExportModal }] =
|
||||||
useDisclosure(false);
|
useDisclosure(false);
|
||||||
|
const [
|
||||||
|
movePageModalOpened,
|
||||||
|
{ open: openMovePageModal, close: closeMoveSpaceModal },
|
||||||
|
] = useDisclosure(false);
|
||||||
|
|
||||||
const handleCopyLink = () => {
|
const handleCopyLink = () => {
|
||||||
const pageUrl =
|
const pageUrl =
|
||||||
@ -486,8 +493,18 @@ function NodeMenu({ node, treeApi }: NodeMenuProps) {
|
|||||||
|
|
||||||
{!(treeApi.props.disableEdit as boolean) && (
|
{!(treeApi.props.disableEdit as boolean) && (
|
||||||
<>
|
<>
|
||||||
<Menu.Divider />
|
<Menu.Item
|
||||||
|
leftSection={<IconArrowRight size={16} />}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
openMovePageModal();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("Move")}
|
||||||
|
</Menu.Item>
|
||||||
|
|
||||||
|
<Menu.Divider />
|
||||||
<Menu.Item
|
<Menu.Item
|
||||||
c="red"
|
c="red"
|
||||||
leftSection={<IconTrash size={16} />}
|
leftSection={<IconTrash size={16} />}
|
||||||
@ -504,6 +521,14 @@ function NodeMenu({ node, treeApi }: NodeMenuProps) {
|
|||||||
</Menu.Dropdown>
|
</Menu.Dropdown>
|
||||||
</Menu>
|
</Menu>
|
||||||
|
|
||||||
|
<MovePageModal
|
||||||
|
pageId={node.id}
|
||||||
|
slugId={node.data.slugId}
|
||||||
|
currentSpaceSlug={spaceSlug}
|
||||||
|
onClose={closeMoveSpaceModal}
|
||||||
|
open={movePageModalOpened}
|
||||||
|
/>
|
||||||
|
|
||||||
<ExportModal
|
<ExportModal
|
||||||
type="page"
|
type="page"
|
||||||
id={node.id}
|
id={node.id}
|
||||||
|
|||||||
@ -42,6 +42,11 @@ export interface IMovePage {
|
|||||||
parentPageId?: string;
|
parentPageId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface IMovePageToSpace {
|
||||||
|
pageId: string;
|
||||||
|
spaceId: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface SidebarPagesParams {
|
export interface SidebarPagesParams {
|
||||||
spaceId: string;
|
spaceId: string;
|
||||||
pageId?: string;
|
pageId?: string;
|
||||||
|
|||||||
@ -6,21 +6,33 @@ import { ISpace } from "../../types/space.types";
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
interface SpaceSelectProps {
|
interface SpaceSelectProps {
|
||||||
onChange: (value: string) => void;
|
onChange: (value: ISpace) => void;
|
||||||
value?: string;
|
value?: string;
|
||||||
label?: string;
|
label?: string;
|
||||||
|
width?: number;
|
||||||
|
opened?: boolean;
|
||||||
|
clearable?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const renderSelectOption: SelectProps["renderOption"] = ({ option }) => (
|
const renderSelectOption: SelectProps["renderOption"] = ({ option }) => (
|
||||||
<Group gap="sm" wrap="nowrap">
|
<Group gap="sm" wrap="nowrap">
|
||||||
<Avatar color="initials" variant="filled" name={option.label} size={20} />
|
<Avatar color="initials" variant="filled" name={option.label} size={20} />
|
||||||
<div>
|
<div>
|
||||||
<Text size="sm" lineClamp={1}>{option.label}</Text>
|
<Text size="sm" lineClamp={1}>
|
||||||
|
{option.label}
|
||||||
|
</Text>
|
||||||
</div>
|
</div>
|
||||||
</Group>
|
</Group>
|
||||||
);
|
);
|
||||||
|
|
||||||
export function SpaceSelect({ onChange, label, value }: SpaceSelectProps) {
|
export function SpaceSelect({
|
||||||
|
onChange,
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
width,
|
||||||
|
opened,
|
||||||
|
clearable,
|
||||||
|
}: SpaceSelectProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [searchValue, setSearchValue] = useState("");
|
const [searchValue, setSearchValue] = useState("");
|
||||||
const [debouncedQuery] = useDebouncedValue(searchValue, 500);
|
const [debouncedQuery] = useDebouncedValue(searchValue, 500);
|
||||||
@ -42,8 +54,8 @@ export function SpaceSelect({ onChange, label, value }: SpaceSelectProps) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const filteredSpaceData = spaceData.filter(
|
const filteredSpaceData = spaceData.filter(
|
||||||
(user) =>
|
(space) =>
|
||||||
!data.find((existingUser) => existingUser.value === user.value),
|
!data.find((existingSpace) => existingSpace.value === space.value),
|
||||||
);
|
);
|
||||||
setData((prevData) => [...prevData, ...filteredSpaceData]);
|
setData((prevData) => [...prevData, ...filteredSpaceData]);
|
||||||
}
|
}
|
||||||
@ -59,14 +71,18 @@ export function SpaceSelect({ onChange, label, value }: SpaceSelectProps) {
|
|||||||
searchable
|
searchable
|
||||||
searchValue={searchValue}
|
searchValue={searchValue}
|
||||||
onSearchChange={setSearchValue}
|
onSearchChange={setSearchValue}
|
||||||
clearable
|
clearable={clearable}
|
||||||
variant="filled"
|
variant="filled"
|
||||||
onChange={onChange}
|
onChange={(slug) =>
|
||||||
|
onChange(spaces.items?.find((item) => item.slug === slug))
|
||||||
|
}
|
||||||
|
// duct tape
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
nothingFoundMessage={t("No space found")}
|
nothingFoundMessage={t("No space found")}
|
||||||
limit={50}
|
limit={50}
|
||||||
checkIconPosition="right"
|
checkIconPosition="right"
|
||||||
comboboxProps={{ width: 300, withinPortal: false }}
|
comboboxProps={{ width, withinPortal: false }}
|
||||||
dropdownOpened
|
dropdownOpened={opened}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -55,7 +55,9 @@ export function SwitchSpace({ spaceName, spaceSlug }: SwitchSpaceProps) {
|
|||||||
<SpaceSelect
|
<SpaceSelect
|
||||||
label={spaceName}
|
label={spaceName}
|
||||||
value={spaceSlug}
|
value={spaceSlug}
|
||||||
onChange={handleSelect}
|
onChange={space => handleSelect(space.slug)}
|
||||||
|
width={300}
|
||||||
|
opened={true}
|
||||||
/>
|
/>
|
||||||
</Popover.Dropdown>
|
</Popover.Dropdown>
|
||||||
</Popover>
|
</Popover>
|
||||||
|
|||||||
@ -70,7 +70,6 @@ function ChangeEmailForm() {
|
|||||||
|
|
||||||
function handleSubmit(data: FormValues) {
|
function handleSubmit(data: FormValues) {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
console.log(data);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@ -13,3 +13,11 @@ export class MovePageDto {
|
|||||||
@IsString()
|
@IsString()
|
||||||
parentPageId?: string | null;
|
parentPageId?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class MovePageToSpaceDto {
|
||||||
|
@IsString()
|
||||||
|
pageId: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
spaceId: string;
|
||||||
|
}
|
||||||
|
|||||||
@ -7,11 +7,12 @@ import {
|
|||||||
UseGuards,
|
UseGuards,
|
||||||
ForbiddenException,
|
ForbiddenException,
|
||||||
NotFoundException,
|
NotFoundException,
|
||||||
|
BadRequestException,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { PageService } from './services/page.service';
|
import { PageService } from './services/page.service';
|
||||||
import { CreatePageDto } from './dto/create-page.dto';
|
import { CreatePageDto } from './dto/create-page.dto';
|
||||||
import { UpdatePageDto } from './dto/update-page.dto';
|
import { UpdatePageDto } from './dto/update-page.dto';
|
||||||
import { MovePageDto } from './dto/move-page.dto';
|
import { MovePageDto, MovePageToSpaceDto } from './dto/move-page.dto';
|
||||||
import { PageHistoryIdDto, PageIdDto, PageInfoDto } from './dto/page.dto';
|
import { PageHistoryIdDto, PageIdDto, PageInfoDto } from './dto/page.dto';
|
||||||
import { PageHistoryService } from './services/page-history.service';
|
import { PageHistoryService } from './services/page-history.service';
|
||||||
import { AuthUser } from '../../common/decorators/auth-user.decorator';
|
import { AuthUser } from '../../common/decorators/auth-user.decorator';
|
||||||
@ -93,11 +94,7 @@ export class PageController {
|
|||||||
throw new ForbiddenException();
|
throw new ForbiddenException();
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.pageService.update(
|
return this.pageService.update(page, updatePageDto, user.id);
|
||||||
page,
|
|
||||||
updatePageDto,
|
|
||||||
user.id,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@HttpCode(HttpStatus.OK)
|
@HttpCode(HttpStatus.OK)
|
||||||
@ -210,6 +207,36 @@ export class PageController {
|
|||||||
return this.pageService.getSidebarPages(dto.spaceId, pagination, pageId);
|
return this.pageService.getSidebarPages(dto.spaceId, pagination, pageId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@HttpCode(HttpStatus.OK)
|
||||||
|
@Post('move-to-space')
|
||||||
|
async movePageToSpace(
|
||||||
|
@Body() dto: MovePageToSpaceDto,
|
||||||
|
@AuthUser() user: User,
|
||||||
|
) {
|
||||||
|
const movedPage = await this.pageRepo.findById(dto.pageId);
|
||||||
|
if (!movedPage) {
|
||||||
|
throw new NotFoundException('Page to move not found');
|
||||||
|
}
|
||||||
|
if (movedPage.spaceId === dto.spaceId) {
|
||||||
|
throw new BadRequestException('Page is already in this space');
|
||||||
|
}
|
||||||
|
|
||||||
|
const abilities = await Promise.all([
|
||||||
|
this.spaceAbility.createForUser(user, movedPage.spaceId),
|
||||||
|
this.spaceAbility.createForUser(user, dto.spaceId),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (
|
||||||
|
abilities.some((ability) =>
|
||||||
|
ability.cannot(SpaceCaslAction.Edit, SpaceCaslSubject.Page),
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
throw new ForbiddenException();
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.pageService.movePageToSpace(movedPage, dto.spaceId);
|
||||||
|
}
|
||||||
|
|
||||||
@HttpCode(HttpStatus.OK)
|
@HttpCode(HttpStatus.OK)
|
||||||
@Post('move')
|
@Post('move')
|
||||||
async movePage(@Body() dto: MovePageDto, @AuthUser() user: User) {
|
async movePage(@Body() dto: MovePageDto, @AuthUser() user: User) {
|
||||||
|
|||||||
@ -19,11 +19,14 @@ import { MovePageDto } from '../dto/move-page.dto';
|
|||||||
import { ExpressionBuilder } from 'kysely';
|
import { ExpressionBuilder } from 'kysely';
|
||||||
import { DB } from '@docmost/db/types/db';
|
import { DB } from '@docmost/db/types/db';
|
||||||
import { generateSlugId } from '../../../common/helpers';
|
import { generateSlugId } from '../../../common/helpers';
|
||||||
|
import { executeTx } from '@docmost/db/utils';
|
||||||
|
import { AttachmentRepo } from '@docmost/db/repos/attachment/attachment.repo';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class PageService {
|
export class PageService {
|
||||||
constructor(
|
constructor(
|
||||||
private pageRepo: PageRepo,
|
private pageRepo: PageRepo,
|
||||||
|
private attachmentRepo: AttachmentRepo,
|
||||||
@InjectKysely() private readonly db: KyselyDB,
|
@InjectKysely() private readonly db: KyselyDB,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@ -60,12 +63,31 @@ export class PageService {
|
|||||||
parentPageId = parentPage.id;
|
parentPageId = parentPage.id;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const createdPage = await this.pageRepo.insertPage({
|
||||||
|
slugId: generateSlugId(),
|
||||||
|
title: createPageDto.title,
|
||||||
|
position: await this.nextPagePosition(
|
||||||
|
createPageDto.spaceId,
|
||||||
|
parentPageId,
|
||||||
|
),
|
||||||
|
icon: createPageDto.icon,
|
||||||
|
parentPageId: parentPageId,
|
||||||
|
spaceId: createPageDto.spaceId,
|
||||||
|
creatorId: userId,
|
||||||
|
workspaceId: workspaceId,
|
||||||
|
lastUpdatedById: userId,
|
||||||
|
});
|
||||||
|
|
||||||
|
return createdPage;
|
||||||
|
}
|
||||||
|
|
||||||
|
async nextPagePosition(spaceId: string, parentPageId?: string) {
|
||||||
let pagePosition: string;
|
let pagePosition: string;
|
||||||
|
|
||||||
const lastPageQuery = this.db
|
const lastPageQuery = this.db
|
||||||
.selectFrom('pages')
|
.selectFrom('pages')
|
||||||
.select(['id', 'position'])
|
.select(['position'])
|
||||||
.where('spaceId', '=', createPageDto.spaceId)
|
.where('spaceId', '=', spaceId)
|
||||||
.orderBy('position', 'desc')
|
.orderBy('position', 'desc')
|
||||||
.limit(1);
|
.limit(1);
|
||||||
|
|
||||||
@ -96,19 +118,7 @@ export class PageService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const createdPage = await this.pageRepo.insertPage({
|
return pagePosition;
|
||||||
slugId: generateSlugId(),
|
|
||||||
title: createPageDto.title,
|
|
||||||
position: pagePosition,
|
|
||||||
icon: createPageDto.icon,
|
|
||||||
parentPageId: parentPageId,
|
|
||||||
spaceId: createPageDto.spaceId,
|
|
||||||
creatorId: userId,
|
|
||||||
workspaceId: workspaceId,
|
|
||||||
lastUpdatedById: userId,
|
|
||||||
});
|
|
||||||
|
|
||||||
return createdPage;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async update(
|
async update(
|
||||||
@ -192,6 +202,36 @@ export class PageService {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async movePageToSpace(rootPage: Page, spaceId: string) {
|
||||||
|
await executeTx(this.db, async (trx) => {
|
||||||
|
// Update root page
|
||||||
|
const nextPosition = await this.nextPagePosition(spaceId);
|
||||||
|
await this.pageRepo.updatePage(
|
||||||
|
{ spaceId, parentPageId: null, position: nextPosition },
|
||||||
|
rootPage.id,
|
||||||
|
trx,
|
||||||
|
);
|
||||||
|
const pageIds = await this.pageRepo
|
||||||
|
.getPageAndDescendants(rootPage.id)
|
||||||
|
.then((pages) => pages.map((page) => page.id));
|
||||||
|
// The first id is the root page id
|
||||||
|
if (pageIds.length > 1) {
|
||||||
|
// Update sub pages
|
||||||
|
await this.pageRepo.updatePages(
|
||||||
|
{ spaceId },
|
||||||
|
pageIds.filter((id) => id !== rootPage.id),
|
||||||
|
trx,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// Update attachments
|
||||||
|
await this.attachmentRepo.updateAttachmentsByPageId(
|
||||||
|
{ spaceId },
|
||||||
|
pageIds,
|
||||||
|
trx,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async movePage(dto: MovePageDto, movedPage: Page) {
|
async movePage(dto: MovePageDto, movedPage: Page) {
|
||||||
// validate position value by attempting to generate a key
|
// validate position value by attempting to generate a key
|
||||||
try {
|
try {
|
||||||
|
|||||||
@ -55,6 +55,18 @@ export class AttachmentRepo {
|
|||||||
.execute();
|
.execute();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
updateAttachmentsByPageId(
|
||||||
|
updatableAttachment: UpdatableAttachment,
|
||||||
|
pageIds: string[],
|
||||||
|
trx?: KyselyTransaction,
|
||||||
|
) {
|
||||||
|
return dbOrTx(this.db, trx)
|
||||||
|
.updateTable('attachments')
|
||||||
|
.set(updatableAttachment)
|
||||||
|
.where('pageId', 'in', pageIds)
|
||||||
|
.executeTakeFirst();
|
||||||
|
}
|
||||||
|
|
||||||
async updateAttachment(
|
async updateAttachment(
|
||||||
updatableAttachment: UpdatableAttachment,
|
updatableAttachment: UpdatableAttachment,
|
||||||
attachmentId: string,
|
attachmentId: string,
|
||||||
|
|||||||
@ -96,18 +96,19 @@ export class PageRepo {
|
|||||||
pageId: string,
|
pageId: string,
|
||||||
trx?: KyselyTransaction,
|
trx?: KyselyTransaction,
|
||||||
) {
|
) {
|
||||||
const db = dbOrTx(this.db, trx);
|
return this.updatePages(updatablePage, [pageId], trx);
|
||||||
let query = db
|
}
|
||||||
|
|
||||||
|
async updatePages(
|
||||||
|
updatePageData: UpdatablePage,
|
||||||
|
pageIds: string[],
|
||||||
|
trx?: KyselyTransaction,
|
||||||
|
) {
|
||||||
|
return dbOrTx(this.db, trx)
|
||||||
.updateTable('pages')
|
.updateTable('pages')
|
||||||
.set({ ...updatablePage, updatedAt: new Date() });
|
.set({ ...updatePageData, updatedAt: new Date() })
|
||||||
|
.where(pageIds.some(pageId => !isValidUUID(pageId)) ? "slugId" : "id", "in", pageIds)
|
||||||
if (isValidUUID(pageId)) {
|
.executeTakeFirst();
|
||||||
query = query.where('id', '=', pageId);
|
|
||||||
} else {
|
|
||||||
query = query.where('slugId', '=', pageId);
|
|
||||||
}
|
|
||||||
|
|
||||||
return query.executeTakeFirst();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async insertPage(
|
async insertPage(
|
||||||
|
|||||||
Reference in New Issue
Block a user