mirror of
https://github.com/docmost/docmost.git
synced 2025-11-12 16:42:37 +10:00
Rework sidebar pages
* Move sidebar pages from workspace to space level * Replace array sorting with lexicographical fractional indexing * Fixes and updates
This commit is contained in:
@ -1,8 +1,7 @@
|
|||||||
import { useAtomValue } from 'jotai';
|
import { useAtomValue } from "jotai";
|
||||||
import { treeDataAtom } from '@/features/page/tree/atoms/tree-data-atom';
|
import { treeDataAtom } from "@/features/page/tree/atoms/tree-data-atom";
|
||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useState } from "react";
|
||||||
import { TreeNode } from '@/features/page/tree/types';
|
import { findBreadcrumbPath } from "@/features/page/tree/utils";
|
||||||
import { findBreadcrumbPath } from '@/features/page/tree/utils';
|
|
||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
Anchor,
|
Anchor,
|
||||||
@ -10,18 +9,17 @@ import {
|
|||||||
Breadcrumbs,
|
Breadcrumbs,
|
||||||
ActionIcon,
|
ActionIcon,
|
||||||
Text,
|
Text,
|
||||||
} from '@mantine/core';
|
} from "@mantine/core";
|
||||||
import {
|
import { IconDots } from "@tabler/icons-react";
|
||||||
IconDots,
|
import { Link, useParams } from "react-router-dom";
|
||||||
} from '@tabler/icons-react';
|
import classes from "./breadcrumb.module.css";
|
||||||
import { Link, useParams } from 'react-router-dom';
|
import { SpaceTreeNode } from "@/features/page/tree/types.ts";
|
||||||
import classes from './breadcrumb.module.css';
|
|
||||||
|
|
||||||
export default function Breadcrumb() {
|
export default function Breadcrumb() {
|
||||||
const treeData = useAtomValue(treeDataAtom);
|
const treeData = useAtomValue(treeDataAtom);
|
||||||
const [breadcrumbNodes, setBreadcrumbNodes] = useState<TreeNode[] | null>(
|
const [breadcrumbNodes, setBreadcrumbNodes] = useState<
|
||||||
null,
|
SpaceTreeNode[] | null
|
||||||
);
|
>(null);
|
||||||
const { pageId } = useParams();
|
const { pageId } = useParams();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -40,31 +38,42 @@ export default function Breadcrumb() {
|
|||||||
}
|
}
|
||||||
}, [pageId, treeData]);
|
}, [pageId, treeData]);
|
||||||
|
|
||||||
const HiddenNodesTooltipContent = () => (
|
const HiddenNodesTooltipContent = () =>
|
||||||
breadcrumbNodes?.slice(1, -2).map(node => (
|
breadcrumbNodes?.slice(1, -2).map((node) => (
|
||||||
<Button.Group orientation="vertical" key={node.id}>
|
<Button.Group orientation="vertical" key={node.id}>
|
||||||
<Button
|
<Button
|
||||||
justify="start"
|
justify="start"
|
||||||
component={Link}
|
component={Link}
|
||||||
to={`/p/${node.id}`}
|
to={`/p/${node.id}`}
|
||||||
variant="default"
|
variant="default"
|
||||||
style={{ border: 'none' }}
|
style={{ border: "none" }}
|
||||||
>
|
>
|
||||||
<Text truncate="end">{node.name}</Text>
|
<Text truncate="end">{node.name}</Text>
|
||||||
</Button>
|
</Button>
|
||||||
</Button.Group>
|
</Button.Group>
|
||||||
))
|
));
|
||||||
);
|
|
||||||
|
|
||||||
const getLastNthNode = (n: number) => breadcrumbNodes && breadcrumbNodes[breadcrumbNodes.length - n];
|
const getLastNthNode = (n: number) =>
|
||||||
|
breadcrumbNodes && breadcrumbNodes[breadcrumbNodes.length - n];
|
||||||
|
|
||||||
const getBreadcrumbItems = () => {
|
const getBreadcrumbItems = () => {
|
||||||
if (breadcrumbNodes?.length > 3) {
|
if (breadcrumbNodes?.length > 3) {
|
||||||
return [
|
return [
|
||||||
<Anchor component={Link} to={`/p/${breadcrumbNodes[0].id}`} underline="never" key={breadcrumbNodes[0].id}>
|
<Anchor
|
||||||
|
component={Link}
|
||||||
|
to={`/p/${breadcrumbNodes[0].id}`}
|
||||||
|
underline="never"
|
||||||
|
key={breadcrumbNodes[0].id}
|
||||||
|
>
|
||||||
{breadcrumbNodes[0].name}
|
{breadcrumbNodes[0].name}
|
||||||
</Anchor>,
|
</Anchor>,
|
||||||
<Popover width={250} position="bottom" withArrow shadow="xl" key="hidden-nodes">
|
<Popover
|
||||||
|
width={250}
|
||||||
|
position="bottom"
|
||||||
|
withArrow
|
||||||
|
shadow="xl"
|
||||||
|
key="hidden-nodes"
|
||||||
|
>
|
||||||
<Popover.Target>
|
<Popover.Target>
|
||||||
<ActionIcon c="gray" variant="transparent">
|
<ActionIcon c="gray" variant="transparent">
|
||||||
<IconDots size={20} stroke={2} />
|
<IconDots size={20} stroke={2} />
|
||||||
@ -74,18 +83,33 @@ export default function Breadcrumb() {
|
|||||||
<HiddenNodesTooltipContent />
|
<HiddenNodesTooltipContent />
|
||||||
</Popover.Dropdown>
|
</Popover.Dropdown>
|
||||||
</Popover>,
|
</Popover>,
|
||||||
<Anchor component={Link} to={`/p/${getLastNthNode(2)?.id}`} underline="never" key={getLastNthNode(2)?.id}>
|
<Anchor
|
||||||
|
component={Link}
|
||||||
|
to={`/p/${getLastNthNode(2)?.id}`}
|
||||||
|
underline="never"
|
||||||
|
key={getLastNthNode(2)?.id}
|
||||||
|
>
|
||||||
{getLastNthNode(2)?.name}
|
{getLastNthNode(2)?.name}
|
||||||
</Anchor>,
|
</Anchor>,
|
||||||
<Anchor component={Link} to={`/p/${getLastNthNode(1)?.id}`} underline="never" key={getLastNthNode(1)?.id}>
|
<Anchor
|
||||||
|
component={Link}
|
||||||
|
to={`/p/${getLastNthNode(1)?.id}`}
|
||||||
|
underline="never"
|
||||||
|
key={getLastNthNode(1)?.id}
|
||||||
|
>
|
||||||
{getLastNthNode(1)?.name}
|
{getLastNthNode(1)?.name}
|
||||||
</Anchor>,
|
</Anchor>,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
if (breadcrumbNodes) {
|
if (breadcrumbNodes) {
|
||||||
return breadcrumbNodes.map(node => (
|
return breadcrumbNodes.map((node) => (
|
||||||
<Anchor component={Link} to={`/p/${node.id}`} underline="never" key={node.id}>
|
<Anchor
|
||||||
|
component={Link}
|
||||||
|
to={`/p/${node.id}`}
|
||||||
|
underline="never"
|
||||||
|
key={node.id}
|
||||||
|
>
|
||||||
{node.name}
|
{node.name}
|
||||||
</Anchor>
|
</Anchor>
|
||||||
));
|
));
|
||||||
@ -98,7 +122,9 @@ export default function Breadcrumb() {
|
|||||||
<div className={classes.breadcrumb}>
|
<div className={classes.breadcrumb}>
|
||||||
{breadcrumbNodes ? (
|
{breadcrumbNodes ? (
|
||||||
<Breadcrumbs>{getBreadcrumbItems()}</Breadcrumbs>
|
<Breadcrumbs>{getBreadcrumbItems()}</Breadcrumbs>
|
||||||
) : (<></>)}
|
) : (
|
||||||
|
<></>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -20,9 +20,8 @@ import React from "react";
|
|||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import { SearchSpotlight } from "@/features/search/search-spotlight";
|
import { SearchSpotlight } from "@/features/search/search-spotlight";
|
||||||
import { treeApiAtom } from "@/features/page/tree/atoms/tree-api-atom";
|
import { treeApiAtom } from "@/features/page/tree/atoms/tree-api-atom";
|
||||||
import PageTree from "@/features/page/tree/page-tree";
|
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import SpaceContent from "@/features/page/component/space-content.tsx";
|
import SpaceContent from "@/features/page/tree/components/space-content.tsx";
|
||||||
|
|
||||||
interface PrimaryMenuItem {
|
interface PrimaryMenuItem {
|
||||||
icon: React.ElementType;
|
icon: React.ElementType;
|
||||||
@ -105,7 +104,6 @@ export function Navbar() {
|
|||||||
|
|
||||||
<div className={classes.pages}>
|
<div className={classes.pages}>
|
||||||
<SpaceContent />
|
<SpaceContent />
|
||||||
{/* <PageTree /> */}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
|||||||
@ -1,15 +1,25 @@
|
|||||||
import { useMutation, useQuery, UseQueryResult } from "@tanstack/react-query";
|
import {
|
||||||
|
useInfiniteQuery,
|
||||||
|
useMutation,
|
||||||
|
useQuery,
|
||||||
|
UseQueryResult,
|
||||||
|
} from "@tanstack/react-query";
|
||||||
import {
|
import {
|
||||||
createPage,
|
createPage,
|
||||||
deletePage,
|
deletePage,
|
||||||
getPageById,
|
getPageById,
|
||||||
getPages,
|
getSidebarPages,
|
||||||
getRecentChanges,
|
getRecentChanges,
|
||||||
getSpacePageOrder,
|
|
||||||
updatePage,
|
updatePage,
|
||||||
|
movePage,
|
||||||
} from "@/features/page/services/page-service";
|
} from "@/features/page/services/page-service";
|
||||||
import { IPage, IWorkspacePageOrder } from "@/features/page/types/page.types";
|
import {
|
||||||
|
IMovePage,
|
||||||
|
IPage,
|
||||||
|
SidebarPagesParams,
|
||||||
|
} from "@/features/page/types/page.types";
|
||||||
import { notifications } from "@mantine/notifications";
|
import { notifications } from "@mantine/notifications";
|
||||||
|
import { IPagination } from "@/lib/types.ts";
|
||||||
|
|
||||||
const RECENT_CHANGES_KEY = ["recentChanges"];
|
const RECENT_CHANGES_KEY = ["recentChanges"];
|
||||||
|
|
||||||
@ -22,16 +32,6 @@ export function usePageQuery(pageId: string): UseQueryResult<IPage, Error> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useGetPagesQuery(
|
|
||||||
spaceId: string,
|
|
||||||
): UseQueryResult<IPage[], Error> {
|
|
||||||
return useQuery({
|
|
||||||
queryKey: ["pages", spaceId],
|
|
||||||
queryFn: () => getPages(spaceId),
|
|
||||||
staleTime: 5 * 60 * 1000,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useRecentChangesQuery(): UseQueryResult<IPage[], Error> {
|
export function useRecentChangesQuery(): UseQueryResult<IPage[], Error> {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: RECENT_CHANGES_KEY,
|
queryKey: RECENT_CHANGES_KEY,
|
||||||
@ -44,6 +44,9 @@ export function useCreatePageMutation() {
|
|||||||
return useMutation<IPage, Error, Partial<IPage>>({
|
return useMutation<IPage, Error, Partial<IPage>>({
|
||||||
mutationFn: (data) => createPage(data),
|
mutationFn: (data) => createPage(data),
|
||||||
onSuccess: (data) => {},
|
onSuccess: (data) => {},
|
||||||
|
onError: (error) => {
|
||||||
|
notifications.show({ message: "Failed to create page", color: "red" });
|
||||||
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -60,16 +63,37 @@ export function useDeletePageMutation() {
|
|||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
notifications.show({ message: "Page deleted successfully" });
|
notifications.show({ message: "Page deleted successfully" });
|
||||||
},
|
},
|
||||||
});
|
onError: (error) => {
|
||||||
}
|
notifications.show({ message: "Failed to delete page", color: "red" });
|
||||||
|
|
||||||
export default function useSpacePageOrder(
|
|
||||||
spaceId: string,
|
|
||||||
): UseQueryResult<IWorkspacePageOrder> {
|
|
||||||
return useQuery({
|
|
||||||
queryKey: ["page-order", spaceId],
|
|
||||||
queryFn: async () => {
|
|
||||||
return await getSpacePageOrder(spaceId);
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function useMovePageMutation() {
|
||||||
|
return useMutation<void, Error, IMovePage>({
|
||||||
|
mutationFn: (data) => movePage(data),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useGetSidebarPagesQuery(
|
||||||
|
data: SidebarPagesParams,
|
||||||
|
): UseQueryResult<IPagination<IPage>, Error> {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["sidebar-pages", data],
|
||||||
|
queryFn: () => getSidebarPages(data),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useGetRootSidebarPagesQuery(data: SidebarPagesParams) {
|
||||||
|
return useInfiniteQuery({
|
||||||
|
queryKey: ["root-sidebar-pages", data.spaceId],
|
||||||
|
queryFn: async ({ pageParam }) => {
|
||||||
|
return getSidebarPages({ spaceId: data.spaceId, page: pageParam });
|
||||||
|
},
|
||||||
|
initialPageParam: 1,
|
||||||
|
getPreviousPageParam: (firstPage) =>
|
||||||
|
firstPage.meta.hasPrevPage ? firstPage.meta.page - 1 : undefined,
|
||||||
|
getNextPageParam: (lastPage) =>
|
||||||
|
lastPage.meta.hasNextPage ? lastPage.meta.page + 1 : undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@ -2,47 +2,41 @@ import api from "@/lib/api-client";
|
|||||||
import {
|
import {
|
||||||
IMovePage,
|
IMovePage,
|
||||||
IPage,
|
IPage,
|
||||||
IWorkspacePageOrder,
|
SidebarPagesParams,
|
||||||
} from "@/features/page/types/page.types";
|
} from "@/features/page/types/page.types";
|
||||||
|
import { IPagination } from "@/lib/types.ts";
|
||||||
|
|
||||||
export async function createPage(data: Partial<IPage>): Promise<IPage> {
|
export async function createPage(data: Partial<IPage>): Promise<IPage> {
|
||||||
const req = await api.post<IPage>("/pages/create", data);
|
const req = await api.post<IPage>("/pages/create", data);
|
||||||
return req.data as IPage;
|
return req.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getPageById(pageId: string): Promise<IPage> {
|
export async function getPageById(pageId: string): Promise<IPage> {
|
||||||
const req = await api.post<IPage>("/pages/info", { pageId });
|
const req = await api.post<IPage>("/pages/info", { pageId });
|
||||||
return req.data as IPage;
|
return req.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updatePage(data: Partial<IPage>): Promise<IPage> {
|
||||||
|
const req = await api.post<IPage>("/pages/update", data);
|
||||||
|
return req.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deletePage(pageId: string): Promise<void> {
|
||||||
|
await api.post("/pages/delete", { pageId });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function movePage(data: IMovePage): Promise<void> {
|
||||||
|
await api.post<void>("/pages/move", data);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getRecentChanges(): Promise<IPage[]> {
|
export async function getRecentChanges(): Promise<IPage[]> {
|
||||||
const req = await api.post<IPage[]>("/pages/recent");
|
const req = await api.post<IPage[]>("/pages/recent");
|
||||||
return req.data as IPage[];
|
return req.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getPages(spaceId: string): Promise<IPage[]> {
|
export async function getSidebarPages(
|
||||||
const req = await api.post<IPage[]>("/pages", { spaceId });
|
params: SidebarPagesParams,
|
||||||
return req.data as IPage[];
|
): Promise<IPagination<IPage>> {
|
||||||
}
|
const req = await api.post("/pages/sidebar-pages", params);
|
||||||
|
return req.data;
|
||||||
export async function getSpacePageOrder(
|
|
||||||
spaceId: string,
|
|
||||||
): Promise<IWorkspacePageOrder[]> {
|
|
||||||
const req = await api.post<IWorkspacePageOrder[]>("/pages/ordering", {
|
|
||||||
spaceId,
|
|
||||||
});
|
|
||||||
return req.data as IWorkspacePageOrder[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function updatePage(data: Partial<IPage>): Promise<IPage> {
|
|
||||||
const req = await api.post<IPage>(`/pages/update`, data);
|
|
||||||
return req.data as IPage;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function movePage(data: IMovePage): Promise<void> {
|
|
||||||
await api.post<IMovePage>("/pages/move", data);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function deletePage(id: string): Promise<void> {
|
|
||||||
await api.post("/pages/delete", { id });
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import { atom } from "jotai";
|
import { atom } from "jotai";
|
||||||
import { TreeApi } from 'react-arborist';
|
import { TreeApi } from "react-arborist";
|
||||||
import { TreeNode } from "../types";
|
import { SpaceTreeNode } from "../types";
|
||||||
|
|
||||||
export const treeApiAtom = atom<TreeApi<TreeNode> | null>(null);
|
export const treeApiAtom = atom<TreeApi<SpaceTreeNode> | null>(null);
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { atom } from "jotai";
|
import { atom } from "jotai";
|
||||||
import { TreeNode } from '@/features/page/tree/types';
|
import { SpaceTreeNode } from "@/features/page/tree/types";
|
||||||
|
|
||||||
export const treeDataAtom = atom<TreeNode[]>([]);
|
export const treeDataAtom = atom<SpaceTreeNode[]>([]);
|
||||||
|
|||||||
@ -1,4 +0,0 @@
|
|||||||
import { atomWithStorage } from "jotai/utils";
|
|
||||||
import { IWorkspacePageOrder } from '@/features/page/types/page.types';
|
|
||||||
|
|
||||||
export const workspacePageOrderAtom = atomWithStorage<IWorkspacePageOrder | null>("workspace-page-order", null);
|
|
||||||
@ -12,7 +12,7 @@ import {
|
|||||||
import { IconPlus } from "@tabler/icons-react";
|
import { IconPlus } from "@tabler/icons-react";
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { treeApiAtom } from "@/features/page/tree/atoms/tree-api-atom.ts";
|
import { treeApiAtom } from "@/features/page/tree/atoms/tree-api-atom.ts";
|
||||||
import PageTree from "@/features/page/tree/page-tree.tsx";
|
import SpaceTree from "@/features/page/tree/components/space-tree.tsx";
|
||||||
|
|
||||||
export default function SpaceContent() {
|
export default function SpaceContent() {
|
||||||
const [currentUser] = useAtom(currentUserAtom);
|
const [currentUser] = useAtom(currentUserAtom);
|
||||||
@ -33,7 +33,7 @@ export default function SpaceContent() {
|
|||||||
<Accordion.Item key={space.id} value={space.id}>
|
<Accordion.Item key={space.id} value={space.id}>
|
||||||
<AccordionControl>{space.name}</AccordionControl>
|
<AccordionControl>{space.name}</AccordionControl>
|
||||||
<Accordion.Panel>
|
<Accordion.Panel>
|
||||||
<PageTree spaceId={space.id} />
|
<SpaceTree spaceId={space.id} />
|
||||||
</Accordion.Panel>
|
</Accordion.Panel>
|
||||||
</Accordion.Item>
|
</Accordion.Item>
|
||||||
</Accordion>
|
</Accordion>
|
||||||
@ -45,6 +45,7 @@ function AccordionControl(props: AccordionControlProps) {
|
|||||||
const [tree] = useAtom(treeApiAtom);
|
const [tree] = useAtom(treeApiAtom);
|
||||||
|
|
||||||
function handleCreatePage() {
|
function handleCreatePage() {
|
||||||
|
//todo: create at the bottom
|
||||||
tree?.create({ parentId: null, type: "internal", index: 0 });
|
tree?.create({ parentId: null, type: "internal", index: 0 });
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1,73 +1,79 @@
|
|||||||
import { NodeApi, NodeRendererProps, Tree, TreeApi } from "react-arborist";
|
import { NodeApi, NodeRendererProps, Tree, TreeApi } from "react-arborist";
|
||||||
|
import { useAtom } from "jotai";
|
||||||
|
import { treeApiAtom } from "@/features/page/tree/atoms/tree-api-atom.ts";
|
||||||
|
import {
|
||||||
|
useGetRootSidebarPagesQuery,
|
||||||
|
useGetSidebarPagesQuery,
|
||||||
|
useUpdatePageMutation,
|
||||||
|
} from "@/features/page/queries/page-query.ts";
|
||||||
|
import React, { useEffect, useRef } from "react";
|
||||||
|
import { useNavigate, useParams } from "react-router-dom";
|
||||||
|
import classes from "@/features/page/tree/styles/tree.module.css";
|
||||||
|
import { FillFlexParent } from "@/features/page/tree/components/fill-flex-parent.tsx";
|
||||||
|
import { ActionIcon, Menu, rem } from "@mantine/core";
|
||||||
import {
|
import {
|
||||||
IconArrowsLeftRight,
|
|
||||||
IconChevronDown,
|
IconChevronDown,
|
||||||
IconChevronRight,
|
IconChevronRight,
|
||||||
IconCornerRightUp,
|
|
||||||
IconDotsVertical,
|
IconDotsVertical,
|
||||||
IconEdit,
|
|
||||||
IconFileDescription,
|
IconFileDescription,
|
||||||
IconLink,
|
IconLink,
|
||||||
IconPlus,
|
IconPlus,
|
||||||
|
IconPointFilled,
|
||||||
IconStar,
|
IconStar,
|
||||||
IconTrash,
|
IconTrash,
|
||||||
} from "@tabler/icons-react";
|
} from "@tabler/icons-react";
|
||||||
|
import { treeDataAtom } from "@/features/page/tree/atoms/tree-data-atom.ts";
|
||||||
import React, { useEffect, useRef } from "react";
|
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
|
|
||||||
import classes from "./styles/tree.module.css";
|
|
||||||
import { ActionIcon, Menu, rem } from "@mantine/core";
|
|
||||||
import { useAtom } from "jotai";
|
|
||||||
import { FillFlexParent } from "./components/fill-flex-parent";
|
|
||||||
import { TreeNode } from "./types";
|
|
||||||
import { treeApiAtom } from "./atoms/tree-api-atom";
|
|
||||||
import { usePersistence } from "@/features/page/tree/hooks/use-persistence";
|
|
||||||
import { useNavigate, useParams } from "react-router-dom";
|
|
||||||
import { convertToTree, updateTreeNodeIcon } from "@/features/page/tree/utils";
|
|
||||||
import useSpacePageOrder, {
|
|
||||||
useGetPagesQuery,
|
|
||||||
useUpdatePageMutation,
|
|
||||||
} from "@/features/page/queries/page-query";
|
|
||||||
import EmojiPicker from "@/components/ui/emoji-picker.tsx";
|
import EmojiPicker from "@/components/ui/emoji-picker.tsx";
|
||||||
import { treeDataAtom } from "@/features/page/tree/atoms/tree-data-atom";
|
import { useTreeMutation } from "@/features/page/tree/hooks/use-tree-mutation.ts";
|
||||||
|
import {
|
||||||
|
buildTree,
|
||||||
|
updateTreeNodeIcon,
|
||||||
|
} from "@/features/page/tree/utils/utils.ts";
|
||||||
|
import { SpaceTreeNode } from "@/features/page/tree/types.ts";
|
||||||
|
import { getSidebarPages } from "@/features/page/services/page-service.ts";
|
||||||
|
import { QueryClient } from "@tanstack/react-query";
|
||||||
|
import { SidebarPagesParams } from "@/features/page/types/page.types.ts";
|
||||||
|
|
||||||
interface PageTreeProps {
|
interface SpaceTreeProps {
|
||||||
spaceId: string;
|
spaceId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function PageTree({ spaceId }: PageTreeProps) {
|
export default function SpaceTree({ spaceId }: SpaceTreeProps) {
|
||||||
const { data, setData, controllers } =
|
const { data, setData, controllers } =
|
||||||
usePersistence<TreeApi<TreeNode>>(spaceId);
|
useTreeMutation<TreeApi<SpaceTreeNode>>(spaceId);
|
||||||
const [tree, setTree] = useAtom<TreeApi<TreeNode>>(treeApiAtom);
|
const [treeAPi, setTreeApi] = useAtom<TreeApi<SpaceTreeNode>>(treeApiAtom);
|
||||||
const { data: pageOrderData } = useSpacePageOrder(spaceId);
|
const {
|
||||||
const { data: pagesData, isLoading } = useGetPagesQuery(spaceId);
|
data: pagesData,
|
||||||
|
hasNextPage,
|
||||||
|
fetchNextPage,
|
||||||
|
isFetching,
|
||||||
|
} = useGetRootSidebarPagesQuery({
|
||||||
|
spaceId,
|
||||||
|
});
|
||||||
const rootElement = useRef<HTMLDivElement>();
|
const rootElement = useRef<HTMLDivElement>();
|
||||||
const { pageId } = useParams();
|
const { pageId } = useParams();
|
||||||
|
|
||||||
const fetchAndSetTreeData = async () => {
|
useEffect(() => {
|
||||||
if (pageOrderData?.childrenIds) {
|
if (hasNextPage && !isFetching) {
|
||||||
try {
|
fetchNextPage();
|
||||||
if (!isLoading) {
|
|
||||||
const treeData = convertToTree(pagesData, pageOrderData.childrenIds);
|
|
||||||
setData(treeData);
|
|
||||||
}
|
}
|
||||||
} catch (err) {
|
}, [hasNextPage, fetchNextPage, isFetching]);
|
||||||
console.error("Error fetching tree data: ", err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchAndSetTreeData();
|
if (pagesData?.pages && !hasNextPage) {
|
||||||
}, [pageOrderData?.childrenIds, isLoading]);
|
const allItems = pagesData.pages.flatMap((page) => page.items);
|
||||||
|
const treeData = buildTree(allItems);
|
||||||
|
setData(treeData);
|
||||||
|
}
|
||||||
|
}, [pagesData, hasNextPage]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
tree?.select(pageId);
|
treeAPi?.select(pageId);
|
||||||
tree?.scrollTo(pageId, "center");
|
treeAPi?.scrollTo(pageId, "auto");
|
||||||
}, 200);
|
}, 200);
|
||||||
}, [tree, pageId]);
|
}, [treeAPi, pageId]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={rootElement} className={classes.treeContainer}>
|
<div ref={rootElement} className={classes.treeContainer}>
|
||||||
@ -78,15 +84,16 @@ export default function PageTree({ spaceId }: PageTreeProps) {
|
|||||||
{...controllers}
|
{...controllers}
|
||||||
{...dimens}
|
{...dimens}
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
ref={(t) => setTree(t)}
|
ref={(t) => setTreeApi(t)}
|
||||||
openByDefault={false}
|
openByDefault={false}
|
||||||
disableMultiSelection={true}
|
disableMultiSelection={true}
|
||||||
className={classes.tree}
|
className={classes.tree}
|
||||||
rowClassName={classes.row}
|
rowClassName={classes.row}
|
||||||
padding={15}
|
// padding={15}
|
||||||
rowHeight={30}
|
rowHeight={30}
|
||||||
overscanCount={5}
|
overscanCount={8}
|
||||||
dndRootElement={rootElement.current}
|
dndRootElement={rootElement.current}
|
||||||
|
selectionFollowsFocus
|
||||||
>
|
>
|
||||||
{Node}
|
{Node}
|
||||||
</Tree>
|
</Tree>
|
||||||
@ -96,18 +103,70 @@ export default function PageTree({ spaceId }: PageTreeProps) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const queryClient = new QueryClient();
|
||||||
function Node({ node, style, dragHandle }: NodeRendererProps<any>) {
|
function Node({ node, style, dragHandle }: NodeRendererProps<any>) {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const updatePageMutation = useUpdatePageMutation();
|
const updatePageMutation = useUpdatePageMutation();
|
||||||
|
//const use = useGetExpandPageTreeQuery()
|
||||||
const [treeData, setTreeData] = useAtom(treeDataAtom);
|
const [treeData, setTreeData] = useAtom(treeDataAtom);
|
||||||
|
|
||||||
|
function updateTreeData(
|
||||||
|
treeItems: SpaceTreeNode[],
|
||||||
|
nodeId: string,
|
||||||
|
children: SpaceTreeNode[],
|
||||||
|
) {
|
||||||
|
return treeItems.map((nodeItem) => {
|
||||||
|
if (nodeItem.id === nodeId) {
|
||||||
|
return { ...nodeItem, children };
|
||||||
|
}
|
||||||
|
if (nodeItem.children) {
|
||||||
|
return {
|
||||||
|
...nodeItem,
|
||||||
|
children: updateTreeData(nodeItem.children, nodeId, children),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return nodeItem;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleLoadChildren(node: NodeApi<SpaceTreeNode>) {
|
||||||
|
if (!node.data.hasChildren) return;
|
||||||
|
if (node.data.children && node.data.children.length > 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const params: SidebarPagesParams = {
|
||||||
|
pageId: node.data.id,
|
||||||
|
spaceId: node.data.spaceId,
|
||||||
|
};
|
||||||
|
|
||||||
|
const newChildren = await queryClient.fetchQuery({
|
||||||
|
queryKey: ["sidebar-pages", params],
|
||||||
|
queryFn: () => getSidebarPages(params),
|
||||||
|
});
|
||||||
|
|
||||||
|
const childrenTree = buildTree(newChildren.items);
|
||||||
|
|
||||||
|
const updatedTreeData = updateTreeData(
|
||||||
|
treeData,
|
||||||
|
node.data.id,
|
||||||
|
childrenTree,
|
||||||
|
);
|
||||||
|
|
||||||
|
setTreeData(updatedTreeData);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to fetch children:", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const handleClick = () => {
|
const handleClick = () => {
|
||||||
navigate(`/p/${node.id}`);
|
navigate(`/p/${node.id}`);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleUpdateNodeIcon = (nodeId, newIcon) => {
|
const handleUpdateNodeIcon = (nodeId: string, newIcon: string) => {
|
||||||
const updatedTreeData = updateTreeNodeIcon(treeData, nodeId, newIcon);
|
const updatedTree = updateTreeNodeIcon(treeData, node.id, newIcon);
|
||||||
setTreeData(updatedTreeData);
|
setTreeData(updatedTree);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleEmojiIconClick = (e) => {
|
const handleEmojiIconClick = (e) => {
|
||||||
@ -115,7 +174,7 @@ function Node({ node, style, dragHandle }: NodeRendererProps<any>) {
|
|||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleEmojiSelect = (emoji) => {
|
const handleEmojiSelect = (emoji: { native: string }) => {
|
||||||
handleUpdateNodeIcon(node.id, emoji.native);
|
handleUpdateNodeIcon(node.id, emoji.native);
|
||||||
updatePageMutation.mutateAsync({ pageId: node.id, icon: emoji.native });
|
updatePageMutation.mutateAsync({ pageId: node.id, icon: emoji.native });
|
||||||
};
|
};
|
||||||
@ -126,6 +185,7 @@ function Node({ node, style, dragHandle }: NodeRendererProps<any>) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
if (node.willReceiveDrop && node.isClosed) {
|
if (node.willReceiveDrop && node.isClosed) {
|
||||||
|
handleLoadChildren(node);
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
if (node.state.willReceiveDrop) node.open();
|
if (node.state.willReceiveDrop) node.open();
|
||||||
}, 650);
|
}, 650);
|
||||||
@ -139,7 +199,7 @@ function Node({ node, style, dragHandle }: NodeRendererProps<any>) {
|
|||||||
ref={dragHandle}
|
ref={dragHandle}
|
||||||
onClick={handleClick}
|
onClick={handleClick}
|
||||||
>
|
>
|
||||||
<PageArrow node={node} />
|
<PageArrow node={node} onExpandTree={() => handleLoadChildren(node)} />
|
||||||
|
|
||||||
<div onClick={handleEmojiIconClick} style={{ marginRight: "4px" }}>
|
<div onClick={handleEmojiIconClick} style={{ marginRight: "4px" }}>
|
||||||
<EmojiPicker
|
<EmojiPicker
|
||||||
@ -155,28 +215,38 @@ function Node({ node, style, dragHandle }: NodeRendererProps<any>) {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<span className={classes.text}>
|
<span className={classes.text}>{node.data.name || "untitled"}</span>
|
||||||
{node.isEditing ? (
|
|
||||||
<Input node={node} />
|
|
||||||
) : (
|
|
||||||
node.data.name || "untitled"
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
|
|
||||||
<div className={classes.actions}>
|
<div className={classes.actions}>
|
||||||
<NodeMenu node={node} />
|
<NodeMenu node={node} />
|
||||||
<CreateNode node={node} />
|
<CreateNode
|
||||||
|
node={node}
|
||||||
|
onExpandTree={() => handleLoadChildren(node)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function CreateNode({ node }: { node: NodeApi<TreeNode> }) {
|
interface CreateNodeProps {
|
||||||
const [tree] = useAtom(treeApiAtom);
|
node: NodeApi<SpaceTreeNode>;
|
||||||
|
onExpandTree?: () => void;
|
||||||
|
}
|
||||||
|
function CreateNode({ node, onExpandTree }: CreateNodeProps) {
|
||||||
|
const [treeApi] = useAtom(treeApiAtom);
|
||||||
|
|
||||||
function handleCreate() {
|
function handleCreate() {
|
||||||
tree?.create({ type: "internal", parentId: node.id, index: 0 });
|
if (node.data.hasChildren && node.children.length === 0) {
|
||||||
|
node.toggle();
|
||||||
|
onExpandTree();
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
treeApi?.create({ type: "internal", parentId: node.id, index: 0 });
|
||||||
|
}, 500);
|
||||||
|
} else {
|
||||||
|
treeApi?.create({ type: "internal", parentId: node.id });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -194,12 +264,8 @@ function CreateNode({ node }: { node: NodeApi<TreeNode> }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function NodeMenu({ node }: { node: NodeApi<TreeNode> }) {
|
function NodeMenu({ node }: { node: NodeApi<SpaceTreeNode> }) {
|
||||||
const [tree] = useAtom(treeApiAtom);
|
const [treeApi] = useAtom(treeApiAtom);
|
||||||
|
|
||||||
function handleDelete() {
|
|
||||||
tree?.delete(node);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Menu shadow="md" width={200}>
|
<Menu shadow="md" width={200}>
|
||||||
@ -220,16 +286,6 @@ function NodeMenu({ node }: { node: NodeApi<TreeNode> }) {
|
|||||||
</Menu.Target>
|
</Menu.Target>
|
||||||
|
|
||||||
<Menu.Dropdown>
|
<Menu.Dropdown>
|
||||||
<Menu.Item
|
|
||||||
leftSection={<IconEdit style={{ width: rem(14), height: rem(14) }} />}
|
|
||||||
onClick={(e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
node.edit();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Rename
|
|
||||||
</Menu.Item>
|
|
||||||
<Menu.Item
|
<Menu.Item
|
||||||
leftSection={<IconStar style={{ width: rem(14), height: rem(14) }} />}
|
leftSection={<IconStar style={{ width: rem(14), height: rem(14) }} />}
|
||||||
>
|
>
|
||||||
@ -244,28 +300,13 @@ function NodeMenu({ node }: { node: NodeApi<TreeNode> }) {
|
|||||||
Copy link
|
Copy link
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
|
|
||||||
<Menu.Item
|
|
||||||
leftSection={
|
|
||||||
<IconCornerRightUp style={{ width: rem(14), height: rem(14) }} />
|
|
||||||
}
|
|
||||||
>
|
|
||||||
Move
|
|
||||||
</Menu.Item>
|
|
||||||
|
|
||||||
<Menu.Divider />
|
<Menu.Divider />
|
||||||
<Menu.Item
|
|
||||||
leftSection={
|
|
||||||
<IconArrowsLeftRight style={{ width: rem(14), height: rem(14) }} />
|
|
||||||
}
|
|
||||||
>
|
|
||||||
Archive
|
|
||||||
</Menu.Item>
|
|
||||||
<Menu.Item
|
<Menu.Item
|
||||||
c="red"
|
c="red"
|
||||||
leftSection={
|
leftSection={
|
||||||
<IconTrash style={{ width: rem(14), height: rem(14) }} />
|
<IconTrash style={{ width: rem(14), height: rem(14) }} />
|
||||||
}
|
}
|
||||||
onClick={() => handleDelete()}
|
onClick={() => treeApi?.delete(node)}
|
||||||
>
|
>
|
||||||
Delete
|
Delete
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
@ -274,7 +315,11 @@ function NodeMenu({ node }: { node: NodeApi<TreeNode> }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function PageArrow({ node }: { node: NodeApi<TreeNode> }) {
|
interface PageArrowProps {
|
||||||
|
node: NodeApi<SpaceTreeNode>;
|
||||||
|
onExpandTree?: () => void;
|
||||||
|
}
|
||||||
|
function PageArrow({ node, onExpandTree }: PageArrowProps) {
|
||||||
return (
|
return (
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
size={20}
|
size={20}
|
||||||
@ -284,37 +329,20 @@ function PageArrow({ node }: { node: NodeApi<TreeNode> }) {
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
node.toggle();
|
node.toggle();
|
||||||
|
onExpandTree();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{node.isInternal ? (
|
{node.isInternal ? (
|
||||||
node.children && node.children.length > 0 ? (
|
node.children && (node.children.length > 0 || node.data.hasChildren) ? (
|
||||||
node.isOpen ? (
|
node.isOpen ? (
|
||||||
<IconChevronDown stroke={2} size={18} />
|
<IconChevronDown stroke={2} size={18} />
|
||||||
) : (
|
) : (
|
||||||
<IconChevronRight stroke={2} size={18} />
|
<IconChevronRight stroke={2} size={18} />
|
||||||
)
|
)
|
||||||
) : (
|
) : (
|
||||||
<IconChevronRight size={18} style={{ visibility: "hidden" }} />
|
<IconPointFilled size={8} />
|
||||||
)
|
)
|
||||||
) : null}
|
) : null}
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function Input({ node }: { node: NodeApi<TreeNode> }) {
|
|
||||||
return (
|
|
||||||
<input
|
|
||||||
autoFocus
|
|
||||||
name="name"
|
|
||||||
type="text"
|
|
||||||
placeholder="untitled"
|
|
||||||
defaultValue={node.data.name}
|
|
||||||
onFocus={(e) => e.currentTarget.select()}
|
|
||||||
onBlur={() => node.reset()}
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key === "Escape") node.reset();
|
|
||||||
if (e.key === "Enter") node.submit(e.currentTarget.value);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,127 +0,0 @@
|
|||||||
import { useMemo } from "react";
|
|
||||||
import {
|
|
||||||
CreateHandler,
|
|
||||||
DeleteHandler,
|
|
||||||
MoveHandler,
|
|
||||||
RenameHandler,
|
|
||||||
SimpleTree,
|
|
||||||
} from "react-arborist";
|
|
||||||
import { useAtom } from "jotai";
|
|
||||||
import { treeDataAtom } from "@/features/page/tree/atoms/tree-data-atom";
|
|
||||||
import { movePage } from "@/features/page/services/page-service";
|
|
||||||
import { v4 as uuidv4 } from "uuid";
|
|
||||||
import { IMovePage } from "@/features/page/types/page.types";
|
|
||||||
import { useNavigate } from "react-router-dom";
|
|
||||||
import { TreeNode } from "@/features/page/tree/types";
|
|
||||||
import {
|
|
||||||
useCreatePageMutation,
|
|
||||||
useDeletePageMutation,
|
|
||||||
useUpdatePageMutation,
|
|
||||||
} from "@/features/page/queries/page-query";
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
spaceId: string;
|
|
||||||
}
|
|
||||||
export function usePersistence<T>(spaceId: string) {
|
|
||||||
const [data, setData] = useAtom(treeDataAtom);
|
|
||||||
const createPageMutation = useCreatePageMutation();
|
|
||||||
const updatePageMutation = useUpdatePageMutation();
|
|
||||||
const deletePageMutation = useDeletePageMutation();
|
|
||||||
|
|
||||||
const navigate = useNavigate();
|
|
||||||
|
|
||||||
const tree = useMemo(() => new SimpleTree<TreeNode>(data), [data]);
|
|
||||||
|
|
||||||
const onMove: MoveHandler<T> = (args: {
|
|
||||||
parentId;
|
|
||||||
index;
|
|
||||||
parentNode;
|
|
||||||
dragNodes;
|
|
||||||
dragIds;
|
|
||||||
}) => {
|
|
||||||
for (const id of args.dragIds) {
|
|
||||||
tree.move({ id, parentId: args.parentId, index: args.index });
|
|
||||||
}
|
|
||||||
setData(tree.data);
|
|
||||||
|
|
||||||
const newDragIndex = tree.find(args.dragIds[0])?.childIndex;
|
|
||||||
|
|
||||||
const currentTreeData = args.parentId
|
|
||||||
? tree.find(args.parentId).children
|
|
||||||
: tree.data;
|
|
||||||
const afterId = currentTreeData[newDragIndex - 1]?.id || null;
|
|
||||||
const beforeId =
|
|
||||||
(!afterId && currentTreeData[newDragIndex + 1]?.id) || null;
|
|
||||||
|
|
||||||
const params: IMovePage = {
|
|
||||||
pageId: args.dragIds[0],
|
|
||||||
after: afterId,
|
|
||||||
before: beforeId,
|
|
||||||
parentId: args.parentId || null,
|
|
||||||
};
|
|
||||||
|
|
||||||
const payload = Object.fromEntries(
|
|
||||||
Object.entries(params).filter(
|
|
||||||
([key, value]) => value !== null && value !== undefined,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
try {
|
|
||||||
movePage(payload as IMovePage);
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error moving page:", error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const onRename: RenameHandler<T> = ({ name, id }) => {
|
|
||||||
tree.update({ id, changes: { name } as any });
|
|
||||||
setData(tree.data);
|
|
||||||
|
|
||||||
try {
|
|
||||||
updatePageMutation.mutateAsync({ pageId: id, title: name });
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error updating page title:", error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const onCreate: CreateHandler<T> = async ({ parentId, index, type }) => {
|
|
||||||
const data = { id: uuidv4(), name: "" } as any;
|
|
||||||
data.children = [];
|
|
||||||
tree.create({ parentId, index, data });
|
|
||||||
setData(tree.data);
|
|
||||||
|
|
||||||
const payload: { pageId: string; parentPageId?: string; spaceId: string } =
|
|
||||||
{
|
|
||||||
pageId: data.id,
|
|
||||||
spaceId: spaceId,
|
|
||||||
};
|
|
||||||
if (parentId) {
|
|
||||||
payload.parentPageId = parentId;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
await createPageMutation.mutateAsync(payload);
|
|
||||||
navigate(`/p/${payload.pageId}`);
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error creating the page:", error);
|
|
||||||
}
|
|
||||||
|
|
||||||
return data;
|
|
||||||
};
|
|
||||||
|
|
||||||
const onDelete: DeleteHandler<T> = async (args: { ids: string[] }) => {
|
|
||||||
args.ids.forEach((id) => tree.drop({ id }));
|
|
||||||
setData(tree.data);
|
|
||||||
|
|
||||||
try {
|
|
||||||
await deletePageMutation.mutateAsync(args.ids[0]);
|
|
||||||
navigate("/home");
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error deleting page:", error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const controllers = { onMove, onRename, onCreate, onDelete };
|
|
||||||
|
|
||||||
return { data, setData, controllers } as const;
|
|
||||||
}
|
|
||||||
166
apps/client/src/features/page/tree/hooks/use-tree-mutation.ts
Normal file
166
apps/client/src/features/page/tree/hooks/use-tree-mutation.ts
Normal file
@ -0,0 +1,166 @@
|
|||||||
|
import { useMemo } from "react";
|
||||||
|
import {
|
||||||
|
CreateHandler,
|
||||||
|
DeleteHandler,
|
||||||
|
MoveHandler,
|
||||||
|
NodeApi,
|
||||||
|
RenameHandler,
|
||||||
|
SimpleTree,
|
||||||
|
} from "react-arborist";
|
||||||
|
import { useAtom } from "jotai";
|
||||||
|
import { treeDataAtom } from "@/features/page/tree/atoms/tree-data-atom.ts";
|
||||||
|
import { IMovePage, IPage } from "@/features/page/types/page.types.ts";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import {
|
||||||
|
useCreatePageMutation,
|
||||||
|
useDeletePageMutation,
|
||||||
|
useMovePageMutation,
|
||||||
|
useUpdatePageMutation,
|
||||||
|
} from "@/features/page/queries/page-query.ts";
|
||||||
|
import { generateJitteredKeyBetween } from "fractional-indexing-jittered";
|
||||||
|
import { SpaceTreeNode } from "@/features/page/tree/types.ts";
|
||||||
|
|
||||||
|
export function useTreeMutation<T>(spaceId: string) {
|
||||||
|
const [data, setData] = useAtom(treeDataAtom);
|
||||||
|
const tree = useMemo(() => new SimpleTree<SpaceTreeNode>(data), [data]);
|
||||||
|
const createPageMutation = useCreatePageMutation();
|
||||||
|
const updatePageMutation = useUpdatePageMutation();
|
||||||
|
const deletePageMutation = useDeletePageMutation();
|
||||||
|
const movePageMutation = useMovePageMutation();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const onCreate: CreateHandler<T> = async ({ parentId, index, type }) => {
|
||||||
|
const payload: { spaceId: string; parentPageId?: string } = {
|
||||||
|
spaceId: spaceId,
|
||||||
|
};
|
||||||
|
if (parentId) {
|
||||||
|
payload.parentPageId = parentId;
|
||||||
|
}
|
||||||
|
|
||||||
|
let createdPage: IPage;
|
||||||
|
try {
|
||||||
|
createdPage = await createPageMutation.mutateAsync(payload);
|
||||||
|
} catch (err) {
|
||||||
|
throw new Error("Failed to create page");
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = {
|
||||||
|
id: createdPage.id,
|
||||||
|
name: "",
|
||||||
|
position: createdPage.position,
|
||||||
|
children: [],
|
||||||
|
} as any;
|
||||||
|
|
||||||
|
let lastIndex: number;
|
||||||
|
if (parentId === null) {
|
||||||
|
lastIndex = tree.data.length;
|
||||||
|
} else {
|
||||||
|
lastIndex = tree.find(parentId).children.length;
|
||||||
|
}
|
||||||
|
// to place the newly created node at the bottom
|
||||||
|
index = lastIndex;
|
||||||
|
|
||||||
|
tree.create({ parentId, index, data });
|
||||||
|
setData(tree.data);
|
||||||
|
|
||||||
|
navigate(`/p/${createdPage.id}`);
|
||||||
|
return data;
|
||||||
|
};
|
||||||
|
|
||||||
|
const onMove: MoveHandler<T> = (args: {
|
||||||
|
dragIds: string[];
|
||||||
|
dragNodes: NodeApi<T>[];
|
||||||
|
parentId: string | null;
|
||||||
|
parentNode: NodeApi<T> | null;
|
||||||
|
index: number;
|
||||||
|
}) => {
|
||||||
|
const draggedNodeId = args.dragIds[0];
|
||||||
|
|
||||||
|
tree.move({
|
||||||
|
id: draggedNodeId,
|
||||||
|
parentId: args.parentId,
|
||||||
|
index: args.index,
|
||||||
|
});
|
||||||
|
|
||||||
|
const newDragIndex = tree.find(draggedNodeId)?.childIndex;
|
||||||
|
|
||||||
|
const currentTreeData = args.parentId
|
||||||
|
? tree.find(args.parentId).children
|
||||||
|
: tree.data;
|
||||||
|
|
||||||
|
// if there is a parentId, tree.find(args.parentId).children returns a SimpleNode array
|
||||||
|
// we have to access the node differently viq currentTreeData[args.index]?.data?.position
|
||||||
|
// this makes it possible to correctly sort children of a parent node that is not the root
|
||||||
|
|
||||||
|
const afterPosition =
|
||||||
|
// @ts-ignore
|
||||||
|
currentTreeData[newDragIndex - 1]?.position ||
|
||||||
|
// @ts-ignore
|
||||||
|
currentTreeData[args.index - 1]?.data?.position ||
|
||||||
|
null;
|
||||||
|
|
||||||
|
const beforePosition =
|
||||||
|
// @ts-ignore
|
||||||
|
currentTreeData[newDragIndex + 1]?.position ||
|
||||||
|
// @ts-ignore
|
||||||
|
currentTreeData[args.index + 1]?.data?.position ||
|
||||||
|
null;
|
||||||
|
|
||||||
|
let newPosition: string;
|
||||||
|
|
||||||
|
if (afterPosition && beforePosition && afterPosition === beforePosition) {
|
||||||
|
// if after is equal to before, put it next to the after node
|
||||||
|
newPosition = generateJitteredKeyBetween(afterPosition, null);
|
||||||
|
} else {
|
||||||
|
// if both are null then, it is the first index
|
||||||
|
newPosition = generateJitteredKeyBetween(afterPosition, beforePosition);
|
||||||
|
}
|
||||||
|
|
||||||
|
// update the node position in tree
|
||||||
|
tree.update({
|
||||||
|
id: draggedNodeId,
|
||||||
|
changes: { position: newPosition } as any,
|
||||||
|
});
|
||||||
|
|
||||||
|
setData(tree.data);
|
||||||
|
|
||||||
|
const payload: IMovePage = {
|
||||||
|
pageId: draggedNodeId,
|
||||||
|
position: newPosition,
|
||||||
|
parentPageId: args.parentId,
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
movePageMutation.mutateAsync(payload);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error moving page:", error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onRename: RenameHandler<T> = ({ name, id }) => {
|
||||||
|
tree.update({ id, changes: { name } as any });
|
||||||
|
setData(tree.data);
|
||||||
|
|
||||||
|
try {
|
||||||
|
updatePageMutation.mutateAsync({ pageId: id, title: name });
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error updating page title:", error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onDelete: DeleteHandler<T> = async (args: { ids: string[] }) => {
|
||||||
|
try {
|
||||||
|
await deletePageMutation.mutateAsync(args.ids[0]);
|
||||||
|
|
||||||
|
tree.drop({ id: args.ids[0] });
|
||||||
|
setData(tree.data);
|
||||||
|
|
||||||
|
navigate("/home");
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to delete page:", error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const controllers = { onMove, onRename, onCreate, onDelete };
|
||||||
|
return { data, setData, controllers } as const;
|
||||||
|
}
|
||||||
@ -20,7 +20,8 @@
|
|||||||
color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-dark-0));
|
color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-dark-0));
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
background-color: light-dark(var(--mantine-color-gray-1), var(--mantine-color-dark-5));
|
background-color: light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-5));
|
||||||
|
/*background-color: light-dark(var(--mantine-color-gray-1), var(--mantine-color-dark-6));*/
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@ -64,7 +65,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.row:focus .node:global(.isSelected) {
|
.row:focus .node:global(.isSelected) {
|
||||||
background-color: light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-6));
|
background-color: light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-5));
|
||||||
}
|
}
|
||||||
|
|
||||||
.row {
|
.row {
|
||||||
@ -78,7 +79,7 @@
|
|||||||
|
|
||||||
.row:focus .node {
|
.row:focus .node {
|
||||||
/** come back to this **/
|
/** come back to this **/
|
||||||
background-color: light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-5));
|
/* background-color: light-dark(var(--mantine-color-red-2), var(--mantine-color-dark-5));*/
|
||||||
}
|
}
|
||||||
|
|
||||||
.icon {
|
.icon {
|
||||||
|
|||||||
@ -1,7 +1,10 @@
|
|||||||
export type TreeNode = {
|
export type SpaceTreeNode = {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
icon?: string;
|
icon?: string;
|
||||||
|
position: string;
|
||||||
slug?: string;
|
slug?: string;
|
||||||
children: TreeNode[];
|
spaceId: string;
|
||||||
|
hasChildren: boolean;
|
||||||
|
children: SpaceTreeNode[];
|
||||||
};
|
};
|
||||||
|
|||||||
@ -1,74 +1 @@
|
|||||||
import { IPage } from '@/features/page/types/page.types';
|
export * from "./utils.ts";
|
||||||
import { TreeNode } from '@/features/page/tree/types';
|
|
||||||
|
|
||||||
export function convertToTree(pages: IPage[], pageOrder: string[]): TreeNode[] {
|
|
||||||
const pageMap: { [id: string]: IPage } = {};
|
|
||||||
pages.forEach(page => {
|
|
||||||
pageMap[page.id] = page;
|
|
||||||
});
|
|
||||||
|
|
||||||
function buildTreeNode(id: string): TreeNode | undefined {
|
|
||||||
const page = pageMap[id];
|
|
||||||
if (!page) return;
|
|
||||||
|
|
||||||
const node: TreeNode = {
|
|
||||||
id: page.id,
|
|
||||||
name: page.title,
|
|
||||||
children: [],
|
|
||||||
};
|
|
||||||
|
|
||||||
if (page.icon) node.icon = page.icon;
|
|
||||||
|
|
||||||
if (page.childrenIds && page.childrenIds.length > 0) {
|
|
||||||
node.children = page.childrenIds.map(childId => buildTreeNode(childId)).filter(Boolean) as TreeNode[];
|
|
||||||
}
|
|
||||||
|
|
||||||
return node;
|
|
||||||
}
|
|
||||||
|
|
||||||
return pageOrder.map(id => buildTreeNode(id)).filter(Boolean) as TreeNode[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export function findBreadcrumbPath(tree: TreeNode[], pageId: string, path: TreeNode[] = []): TreeNode[] | null {
|
|
||||||
for (const node of tree) {
|
|
||||||
if (!node.name || node.name.trim() === "") {
|
|
||||||
node.name = "untitled";
|
|
||||||
}
|
|
||||||
|
|
||||||
if (node.id === pageId) {
|
|
||||||
return [...path, node];
|
|
||||||
}
|
|
||||||
|
|
||||||
if (node.children) {
|
|
||||||
const newPath = findBreadcrumbPath(node.children, pageId, [...path, node]);
|
|
||||||
if (newPath) {
|
|
||||||
return newPath;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const updateTreeNodeName = (nodes: TreeNode[], nodeId: string, newName: string): TreeNode[] => {
|
|
||||||
return nodes.map(node => {
|
|
||||||
if (node.id === nodeId) {
|
|
||||||
return { ...node, name: newName };
|
|
||||||
}
|
|
||||||
if (node.children && node.children.length > 0) {
|
|
||||||
return { ...node, children: updateTreeNodeName(node.children, nodeId, newName) };
|
|
||||||
}
|
|
||||||
return node;
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
export const updateTreeNodeIcon = (nodes: TreeNode[], nodeId: string, newIcon: string): TreeNode[] => {
|
|
||||||
return nodes.map(node => {
|
|
||||||
if (node.id === nodeId) {
|
|
||||||
return { ...node, icon: newIcon };
|
|
||||||
}
|
|
||||||
if (node.children && node.children.length > 0) {
|
|
||||||
return { ...node, children: updateTreeNodeIcon(node.children, nodeId, newIcon) };
|
|
||||||
}
|
|
||||||
return node;
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|||||||
99
apps/client/src/features/page/tree/utils/utils.ts
Normal file
99
apps/client/src/features/page/tree/utils/utils.ts
Normal file
@ -0,0 +1,99 @@
|
|||||||
|
import { IPage } from "@/features/page/types/page.types.ts";
|
||||||
|
import { SpaceTreeNode } from "@/features/page/tree/types.ts";
|
||||||
|
|
||||||
|
function sortPositionKeys(keys: any[]) {
|
||||||
|
return keys.sort((a, b) => {
|
||||||
|
if (a.position < b.position) return -1;
|
||||||
|
if (a.position > b.position) return 1;
|
||||||
|
return 0;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildTree(pages: IPage[]): SpaceTreeNode[] {
|
||||||
|
const pageMap: Record<string, SpaceTreeNode> = {};
|
||||||
|
|
||||||
|
const tree: SpaceTreeNode[] = [];
|
||||||
|
|
||||||
|
pages.forEach((page) => {
|
||||||
|
pageMap[page.id] = {
|
||||||
|
id: page.id,
|
||||||
|
name: page.title,
|
||||||
|
icon: page.icon,
|
||||||
|
position: page.position,
|
||||||
|
hasChildren: page.hasChildren,
|
||||||
|
spaceId: page.spaceId,
|
||||||
|
children: [],
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
pages.forEach((page) => {
|
||||||
|
tree.push(pageMap[page.id]);
|
||||||
|
});
|
||||||
|
|
||||||
|
return sortPositionKeys(tree);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function findBreadcrumbPath(
|
||||||
|
tree: SpaceTreeNode[],
|
||||||
|
pageId: string,
|
||||||
|
path: SpaceTreeNode[] = [],
|
||||||
|
): SpaceTreeNode[] | null {
|
||||||
|
for (const node of tree) {
|
||||||
|
if (!node.name || node.name.trim() === "") {
|
||||||
|
node.name = "untitled";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (node.id === pageId) {
|
||||||
|
return [...path, node];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (node.children) {
|
||||||
|
const newPath = findBreadcrumbPath(node.children, pageId, [
|
||||||
|
...path,
|
||||||
|
node,
|
||||||
|
]);
|
||||||
|
if (newPath) {
|
||||||
|
return newPath;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const updateTreeNodeName = (
|
||||||
|
nodes: SpaceTreeNode[],
|
||||||
|
nodeId: string,
|
||||||
|
newName: string,
|
||||||
|
): SpaceTreeNode[] => {
|
||||||
|
return nodes.map((node) => {
|
||||||
|
if (node.id === nodeId) {
|
||||||
|
return { ...node, name: newName };
|
||||||
|
}
|
||||||
|
if (node.children && node.children.length > 0) {
|
||||||
|
return {
|
||||||
|
...node,
|
||||||
|
children: updateTreeNodeName(node.children, nodeId, newName),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return node;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const updateTreeNodeIcon = (
|
||||||
|
nodes: SpaceTreeNode[],
|
||||||
|
nodeId: string,
|
||||||
|
newIcon: string,
|
||||||
|
): SpaceTreeNode[] => {
|
||||||
|
return nodes.map((node) => {
|
||||||
|
if (node.id === nodeId) {
|
||||||
|
return { ...node, icon: newIcon };
|
||||||
|
}
|
||||||
|
if (node.children && node.children.length > 0) {
|
||||||
|
return {
|
||||||
|
...node,
|
||||||
|
children: updateTreeNodeIcon(node.children, nodeId, newIcon),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return node;
|
||||||
|
});
|
||||||
|
};
|
||||||
@ -21,17 +21,20 @@ export interface IPage {
|
|||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
updatedAt: Date;
|
updatedAt: Date;
|
||||||
deletedAt: Date;
|
deletedAt: Date;
|
||||||
|
position: string;
|
||||||
|
hasChildren: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IMovePage {
|
export interface IMovePage {
|
||||||
pageId: string;
|
pageId: string;
|
||||||
|
position?: string;
|
||||||
after?: string;
|
after?: string;
|
||||||
before?: string;
|
before?: string;
|
||||||
parentId?: string;
|
parentPageId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IWorkspacePageOrder {
|
export interface SidebarPagesParams {
|
||||||
id: string;
|
spaceId: string;
|
||||||
childrenIds: string[];
|
pageId?: string;
|
||||||
workspaceId: string;
|
page?: number; // pagination
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,10 +1,6 @@
|
|||||||
import { IsOptional, IsString, IsUUID } from 'class-validator';
|
import { IsOptional, IsString, IsUUID } from 'class-validator';
|
||||||
|
|
||||||
export class CreatePageDto {
|
export class CreatePageDto {
|
||||||
@IsOptional()
|
|
||||||
@IsUUID()
|
|
||||||
pageId?: string;
|
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
title?: string;
|
title?: string;
|
||||||
|
|||||||
@ -1,18 +1,21 @@
|
|||||||
import { IsString, IsOptional, IsUUID } from 'class-validator';
|
import {
|
||||||
|
IsString,
|
||||||
|
IsUUID,
|
||||||
|
IsOptional,
|
||||||
|
MinLength,
|
||||||
|
MaxLength,
|
||||||
|
} from 'class-validator';
|
||||||
|
|
||||||
export class MovePageDto {
|
export class MovePageDto {
|
||||||
@IsUUID()
|
@IsUUID()
|
||||||
pageId: string;
|
pageId: string;
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@IsString()
|
@IsString()
|
||||||
after?: string;
|
@MinLength(5)
|
||||||
|
@MaxLength(12)
|
||||||
|
position: string;
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
before?: string;
|
parentPageId?: string | null;
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@IsString()
|
|
||||||
parentId?: string | null;
|
|
||||||
}
|
}
|
||||||
|
|||||||
8
apps/server/src/core/page/dto/sidebar-page.dto.ts
Normal file
8
apps/server/src/core/page/dto/sidebar-page.dto.ts
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
import { IsOptional, IsUUID } from 'class-validator';
|
||||||
|
import { SpaceIdDto } from './page.dto';
|
||||||
|
|
||||||
|
export class SidebarPageDto extends SpaceIdDto {
|
||||||
|
@IsOptional()
|
||||||
|
@IsUUID()
|
||||||
|
pageId: string;
|
||||||
|
}
|
||||||
@ -11,36 +11,29 @@ 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 } from './dto/move-page.dto';
|
||||||
import { PageHistoryIdDto, PageIdDto, SpaceIdDto } from './dto/page.dto';
|
import { PageHistoryIdDto, PageIdDto, SpaceIdDto } from './dto/page.dto';
|
||||||
import { PageOrderingService } from './services/page-ordering.service';
|
|
||||||
import { PageHistoryService } from './services/page-history.service';
|
import { PageHistoryService } from './services/page-history.service';
|
||||||
import { AuthUser } from '../../decorators/auth-user.decorator';
|
import { AuthUser } from '../../decorators/auth-user.decorator';
|
||||||
import { AuthWorkspace } from '../../decorators/auth-workspace.decorator';
|
import { AuthWorkspace } from '../../decorators/auth-workspace.decorator';
|
||||||
import { JwtAuthGuard } from '../../guards/jwt-auth.guard';
|
import { JwtAuthGuard } from '../../guards/jwt-auth.guard';
|
||||||
import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
|
import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
|
||||||
import { User, Workspace } from '@docmost/db/types/entity.types';
|
import { User, Workspace } from '@docmost/db/types/entity.types';
|
||||||
|
import { SidebarPageDto } from './dto/sidebar-page.dto';
|
||||||
|
|
||||||
@UseGuards(JwtAuthGuard)
|
@UseGuards(JwtAuthGuard)
|
||||||
@Controller('pages')
|
@Controller('pages')
|
||||||
export class PageController {
|
export class PageController {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly pageService: PageService,
|
private readonly pageService: PageService,
|
||||||
private readonly pageOrderService: PageOrderingService,
|
|
||||||
private readonly pageHistoryService: PageHistoryService,
|
private readonly pageHistoryService: PageHistoryService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@HttpCode(HttpStatus.OK)
|
|
||||||
@Post()
|
|
||||||
async getSpacePages(@Body() spaceIdDto: SpaceIdDto) {
|
|
||||||
return this.pageService.getSidebarPagesBySpaceId(spaceIdDto.spaceId);
|
|
||||||
}
|
|
||||||
|
|
||||||
@HttpCode(HttpStatus.OK)
|
@HttpCode(HttpStatus.OK)
|
||||||
@Post('/info')
|
@Post('/info')
|
||||||
async getPage(@Body() pageIdDto: PageIdDto) {
|
async getPage(@Body() pageIdDto: PageIdDto) {
|
||||||
return this.pageService.findById(pageIdDto.pageId);
|
return this.pageService.findById(pageIdDto.pageId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@HttpCode(HttpStatus.CREATED)
|
@HttpCode(HttpStatus.OK)
|
||||||
@Post('create')
|
@Post('create')
|
||||||
async create(
|
async create(
|
||||||
@Body() createPageDto: CreatePageDto,
|
@Body() createPageDto: CreatePageDto,
|
||||||
@ -72,12 +65,6 @@ export class PageController {
|
|||||||
// await this.pageService.restore(deletePageDto.id);
|
// await this.pageService.restore(deletePageDto.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@HttpCode(HttpStatus.OK)
|
|
||||||
@Post('move')
|
|
||||||
async movePage(@Body() movePageDto: MovePageDto) {
|
|
||||||
return this.pageOrderService.movePage(movePageDto);
|
|
||||||
}
|
|
||||||
|
|
||||||
@HttpCode(HttpStatus.OK)
|
@HttpCode(HttpStatus.OK)
|
||||||
@Post('recent')
|
@Post('recent')
|
||||||
async getRecentSpacePages(
|
async getRecentSpacePages(
|
||||||
@ -87,18 +74,6 @@ export class PageController {
|
|||||||
return this.pageService.getRecentSpacePages(spaceIdDto.spaceId, pagination);
|
return this.pageService.getRecentSpacePages(spaceIdDto.spaceId, pagination);
|
||||||
}
|
}
|
||||||
|
|
||||||
@HttpCode(HttpStatus.OK)
|
|
||||||
@Post('ordering')
|
|
||||||
async getSpacePageOrder(@Body() spaceIdDto: SpaceIdDto) {
|
|
||||||
return this.pageOrderService.getSpacePageOrder(spaceIdDto.spaceId);
|
|
||||||
}
|
|
||||||
|
|
||||||
@HttpCode(HttpStatus.OK)
|
|
||||||
@Post('tree')
|
|
||||||
async spacePageTree(@Body() spaceIdDto: SpaceIdDto) {
|
|
||||||
return this.pageOrderService.convertToTree(spaceIdDto.spaceId);
|
|
||||||
}
|
|
||||||
|
|
||||||
// TODO: scope to workspaces
|
// TODO: scope to workspaces
|
||||||
@HttpCode(HttpStatus.OK)
|
@HttpCode(HttpStatus.OK)
|
||||||
@Post('/history')
|
@Post('/history')
|
||||||
@ -111,7 +86,22 @@ export class PageController {
|
|||||||
|
|
||||||
@HttpCode(HttpStatus.OK)
|
@HttpCode(HttpStatus.OK)
|
||||||
@Post('/history/details')
|
@Post('/history/details')
|
||||||
async get(@Body() dto: PageHistoryIdDto) {
|
async getPageHistoryInfo(@Body() dto: PageHistoryIdDto) {
|
||||||
return this.pageHistoryService.findById(dto.historyId);
|
return this.pageHistoryService.findById(dto.historyId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@HttpCode(HttpStatus.OK)
|
||||||
|
@Post('/sidebar-pages')
|
||||||
|
async getSidebarPages(
|
||||||
|
@Body() dto: SidebarPageDto,
|
||||||
|
@Body() pagination: PaginationOptions,
|
||||||
|
) {
|
||||||
|
return this.pageService.getSidebarPages(dto, pagination);
|
||||||
|
}
|
||||||
|
|
||||||
|
@HttpCode(HttpStatus.OK)
|
||||||
|
@Post('move')
|
||||||
|
async movePage(@Body() movePageDto: MovePageDto) {
|
||||||
|
return this.pageService.movePage(movePageDto);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,13 +2,12 @@ import { Module } from '@nestjs/common';
|
|||||||
import { PageService } from './services/page.service';
|
import { PageService } from './services/page.service';
|
||||||
import { PageController } from './page.controller';
|
import { PageController } from './page.controller';
|
||||||
import { WorkspaceModule } from '../workspace/workspace.module';
|
import { WorkspaceModule } from '../workspace/workspace.module';
|
||||||
import { PageOrderingService } from './services/page-ordering.service';
|
|
||||||
import { PageHistoryService } from './services/page-history.service';
|
import { PageHistoryService } from './services/page-history.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [WorkspaceModule],
|
imports: [WorkspaceModule],
|
||||||
controllers: [PageController],
|
controllers: [PageController],
|
||||||
providers: [PageService, PageOrderingService, PageHistoryService],
|
providers: [PageService, PageHistoryService],
|
||||||
exports: [PageService, PageOrderingService, PageHistoryService],
|
exports: [PageService, PageHistoryService],
|
||||||
})
|
})
|
||||||
export class PageModule {}
|
export class PageModule {}
|
||||||
|
|||||||
@ -1,87 +0,0 @@
|
|||||||
import { KyselyTransaction } from '@docmost/db/types/kysely.types';
|
|
||||||
import { MovePageDto } from './dto/move-page.dto';
|
|
||||||
import { PageOrdering } from '@docmost/db/types/entity.types';
|
|
||||||
|
|
||||||
export enum OrderingEntity {
|
|
||||||
WORKSPACE = 'WORKSPACE',
|
|
||||||
SPACE = 'SPACE',
|
|
||||||
PAGE = 'PAGE',
|
|
||||||
}
|
|
||||||
|
|
||||||
export type TreeNode = {
|
|
||||||
id: string;
|
|
||||||
title: string;
|
|
||||||
icon?: string;
|
|
||||||
children?: TreeNode[];
|
|
||||||
};
|
|
||||||
|
|
||||||
export function orderPageList(arr: string[], payload: MovePageDto): void {
|
|
||||||
const { pageId: id, after, before } = payload;
|
|
||||||
|
|
||||||
// Removing the item we are moving from the array first.
|
|
||||||
const index = arr.indexOf(id);
|
|
||||||
if (index > -1) arr.splice(index, 1);
|
|
||||||
|
|
||||||
if (after) {
|
|
||||||
const afterIndex = arr.indexOf(after);
|
|
||||||
if (afterIndex > -1) {
|
|
||||||
arr.splice(afterIndex + 1, 0, id);
|
|
||||||
} else {
|
|
||||||
// Place the item at the end if the after ID is not found.
|
|
||||||
arr.push(id);
|
|
||||||
}
|
|
||||||
} else if (before) {
|
|
||||||
const beforeIndex = arr.indexOf(before);
|
|
||||||
if (beforeIndex > -1) {
|
|
||||||
arr.splice(beforeIndex, 0, id);
|
|
||||||
} else {
|
|
||||||
// Place the item at the end if the before ID is not found.
|
|
||||||
arr.push(id);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// If neither after nor before is provided, just add the id at the end
|
|
||||||
if (!arr.includes(id)) {
|
|
||||||
arr.push(id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Remove an item from an array and update the entity
|
|
||||||
* @param entity - The entity instance (Page or Space)
|
|
||||||
* @param arrayField - The name of the field which is an array
|
|
||||||
* @param itemToRemove - The item to remove from the array
|
|
||||||
* @param manager - EntityManager instance
|
|
||||||
*/
|
|
||||||
export async function removeFromArrayAndSave(
|
|
||||||
entity: PageOrdering,
|
|
||||||
arrayField: string,
|
|
||||||
itemToRemove: any,
|
|
||||||
trx: KyselyTransaction,
|
|
||||||
) {
|
|
||||||
const array = entity[arrayField];
|
|
||||||
const index = array.indexOf(itemToRemove);
|
|
||||||
if (index > -1) {
|
|
||||||
array.splice(index, 1);
|
|
||||||
await trx
|
|
||||||
.updateTable('pageOrdering')
|
|
||||||
.set(entity)
|
|
||||||
.where('id', '=', entity.id)
|
|
||||||
.execute();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function transformPageResult(result: any[]): any[] {
|
|
||||||
return result.map((row) => {
|
|
||||||
const processedRow = {};
|
|
||||||
for (const key in row) {
|
|
||||||
//const newKey = key.split('_').slice(1).join('_');
|
|
||||||
if (key === 'childrenIds' && !row[key]) {
|
|
||||||
processedRow[key] = [];
|
|
||||||
} else {
|
|
||||||
processedRow[key] = row[key];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return processedRow;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@ -1,332 +0,0 @@
|
|||||||
import {
|
|
||||||
forwardRef,
|
|
||||||
Inject,
|
|
||||||
Injectable,
|
|
||||||
NotFoundException,
|
|
||||||
} from '@nestjs/common';
|
|
||||||
import { MovePageDto } from '../dto/move-page.dto';
|
|
||||||
import {
|
|
||||||
OrderingEntity,
|
|
||||||
orderPageList,
|
|
||||||
removeFromArrayAndSave,
|
|
||||||
TreeNode,
|
|
||||||
} from '../page.util';
|
|
||||||
import { PageService } from './page.service';
|
|
||||||
import { InjectKysely } from 'nestjs-kysely';
|
|
||||||
import { KyselyDB, KyselyTransaction } from '@docmost/db/types/kysely.types';
|
|
||||||
import { executeTx } from '@docmost/db/utils';
|
|
||||||
import { Page, PageOrdering } from '@docmost/db/types/entity.types';
|
|
||||||
import { PageWithOrderingDto } from '../dto/page-with-ordering.dto';
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class PageOrderingService {
|
|
||||||
constructor(
|
|
||||||
@Inject(forwardRef(() => PageService))
|
|
||||||
private pageService: PageService,
|
|
||||||
@InjectKysely() private readonly db: KyselyDB,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
// TODO: scope to workspace and space
|
|
||||||
async movePage(dto: MovePageDto, trx?: KyselyTransaction): Promise<void> {
|
|
||||||
await executeTx(
|
|
||||||
this.db,
|
|
||||||
async (trx) => {
|
|
||||||
const movedPageId = dto.pageId;
|
|
||||||
|
|
||||||
const movedPage = await trx
|
|
||||||
.selectFrom('pages as page')
|
|
||||||
.select(['page.id', 'page.spaceId', 'page.parentPageId'])
|
|
||||||
.where('page.id', '=', movedPageId)
|
|
||||||
.executeTakeFirst();
|
|
||||||
|
|
||||||
if (!movedPage) throw new NotFoundException('Moved page not found');
|
|
||||||
|
|
||||||
// if no parentId, it means the page is a root page or now a root page
|
|
||||||
if (!dto.parentId) {
|
|
||||||
// if it had a parent before being moved, we detach it from the previous parent
|
|
||||||
if (movedPage.parentPageId) {
|
|
||||||
await this.removeFromParent(
|
|
||||||
movedPage.parentPageId,
|
|
||||||
dto.pageId,
|
|
||||||
trx,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
const spaceOrdering = await this.getEntityOrdering(
|
|
||||||
movedPage.spaceId,
|
|
||||||
OrderingEntity.SPACE,
|
|
||||||
trx,
|
|
||||||
);
|
|
||||||
|
|
||||||
orderPageList(spaceOrdering.childrenIds, dto);
|
|
||||||
await trx
|
|
||||||
.updateTable('pageOrdering')
|
|
||||||
.set(spaceOrdering)
|
|
||||||
.where('id', '=', spaceOrdering.id)
|
|
||||||
.execute();
|
|
||||||
} else {
|
|
||||||
const parentPageId = dto.parentId;
|
|
||||||
|
|
||||||
let parentPageOrdering = await this.getEntityOrdering(
|
|
||||||
parentPageId,
|
|
||||||
OrderingEntity.PAGE,
|
|
||||||
trx,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!parentPageOrdering) {
|
|
||||||
parentPageOrdering = await this.createPageOrdering(
|
|
||||||
parentPageId,
|
|
||||||
OrderingEntity.PAGE,
|
|
||||||
movedPage.spaceId,
|
|
||||||
trx,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if the parent was changed
|
|
||||||
if (
|
|
||||||
movedPage.parentPageId &&
|
|
||||||
movedPage.parentPageId !== parentPageId
|
|
||||||
) {
|
|
||||||
//if yes, remove moved page from old parent's children
|
|
||||||
await this.removeFromParent(
|
|
||||||
movedPage.parentPageId,
|
|
||||||
dto.pageId,
|
|
||||||
trx,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// If movedPage didn't have a parent initially (was at root level), update the root level
|
|
||||||
if (!movedPage.parentPageId) {
|
|
||||||
await this.removeFromSpacePageOrder(
|
|
||||||
movedPage.spaceId,
|
|
||||||
dto.pageId,
|
|
||||||
trx,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Modify the children list of the new parentPage and save
|
|
||||||
orderPageList(parentPageOrdering.childrenIds, dto);
|
|
||||||
await trx
|
|
||||||
.updateTable('pageOrdering')
|
|
||||||
.set(parentPageOrdering)
|
|
||||||
.where('id', '=', parentPageOrdering.id)
|
|
||||||
.execute();
|
|
||||||
}
|
|
||||||
|
|
||||||
// update the parent Id of the moved page
|
|
||||||
await trx
|
|
||||||
.updateTable('pages')
|
|
||||||
.set({
|
|
||||||
parentPageId: movedPage.parentPageId || null,
|
|
||||||
})
|
|
||||||
.where('id', '=', movedPage.id)
|
|
||||||
.execute();
|
|
||||||
},
|
|
||||||
trx,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
async addPageToOrder(
|
|
||||||
spaceId: string,
|
|
||||||
pageId: string,
|
|
||||||
parentPageId?: string,
|
|
||||||
trx?: KyselyTransaction,
|
|
||||||
) {
|
|
||||||
await executeTx(
|
|
||||||
this.db,
|
|
||||||
async (trx: KyselyTransaction) => {
|
|
||||||
if (parentPageId) {
|
|
||||||
await this.upsertOrdering(
|
|
||||||
parentPageId,
|
|
||||||
OrderingEntity.PAGE,
|
|
||||||
pageId,
|
|
||||||
spaceId,
|
|
||||||
trx,
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
await this.addToSpacePageOrder(pageId, spaceId, trx);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
trx,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
async addToSpacePageOrder(
|
|
||||||
pageId: string,
|
|
||||||
spaceId: string,
|
|
||||||
trx: KyselyTransaction,
|
|
||||||
) {
|
|
||||||
await this.upsertOrdering(
|
|
||||||
spaceId,
|
|
||||||
OrderingEntity.SPACE,
|
|
||||||
pageId,
|
|
||||||
spaceId,
|
|
||||||
trx,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
async upsertOrdering(
|
|
||||||
entityId: string,
|
|
||||||
entityType: string,
|
|
||||||
childId: string,
|
|
||||||
spaceId: string,
|
|
||||||
trx: KyselyTransaction,
|
|
||||||
) {
|
|
||||||
let ordering = await this.getEntityOrdering(entityId, entityType, trx);
|
|
||||||
console.log(ordering);
|
|
||||||
console.log('oga1');
|
|
||||||
|
|
||||||
if (!ordering) {
|
|
||||||
ordering = await this.createPageOrdering(
|
|
||||||
entityId,
|
|
||||||
entityType,
|
|
||||||
spaceId,
|
|
||||||
trx,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!ordering.childrenIds.includes(childId)) {
|
|
||||||
ordering.childrenIds.unshift(childId);
|
|
||||||
console.log(childId);
|
|
||||||
console.log('childId above');
|
|
||||||
await trx
|
|
||||||
.updateTable('pageOrdering')
|
|
||||||
.set(ordering)
|
|
||||||
.where('id', '=', ordering.id)
|
|
||||||
.execute();
|
|
||||||
//await manager.save(PageOrdering, ordering);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async removeFromParent(
|
|
||||||
parentId: string,
|
|
||||||
childId: string,
|
|
||||||
trx: KyselyTransaction,
|
|
||||||
): Promise<void> {
|
|
||||||
await this.removeChildFromOrdering(
|
|
||||||
parentId,
|
|
||||||
OrderingEntity.PAGE,
|
|
||||||
childId,
|
|
||||||
trx,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
async removeFromSpacePageOrder(
|
|
||||||
spaceId: string,
|
|
||||||
pageId: string,
|
|
||||||
trx: KyselyTransaction,
|
|
||||||
) {
|
|
||||||
await this.removeChildFromOrdering(
|
|
||||||
spaceId,
|
|
||||||
OrderingEntity.SPACE,
|
|
||||||
pageId,
|
|
||||||
trx,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
async removeChildFromOrdering(
|
|
||||||
entityId: string,
|
|
||||||
entityType: string,
|
|
||||||
childId: string,
|
|
||||||
trx: KyselyTransaction,
|
|
||||||
): Promise<void> {
|
|
||||||
const ordering = await this.getEntityOrdering(entityId, entityType, trx);
|
|
||||||
|
|
||||||
if (ordering && ordering.childrenIds.includes(childId)) {
|
|
||||||
await removeFromArrayAndSave(ordering, 'childrenIds', childId, trx);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async removePageFromHierarchy(
|
|
||||||
page: Page,
|
|
||||||
trx: KyselyTransaction,
|
|
||||||
): Promise<void> {
|
|
||||||
if (page.parentPageId) {
|
|
||||||
await this.removeFromParent(page.parentPageId, page.id, trx);
|
|
||||||
} else {
|
|
||||||
await this.removeFromSpacePageOrder(page.spaceId, page.id, trx);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async getEntityOrdering(
|
|
||||||
entityId: string,
|
|
||||||
entityType: string,
|
|
||||||
trx: KyselyTransaction,
|
|
||||||
): Promise<PageOrdering> {
|
|
||||||
return trx
|
|
||||||
.selectFrom('pageOrdering')
|
|
||||||
.selectAll()
|
|
||||||
.where('entityId', '=', entityId)
|
|
||||||
.where('entityType', '=', entityType)
|
|
||||||
.forUpdate()
|
|
||||||
.executeTakeFirst();
|
|
||||||
}
|
|
||||||
|
|
||||||
async createPageOrdering(
|
|
||||||
entityId: string,
|
|
||||||
entityType: string,
|
|
||||||
spaceId: string,
|
|
||||||
trx: KyselyTransaction,
|
|
||||||
): Promise<PageOrdering> {
|
|
||||||
await trx
|
|
||||||
.insertInto('pageOrdering')
|
|
||||||
.values({
|
|
||||||
entityId,
|
|
||||||
entityType,
|
|
||||||
spaceId,
|
|
||||||
childrenIds: [],
|
|
||||||
})
|
|
||||||
.onConflict((oc) => oc.columns(['entityId', 'entityType']).doNothing())
|
|
||||||
.execute();
|
|
||||||
|
|
||||||
// Todo: maybe use returning above
|
|
||||||
return await this.getEntityOrdering(entityId, entityType, trx);
|
|
||||||
}
|
|
||||||
|
|
||||||
async getSpacePageOrder(
|
|
||||||
spaceId: string,
|
|
||||||
): Promise<{ id: string; childrenIds: string[]; spaceId: string }> {
|
|
||||||
return await this.db
|
|
||||||
.selectFrom('pageOrdering')
|
|
||||||
.select(['id', 'childrenIds', 'spaceId'])
|
|
||||||
.where('entityId', '=', spaceId)
|
|
||||||
.where('entityType', '=', OrderingEntity.SPACE)
|
|
||||||
.executeTakeFirst();
|
|
||||||
}
|
|
||||||
|
|
||||||
async convertToTree(spaceId: string): Promise<TreeNode[]> {
|
|
||||||
const spaceOrder = await this.getSpacePageOrder(spaceId);
|
|
||||||
|
|
||||||
const pageOrder = spaceOrder ? spaceOrder.childrenIds : undefined;
|
|
||||||
const pages = await this.pageService.getSidebarPagesBySpaceId(spaceId);
|
|
||||||
|
|
||||||
const pageMap: { [id: string]: PageWithOrderingDto } = {};
|
|
||||||
pages.forEach((page) => {
|
|
||||||
pageMap[page.id] = page;
|
|
||||||
});
|
|
||||||
|
|
||||||
function buildTreeNode(id: string): TreeNode | undefined {
|
|
||||||
const page = pageMap[id];
|
|
||||||
if (!page) return;
|
|
||||||
|
|
||||||
const node: TreeNode = {
|
|
||||||
id: page.id,
|
|
||||||
title: page.title || '',
|
|
||||||
children: [],
|
|
||||||
};
|
|
||||||
|
|
||||||
if (page.icon) node.icon = page.icon;
|
|
||||||
|
|
||||||
if (page.childrenIds && page.childrenIds.length > 0) {
|
|
||||||
node.children = page.childrenIds
|
|
||||||
.map((childId) => buildTreeNode(childId))
|
|
||||||
.filter(Boolean) as TreeNode[];
|
|
||||||
}
|
|
||||||
|
|
||||||
return node;
|
|
||||||
}
|
|
||||||
|
|
||||||
return pageOrder
|
|
||||||
.map((id) => buildTreeNode(id))
|
|
||||||
.filter(Boolean) as TreeNode[];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,25 +1,30 @@
|
|||||||
import {
|
import {
|
||||||
forwardRef,
|
BadRequestException,
|
||||||
Inject,
|
|
||||||
Injectable,
|
Injectable,
|
||||||
NotFoundException,
|
NotFoundException,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
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 { PageOrderingService } from './page-ordering.service';
|
|
||||||
import { PageWithOrderingDto } from '../dto/page-with-ordering.dto';
|
|
||||||
import { transformPageResult } from '../page.util';
|
|
||||||
import { PageRepo } from '@docmost/db/repos/page/page.repo';
|
import { PageRepo } from '@docmost/db/repos/page/page.repo';
|
||||||
import { Page } from '@docmost/db/types/entity.types';
|
import { Page } from '@docmost/db/types/entity.types';
|
||||||
import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
|
import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
|
||||||
import { PaginationResult } from '@docmost/db/pagination/pagination';
|
import {
|
||||||
|
executeWithPagination,
|
||||||
|
PaginationResult,
|
||||||
|
} from '@docmost/db/pagination/pagination';
|
||||||
|
import { InjectKysely } from 'nestjs-kysely';
|
||||||
|
import { KyselyDB } from '@docmost/db/types/kysely.types';
|
||||||
|
import { generateJitteredKeyBetween } from 'fractional-indexing-jittered';
|
||||||
|
import { MovePageDto } from '../dto/move-page.dto';
|
||||||
|
import { ExpressionBuilder } from 'kysely';
|
||||||
|
import { DB } from '@docmost/db/types/db';
|
||||||
|
import { SidebarPageDto } from '../dto/sidebar-page.dto';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class PageService {
|
export class PageService {
|
||||||
constructor(
|
constructor(
|
||||||
private pageRepo: PageRepo,
|
private pageRepo: PageRepo,
|
||||||
@Inject(forwardRef(() => PageOrderingService))
|
@InjectKysely() private readonly db: KyselyDB,
|
||||||
private pageOrderingService: PageOrderingService,
|
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async findById(
|
async findById(
|
||||||
@ -27,7 +32,7 @@ export class PageService {
|
|||||||
includeContent?: boolean,
|
includeContent?: boolean,
|
||||||
includeYdoc?: boolean,
|
includeYdoc?: boolean,
|
||||||
): Promise<Page> {
|
): Promise<Page> {
|
||||||
return this.pageRepo.findById(pageId, includeContent, includeYdoc);
|
return this.pageRepo.findById(pageId, { includeContent, includeYdoc });
|
||||||
}
|
}
|
||||||
|
|
||||||
async create(
|
async create(
|
||||||
@ -38,33 +43,61 @@ export class PageService {
|
|||||||
// check if parent page exists
|
// check if parent page exists
|
||||||
if (createPageDto.parentPageId) {
|
if (createPageDto.parentPageId) {
|
||||||
// TODO: make sure parent page belongs to same space and user has permissions
|
// TODO: make sure parent page belongs to same space and user has permissions
|
||||||
|
// make sure user has permission to parent.
|
||||||
const parentPage = await this.pageRepo.findById(
|
const parentPage = await this.pageRepo.findById(
|
||||||
createPageDto.parentPageId,
|
createPageDto.parentPageId,
|
||||||
);
|
);
|
||||||
if (!parentPage) throw new NotFoundException('Parent page not found');
|
if (!parentPage) throw new NotFoundException('Parent page not found');
|
||||||
}
|
}
|
||||||
|
|
||||||
let pageId = undefined;
|
let pagePosition: string;
|
||||||
if (createPageDto.pageId) {
|
|
||||||
pageId = createPageDto.pageId;
|
const lastPageQuery = this.db
|
||||||
delete createPageDto.pageId;
|
.selectFrom('pages')
|
||||||
|
.select(['id', 'position'])
|
||||||
|
.where('spaceId', '=', createPageDto.spaceId)
|
||||||
|
.orderBy('position', 'desc')
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
// todo: simplify code
|
||||||
|
if (createPageDto.parentPageId) {
|
||||||
|
// check for children of this page
|
||||||
|
const lastPage = await lastPageQuery
|
||||||
|
.where('parentPageId', '=', createPageDto.parentPageId)
|
||||||
|
.executeTakeFirst();
|
||||||
|
|
||||||
|
if (!lastPage) {
|
||||||
|
pagePosition = generateJitteredKeyBetween(null, null);
|
||||||
|
} else {
|
||||||
|
// if there is an existing page, we should get a position below it
|
||||||
|
pagePosition = generateJitteredKeyBetween(lastPage.position, null);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// for root page
|
||||||
|
const lastPage = await lastPageQuery
|
||||||
|
.where('parentPageId', 'is', null)
|
||||||
|
.executeTakeFirst();
|
||||||
|
|
||||||
|
// if no existing page, make this the first
|
||||||
|
if (!lastPage) {
|
||||||
|
pagePosition = generateJitteredKeyBetween(null, null); // we expect "a0"
|
||||||
|
} else {
|
||||||
|
// if there is an existing page, we should get a position below it
|
||||||
|
pagePosition = generateJitteredKeyBetween(lastPage.position, null);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
//TODO: should be in a transaction
|
|
||||||
const createdPage = await this.pageRepo.insertPage({
|
const createdPage = await this.pageRepo.insertPage({
|
||||||
...createPageDto,
|
title: createPageDto.title,
|
||||||
id: pageId,
|
position: pagePosition,
|
||||||
|
icon: createPageDto.icon,
|
||||||
|
parentPageId: createPageDto.parentPageId,
|
||||||
|
spaceId: createPageDto.spaceId,
|
||||||
creatorId: userId,
|
creatorId: userId,
|
||||||
workspaceId: workspaceId,
|
workspaceId: workspaceId,
|
||||||
lastUpdatedById: userId,
|
lastUpdatedById: userId,
|
||||||
});
|
});
|
||||||
|
|
||||||
await this.pageOrderingService.addPageToOrder(
|
|
||||||
createPageDto.spaceId,
|
|
||||||
pageId,
|
|
||||||
createPageDto.parentPageId,
|
|
||||||
);
|
|
||||||
|
|
||||||
return createdPage;
|
return createdPage;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -103,12 +136,90 @@ export class PageService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async getSidebarPagesBySpaceId(
|
withHasChildren(eb: ExpressionBuilder<DB, 'pages'>) {
|
||||||
spaceId: string,
|
return eb
|
||||||
limit = 200,
|
.selectFrom('pages as child')
|
||||||
): Promise<PageWithOrderingDto[]> {
|
.select((eb) =>
|
||||||
const pages = await this.pageRepo.getSpaceSidebarPages(spaceId, limit);
|
eb
|
||||||
return transformPageResult(pages);
|
.case()
|
||||||
|
.when(eb.fn.countAll(), '>', 0)
|
||||||
|
.then(true)
|
||||||
|
.else(false)
|
||||||
|
.end()
|
||||||
|
.as('count'),
|
||||||
|
)
|
||||||
|
.whereRef('child.parentPageId', '=', 'pages.id')
|
||||||
|
.limit(1)
|
||||||
|
.as('hasChildren');
|
||||||
|
}
|
||||||
|
|
||||||
|
async getSidebarPages(
|
||||||
|
dto: SidebarPageDto,
|
||||||
|
pagination: PaginationOptions,
|
||||||
|
): Promise<any> {
|
||||||
|
let query = this.db
|
||||||
|
.selectFrom('pages')
|
||||||
|
.select([
|
||||||
|
'id',
|
||||||
|
'title',
|
||||||
|
'icon',
|
||||||
|
'position',
|
||||||
|
'parentPageId',
|
||||||
|
'spaceId',
|
||||||
|
'creatorId',
|
||||||
|
])
|
||||||
|
.select((eb) => this.withHasChildren(eb))
|
||||||
|
.orderBy('position', 'asc')
|
||||||
|
.where('spaceId', '=', dto.spaceId);
|
||||||
|
|
||||||
|
if (dto.pageId) {
|
||||||
|
query = query.where('parentPageId', '=', dto.pageId);
|
||||||
|
} else {
|
||||||
|
query = query.where('parentPageId', 'is', null);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = executeWithPagination(query, {
|
||||||
|
page: pagination.page,
|
||||||
|
perPage: 250,
|
||||||
|
});
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
async movePage(dto: MovePageDto) {
|
||||||
|
// validate position value by attempting to generate a key
|
||||||
|
try {
|
||||||
|
generateJitteredKeyBetween(dto.position, null);
|
||||||
|
} catch (err) {
|
||||||
|
throw new BadRequestException('Invalid move position');
|
||||||
|
}
|
||||||
|
|
||||||
|
const movedPage = await this.pageRepo.findById(dto.pageId);
|
||||||
|
if (!movedPage) throw new NotFoundException('Moved page not found');
|
||||||
|
|
||||||
|
let parentPageId: string;
|
||||||
|
if (movedPage.parentPageId === dto.parentPageId) {
|
||||||
|
parentPageId = undefined;
|
||||||
|
} else {
|
||||||
|
// changing the page's parent
|
||||||
|
if (dto.parentPageId) {
|
||||||
|
const parentPage = await this.pageRepo.findById(dto.parentPageId);
|
||||||
|
if (!parentPage) throw new NotFoundException('Parent page not found');
|
||||||
|
}
|
||||||
|
parentPageId = dto.parentPageId;
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.pageRepo.updatePage(
|
||||||
|
{
|
||||||
|
position: dto.position,
|
||||||
|
parentPageId: parentPageId,
|
||||||
|
},
|
||||||
|
dto.pageId,
|
||||||
|
);
|
||||||
|
|
||||||
|
// TODO
|
||||||
|
// check for duplicates?
|
||||||
|
// permissions
|
||||||
}
|
}
|
||||||
|
|
||||||
async getRecentSpacePages(
|
async getRecentSpacePages(
|
||||||
@ -126,8 +237,8 @@ export class PageService {
|
|||||||
async forceDelete(pageId: string): Promise<void> {
|
async forceDelete(pageId: string): Promise<void> {
|
||||||
await this.pageRepo.deletePage(pageId);
|
await this.pageRepo.deletePage(pageId);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
/*
|
/*
|
||||||
// TODO: page deletion and restoration
|
// TODO: page deletion and restoration
|
||||||
async delete(pageId: string): Promise<void> {
|
async delete(pageId: string): Promise<void> {
|
||||||
await this.dataSource.transaction(async (manager: EntityManager) => {
|
await this.dataSource.transaction(async (manager: EntityManager) => {
|
||||||
@ -217,4 +328,3 @@ export class PageService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
*/
|
*/
|
||||||
}
|
|
||||||
|
|||||||
@ -12,7 +12,6 @@ import { SpaceMemberRepo } from '@docmost/db/repos/space/space-member.repo';
|
|||||||
import { PageRepo } from './repos/page/page.repo';
|
import { PageRepo } from './repos/page/page.repo';
|
||||||
import { CommentRepo } from './repos/comment/comment.repo';
|
import { CommentRepo } from './repos/comment/comment.repo';
|
||||||
import { PageHistoryRepo } from './repos/page/page-history.repo';
|
import { PageHistoryRepo } from './repos/page/page-history.repo';
|
||||||
import { PageOrderingRepo } from './repos/page/page-ordering.repo';
|
|
||||||
import { AttachmentRepo } from './repos/attachment/attachment.repo';
|
import { AttachmentRepo } from './repos/attachment/attachment.repo';
|
||||||
|
|
||||||
// https://github.com/brianc/node-postgres/issues/811
|
// https://github.com/brianc/node-postgres/issues/811
|
||||||
@ -35,9 +34,9 @@ types.setTypeParser(types.builtins.INT8, (val) => Number(val));
|
|||||||
if (environmentService.getEnv() !== 'development') return;
|
if (environmentService.getEnv() !== 'development') return;
|
||||||
if (event.level === 'query') {
|
if (event.level === 'query') {
|
||||||
console.log(event.query.sql);
|
console.log(event.query.sql);
|
||||||
if (event.query.parameters.length > 0) {
|
//if (event.query.parameters.length > 0) {
|
||||||
console.log('parameters: ' + event.query.parameters);
|
//console.log('parameters: ' + event.query.parameters);
|
||||||
}
|
//}
|
||||||
console.log('time: ' + event.queryDurationMillis);
|
console.log('time: ' + event.queryDurationMillis);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@ -53,7 +52,6 @@ types.setTypeParser(types.builtins.INT8, (val) => Number(val));
|
|||||||
SpaceMemberRepo,
|
SpaceMemberRepo,
|
||||||
PageRepo,
|
PageRepo,
|
||||||
PageHistoryRepo,
|
PageHistoryRepo,
|
||||||
PageOrderingRepo,
|
|
||||||
CommentRepo,
|
CommentRepo,
|
||||||
AttachmentRepo,
|
AttachmentRepo,
|
||||||
],
|
],
|
||||||
@ -66,7 +64,6 @@ types.setTypeParser(types.builtins.INT8, (val) => Number(val));
|
|||||||
SpaceMemberRepo,
|
SpaceMemberRepo,
|
||||||
PageRepo,
|
PageRepo,
|
||||||
PageHistoryRepo,
|
PageHistoryRepo,
|
||||||
PageOrderingRepo,
|
|
||||||
CommentRepo,
|
CommentRepo,
|
||||||
AttachmentRepo,
|
AttachmentRepo,
|
||||||
],
|
],
|
||||||
|
|||||||
@ -0,0 +1,12 @@
|
|||||||
|
import { type Kysely } from 'kysely';
|
||||||
|
|
||||||
|
export async function up(db: Kysely<any>): Promise<void> {
|
||||||
|
await db.schema
|
||||||
|
.alterTable('pages')
|
||||||
|
.addColumn('position', 'varchar', (col) => col)
|
||||||
|
.execute();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function down(db: Kysely<any>): Promise<void> {
|
||||||
|
await db.schema.alterTable('pages').dropColumn('position').execute();
|
||||||
|
}
|
||||||
@ -23,13 +23,4 @@ export class PaginationOptions {
|
|||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
query: string;
|
query: string;
|
||||||
|
|
||||||
get offset(): number {
|
|
||||||
return (this.page - 1) * this.limit;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export enum PaginationSort {
|
|
||||||
ASC = 'asc',
|
|
||||||
DESC = 'desc',
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -20,6 +20,10 @@ export async function executeWithPagination<O, DB, TB extends keyof DB>(
|
|||||||
experimental_deferredJoinPrimaryKey?: StringReference<DB, TB>;
|
experimental_deferredJoinPrimaryKey?: StringReference<DB, TB>;
|
||||||
},
|
},
|
||||||
): Promise<PaginationResult<O>> {
|
): Promise<PaginationResult<O>> {
|
||||||
|
if (opts.page < 1) {
|
||||||
|
opts.page = 1;
|
||||||
|
}
|
||||||
|
console.log('perpage', opts.perPage);
|
||||||
qb = qb.limit(opts.perPage + 1).offset((opts.page - 1) * opts.perPage);
|
qb = qb.limit(opts.perPage + 1).offset((opts.page - 1) * opts.perPage);
|
||||||
|
|
||||||
const deferredJoinPrimaryKey = opts.experimental_deferredJoinPrimaryKey;
|
const deferredJoinPrimaryKey = opts.experimental_deferredJoinPrimaryKey;
|
||||||
|
|||||||
@ -72,7 +72,7 @@ export class PageHistoryRepo {
|
|||||||
.orderBy('createdAt', 'desc');
|
.orderBy('createdAt', 'desc');
|
||||||
|
|
||||||
const result = executeWithPagination(query, {
|
const result = executeWithPagination(query, {
|
||||||
page: pagination.offset,
|
page: pagination.page,
|
||||||
perPage: pagination.limit,
|
perPage: pagination.limit,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -1,8 +0,0 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
|
||||||
import { InjectKysely } from 'nestjs-kysely';
|
|
||||||
import { KyselyDB } from '../../types/kysely.types';
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class PageOrderingRepo {
|
|
||||||
constructor(@InjectKysely() private readonly db: KyselyDB) {}
|
|
||||||
}
|
|
||||||
@ -23,6 +23,7 @@ export class PageRepo {
|
|||||||
'icon',
|
'icon',
|
||||||
'coverPhoto',
|
'coverPhoto',
|
||||||
'key',
|
'key',
|
||||||
|
'position',
|
||||||
'parentPageId',
|
'parentPageId',
|
||||||
'creatorId',
|
'creatorId',
|
||||||
'lastUpdatedById',
|
'lastUpdatedById',
|
||||||
@ -38,15 +39,17 @@ export class PageRepo {
|
|||||||
|
|
||||||
async findById(
|
async findById(
|
||||||
pageId: string,
|
pageId: string,
|
||||||
withJsonContent?: boolean,
|
opts?: {
|
||||||
withYdoc?: boolean,
|
includeContent?: boolean;
|
||||||
|
includeYdoc?: boolean;
|
||||||
|
},
|
||||||
): Promise<Page> {
|
): Promise<Page> {
|
||||||
return await this.db
|
return await this.db
|
||||||
.selectFrom('pages')
|
.selectFrom('pages')
|
||||||
.select(this.baseFields)
|
.select(this.baseFields)
|
||||||
.where('id', '=', pageId)
|
.where('id', '=', pageId)
|
||||||
.$if(withJsonContent, (qb) => qb.select('content'))
|
.$if(opts?.includeContent, (qb) => qb.select('content'))
|
||||||
.$if(withYdoc, (qb) => qb.select('ydoc'))
|
.$if(opts?.includeYdoc, (qb) => qb.select('ydoc'))
|
||||||
.executeTakeFirst();
|
.executeTakeFirst();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -79,7 +82,7 @@ export class PageRepo {
|
|||||||
return db
|
return db
|
||||||
.insertInto('pages')
|
.insertInto('pages')
|
||||||
.values(insertablePage)
|
.values(insertablePage)
|
||||||
.returningAll()
|
.returning(this.baseFields)
|
||||||
.executeTakeFirst();
|
.executeTakeFirst();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -101,26 +104,4 @@ export class PageRepo {
|
|||||||
|
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
async getSpaceSidebarPages(spaceId: string, limit: number) {
|
|
||||||
const pages = await this.db
|
|
||||||
.selectFrom('pages as page')
|
|
||||||
.leftJoin('pageOrdering as ordering', 'ordering.entityId', 'page.id')
|
|
||||||
.where('page.spaceId', '=', spaceId)
|
|
||||||
.select([
|
|
||||||
'page.id',
|
|
||||||
'page.title',
|
|
||||||
'page.icon',
|
|
||||||
'page.parentPageId',
|
|
||||||
'page.spaceId',
|
|
||||||
'ordering.childrenIds',
|
|
||||||
'page.creatorId',
|
|
||||||
'page.createdAt',
|
|
||||||
])
|
|
||||||
.orderBy('page.updatedAt', 'desc')
|
|
||||||
.limit(limit)
|
|
||||||
.execute();
|
|
||||||
|
|
||||||
return pages;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
14
apps/server/src/kysely/types/db.d.ts
vendored
14
apps/server/src/kysely/types/db.d.ts
vendored
@ -91,18 +91,6 @@ export interface PageHistory {
|
|||||||
workspaceId: string;
|
workspaceId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PageOrdering {
|
|
||||||
childrenIds: string[];
|
|
||||||
createdAt: Generated<Timestamp>;
|
|
||||||
deletedAt: Timestamp | null;
|
|
||||||
entityId: string;
|
|
||||||
entityType: string;
|
|
||||||
id: Generated<string>;
|
|
||||||
spaceId: string;
|
|
||||||
updatedAt: Generated<Timestamp>;
|
|
||||||
workspaceId: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Pages {
|
export interface Pages {
|
||||||
content: Json | null;
|
content: Json | null;
|
||||||
coverPhoto: string | null;
|
coverPhoto: string | null;
|
||||||
@ -118,6 +106,7 @@ export interface Pages {
|
|||||||
key: string | null;
|
key: string | null;
|
||||||
lastUpdatedById: string | null;
|
lastUpdatedById: string | null;
|
||||||
parentPageId: string | null;
|
parentPageId: string | null;
|
||||||
|
position: string | null;
|
||||||
publishedAt: Timestamp | null;
|
publishedAt: Timestamp | null;
|
||||||
slug: string | null;
|
slug: string | null;
|
||||||
spaceId: string;
|
spaceId: string;
|
||||||
@ -209,7 +198,6 @@ export interface DB {
|
|||||||
groups: Groups;
|
groups: Groups;
|
||||||
groupUsers: GroupUsers;
|
groupUsers: GroupUsers;
|
||||||
pageHistory: PageHistory;
|
pageHistory: PageHistory;
|
||||||
pageOrdering: PageOrdering;
|
|
||||||
pages: Pages;
|
pages: Pages;
|
||||||
spaceMembers: SpaceMembers;
|
spaceMembers: SpaceMembers;
|
||||||
spaces: Spaces;
|
spaces: Spaces;
|
||||||
|
|||||||
@ -8,7 +8,6 @@ import {
|
|||||||
Users,
|
Users,
|
||||||
Workspaces,
|
Workspaces,
|
||||||
PageHistory as History,
|
PageHistory as History,
|
||||||
PageOrdering as Ordering,
|
|
||||||
GroupUsers,
|
GroupUsers,
|
||||||
SpaceMembers,
|
SpaceMembers,
|
||||||
WorkspaceInvitations,
|
WorkspaceInvitations,
|
||||||
@ -63,11 +62,6 @@ export type PageHistory = Selectable<History>;
|
|||||||
export type InsertablePageHistory = Insertable<History>;
|
export type InsertablePageHistory = Insertable<History>;
|
||||||
export type UpdatablePageHistory = Updateable<Omit<History, 'id'>>;
|
export type UpdatablePageHistory = Updateable<Omit<History, 'id'>>;
|
||||||
|
|
||||||
// PageOrdering
|
|
||||||
export type PageOrdering = Selectable<Ordering>;
|
|
||||||
export type InsertablePageOrdering = Insertable<Ordering>;
|
|
||||||
export type UpdatablePageOrdering = Updateable<Omit<Ordering, 'id'>>;
|
|
||||||
|
|
||||||
// Comment
|
// Comment
|
||||||
export type Comment = Selectable<Comments>;
|
export type Comment = Selectable<Comments>;
|
||||||
export type InsertableComment = Insertable<Comments>;
|
export type InsertableComment = Insertable<Comments>;
|
||||||
|
|||||||
@ -36,6 +36,7 @@
|
|||||||
"@tiptap/react": "^2.2.4",
|
"@tiptap/react": "^2.2.4",
|
||||||
"@tiptap/starter-kit": "^2.2.4",
|
"@tiptap/starter-kit": "^2.2.4",
|
||||||
"@tiptap/suggestion": "^2.2.4",
|
"@tiptap/suggestion": "^2.2.4",
|
||||||
|
"fractional-indexing-jittered": "^0.9.1",
|
||||||
"y-indexeddb": "^9.0.12",
|
"y-indexeddb": "^9.0.12",
|
||||||
"yjs": "^13.6.14"
|
"yjs": "^13.6.14"
|
||||||
},
|
},
|
||||||
|
|||||||
7
pnpm-lock.yaml
generated
7
pnpm-lock.yaml
generated
@ -98,6 +98,9 @@ importers:
|
|||||||
'@tiptap/suggestion':
|
'@tiptap/suggestion':
|
||||||
specifier: ^2.2.4
|
specifier: ^2.2.4
|
||||||
version: 2.2.4(@tiptap/core@2.2.4)(@tiptap/pm@2.2.4)
|
version: 2.2.4(@tiptap/core@2.2.4)(@tiptap/pm@2.2.4)
|
||||||
|
fractional-indexing-jittered:
|
||||||
|
specifier: ^0.9.1
|
||||||
|
version: 0.9.1
|
||||||
y-indexeddb:
|
y-indexeddb:
|
||||||
specifier: ^9.0.12
|
specifier: ^9.0.12
|
||||||
version: 9.0.12(yjs@13.6.14)
|
version: 9.0.12(yjs@13.6.14)
|
||||||
@ -7605,6 +7608,10 @@ packages:
|
|||||||
engines: {node: '>= 0.6'}
|
engines: {node: '>= 0.6'}
|
||||||
dev: false
|
dev: false
|
||||||
|
|
||||||
|
/fractional-indexing-jittered@0.9.1:
|
||||||
|
resolution: {integrity: sha512-qyzDZ7JXWf/yZT2rQDpQwFBbIaZS2o+zb0s740vqreXQ6bFQPd8tAy4D1gGN0CUeIcnNHjuvb0EaLnqHhGV/PA==}
|
||||||
|
dev: false
|
||||||
|
|
||||||
/fs-constants@1.0.0:
|
/fs-constants@1.0.0:
|
||||||
resolution: {integrity: sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==}
|
resolution: {integrity: sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==}
|
||||||
dev: true
|
dev: true
|
||||||
|
|||||||
Reference in New Issue
Block a user