Updates to sidebar tree

* Maintain tree open state on route change and return
* Load page tree ancestors and their children when a page is accessed directly
* Show correct breadcrumb path
* Add emoji to breadcrumbs
* Backend api to get page breadcrumbs/ancestors
This commit is contained in:
Philipinho
2024-04-21 16:38:59 +01:00
parent 3e2c13a22e
commit 3462c7fdbc
13 changed files with 348 additions and 116 deletions

View File

@ -15,6 +15,13 @@ import { Link, useParams } from "react-router-dom";
import classes from "./breadcrumb.module.css";
import { SpaceTreeNode } from "@/features/page/tree/types.ts";
function getTitle(name: string, icon: string) {
if (icon) {
return `${icon} ${name}`;
}
return name;
}
export default function Breadcrumb() {
const treeData = useAtomValue(treeDataAtom);
const [breadcrumbNodes, setBreadcrumbNodes] = useState<
@ -48,7 +55,7 @@ export default function Breadcrumb() {
variant="default"
style={{ border: "none" }}
>
<Text truncate="end">{node.name}</Text>
<Text truncate="end">{getTitle(node.name, node.icon)}</Text>
</Button>
</Button.Group>
));
@ -56,6 +63,8 @@ export default function Breadcrumb() {
const getLastNthNode = (n: number) =>
breadcrumbNodes && breadcrumbNodes[breadcrumbNodes.length - n];
// const getTitle = (title: string) => (title?.length > 0 ? title : "untitled");
const getBreadcrumbItems = () => {
if (breadcrumbNodes?.length > 3) {
return [
@ -65,7 +74,7 @@ export default function Breadcrumb() {
underline="never"
key={breadcrumbNodes[0].id}
>
{breadcrumbNodes[0].name}
{getTitle(breadcrumbNodes[0].name, breadcrumbNodes[0].icon)}
</Anchor>,
<Popover
width={250}
@ -89,7 +98,7 @@ export default function Breadcrumb() {
underline="never"
key={getLastNthNode(2)?.id}
>
{getLastNthNode(2)?.name}
{getTitle(getLastNthNode(2)?.name, getLastNthNode(2)?.icon)}
</Anchor>,
<Anchor
component={Link}
@ -97,7 +106,7 @@ export default function Breadcrumb() {
underline="never"
key={getLastNthNode(1)?.id}
>
{getLastNthNode(1)?.name}
{getTitle(getLastNthNode(1)?.name, getLastNthNode(1)?.icon)}
</Anchor>,
];
}
@ -110,7 +119,7 @@ export default function Breadcrumb() {
underline="never"
key={node.id}
>
{node.name}
{getTitle(node.name, node.icon)}
</Anchor>
));
}

View File

@ -1,26 +1,31 @@
.header,
.footer {
@media (max-width: 992px) {
[data-layout='alt'] & {
[data-layout="alt"] & {
--_section-right: var(--app-shell-aside-offset, 0px);
}
}
}
.aside {
@media (min-width: 993px) {
background: var(--mantine-color-gray-light);
[data-layout='alt'] & {
[data-layout="alt"] & {
--_section-top: var(--_section-top, var(--app-shell-header-offset, 0px));
--_section-height: var(
--_section-height,
calc(100dvh - var(--app-shell-header-offset, 0px) - var(--app-shell-footer-offset, 0px))
calc(
100dvh - var(--app-shell-header-offset, 0px) -
var(--app-shell-footer-offset, 0px)
)
);
}
}
}
@media (max-width: 48em) {
.navbar {
width: 300px;
}
}

View File

@ -15,7 +15,8 @@ import { useMatchPath } from "@/hooks/use-match-path.tsx";
import React from "react";
export default function Shell({ children }: { children: React.ReactNode }) {
const [mobileOpened, { toggle: toggleMobile }] = useDisclosure();
const [mobileOpened, { toggle: toggleMobile, close: closeMobile }] =
useDisclosure();
const [desktopOpened] = useAtom(desktopSidebarAtom);
const toggleDesktop = useToggleSidebar(desktopSidebarAtom);
const matchPath = useMatchPath();
@ -38,7 +39,7 @@ export default function Shell({ children }: { children: React.ReactNode }) {
}}
padding="md"
>
<AppShell.Header className={classes.header}>
<AppShell.Header className={classes.header} withBorder={false}>
<Group justify="space-between" h="100%" px="md" wrap="nowrap">
<Group
h="100%"
@ -71,7 +72,7 @@ export default function Shell({ children }: { children: React.ReactNode }) {
</Group>
</AppShell.Header>
<AppShell.Navbar>
<AppShell.Navbar className={classes.navbar} withBorder={false}>
<Navbar />
</AppShell.Navbar>

View File

@ -18,7 +18,7 @@ import { updateTreeNodeName } from "@/features/page/tree/utils";
export interface TitleEditorProps {
pageId: string;
title: any;
title: string;
}
export function TitleEditor({ pageId, title }: TitleEditorProps) {

View File

@ -2,6 +2,7 @@ import {
useInfiniteQuery,
useMutation,
useQuery,
useQueryClient,
UseQueryResult,
} from "@tanstack/react-query";
import {
@ -12,6 +13,7 @@ import {
getRecentChanges,
updatePage,
movePage,
getPageBreadcrumbs,
} from "@/features/page/services/page-service";
import {
IMovePage,
@ -20,6 +22,8 @@ import {
} from "@/features/page/types/page.types";
import { notifications } from "@mantine/notifications";
import { IPagination } from "@/lib/types.ts";
import { queryClient } from "@/main.tsx";
import { buildTree } from "@/features/page/tree/utils";
const RECENT_CHANGES_KEY = ["recentChanges"];
@ -51,9 +55,13 @@ export function useCreatePageMutation() {
}
export function useUpdatePageMutation() {
const queryClient = useQueryClient();
return useMutation<IPage, Error, Partial<IPage>>({
mutationFn: (data) => updatePage(data),
onSuccess: (data) => {},
onSuccess: (data) => {
// update page in cache
queryClient.setQueryData(["pages", data.id], data);
},
});
}
@ -97,3 +105,23 @@ export function useGetRootSidebarPagesQuery(data: SidebarPagesParams) {
lastPage.meta.hasNextPage ? lastPage.meta.page + 1 : undefined,
});
}
export function usePageBreadcrumbsQuery(
pageId: string,
): UseQueryResult<Partial<IPage[]>, Error> {
return useQuery({
queryKey: ["breadcrumbs", pageId],
queryFn: () => getPageBreadcrumbs(pageId),
enabled: !!pageId,
});
}
export async function fetchAncestorChildren(params: SidebarPagesParams) {
// not using a hook here, so we can call it inside a useEffect hook
const response = await queryClient.fetchQuery({
queryKey: ["sidebar-pages", params],
queryFn: () => getSidebarPages(params),
staleTime: 30 * 60 * 1000,
});
return buildTree(response.items);
}

View File

@ -40,3 +40,10 @@ export async function getSidebarPages(
const req = await api.post("/pages/sidebar-pages", params);
return req.data;
}
export async function getPageBreadcrumbs(
pageId: string,
): Promise<Partial<IPage[]>> {
const req = await api.post("/pages/breadcrumbs", { pageId });
return req.data;
}

View File

@ -1,28 +0,0 @@
import React, { ReactElement } from 'react';
import { useElementSize } from '@mantine/hooks';
import { useMergedRef } from '@mantine/hooks';
type Props = {
children: (dimens: { width: number; height: number }) => ReactElement;
};
const style = {
flex: 1,
width: '100%',
height: '100%',
minHeight: 0,
minWidth: 0,
};
export const FillFlexParent = React.forwardRef(function FillFlexParent(
props: Props,
forwardRef
) {
const { ref, width, height } = useElementSize();
const mergedRef = useMergedRef(ref, forwardRef);
return (
<div style={style} ref={mergedRef}>
{width && height ? props.children({ width, height }) : null}
</div>
);
});

View File

@ -1,14 +1,14 @@
import { NodeApi, NodeRendererProps, Tree, TreeApi } from "react-arborist";
import { useAtom } from "jotai";
import { atom, useAtom } from "jotai";
import { treeApiAtom } from "@/features/page/tree/atoms/tree-api-atom.ts";
import {
fetchAncestorChildren,
useGetRootSidebarPagesQuery,
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 {
IconChevronDown,
@ -26,22 +26,32 @@ import clsx from "clsx";
import EmojiPicker from "@/components/ui/emoji-picker.tsx";
import { useTreeMutation } from "@/features/page/tree/hooks/use-tree-mutation.ts";
import {
appendNodeChildren,
buildTree,
buildTreeWithChildren,
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 { SidebarPagesParams } from "@/features/page/types/page.types.ts";
import {
getPageBreadcrumbs,
getSidebarPages,
} from "@/features/page/services/page-service.ts";
import { IPage, SidebarPagesParams } from "@/features/page/types/page.types.ts";
import { queryClient } from "@/main.tsx";
import { OpenMap } from "react-arborist/dist/main/state/open-slice";
import { useElementSize, useMergedRef } from "@mantine/hooks";
import { dfs } from "react-arborist/dist/module/utils";
interface SpaceTreeProps {
spaceId: string;
}
const openTreeNodesAtom = atom<OpenMap>({});
export default function SpaceTree({ spaceId }: SpaceTreeProps) {
const { pageId } = useParams();
const { data, setData, controllers } =
useTreeMutation<TreeApi<SpaceTreeNode>>(spaceId);
const [treeAPi, setTreeApi] = useAtom<TreeApi<SpaceTreeNode>>(treeApiAtom);
const {
data: pagesData,
hasNextPage,
@ -50,8 +60,13 @@ export default function SpaceTree({ spaceId }: SpaceTreeProps) {
} = useGetRootSidebarPagesQuery({
spaceId,
});
const [, setTreeApi] = useAtom<TreeApi<SpaceTreeNode>>(treeApiAtom);
const treeApiRef = useRef<TreeApi<SpaceTreeNode>>();
const [openTreeNodes, setOpenTreeNodes] = useAtom<OpenMap>(openTreeNodesAtom);
const rootElement = useRef<HTMLDivElement>();
const { pageId } = useParams();
const { ref: sizeRef, width, height } = useElementSize();
const mergedRef = useMergedRef(rootElement, sizeRef);
const isDataLoaded = useRef(false);
useEffect(() => {
if (hasNextPage && !isFetching) {
@ -63,67 +78,134 @@ export default function SpaceTree({ spaceId }: SpaceTreeProps) {
if (pagesData?.pages && !hasNextPage) {
const allItems = pagesData.pages.flatMap((page) => page.items);
const treeData = buildTree(allItems);
if (data.length < 1) {
//Thoughts
// don't reset if there is data in state
// we only expect to call this once on initial load
// even if we decide to refetch, it should only update
// and append root pages instead of resetting the entire tree
// which looses async loaded children too
setData(treeData);
isDataLoaded.current = true;
}
}
}, [pagesData, hasNextPage]);
useEffect(() => {
const fetchData = async () => {
if (isDataLoaded.current) {
// check if pageId node is present in the tree
const node = dfs(treeApiRef.current.root, pageId);
if (node) {
// if node is found, no need to traverse its ancestors
return;
}
// if not found, fetch and build its ancestors and their children
if (!pageId) return;
const ancestors = await getPageBreadcrumbs(pageId);
if (ancestors && ancestors?.length > 1) {
let flatTreeItems = [...buildTree(ancestors)];
const fetchAndUpdateChildren = async (ancestor: IPage) => {
// we don't want to fetch the children of the opened page
if (ancestor.id === pageId) {
return;
}
const children = await fetchAncestorChildren({
pageId: ancestor.id,
spaceId: ancestor.spaceId,
});
flatTreeItems = [
...flatTreeItems,
...children.filter(
(child) => !flatTreeItems.some((item) => item.id === child.id),
),
];
};
const fetchPromises = ancestors.map((ancestor) =>
fetchAndUpdateChildren(ancestor),
);
// Wait for all fetch operations to complete
Promise.all(fetchPromises).then(() => {
// build tree with children
const ancestorsTree = buildTreeWithChildren(flatTreeItems);
// child of root page we're attaching the built ancestors to
const rootChild = ancestorsTree[0];
// attach built ancestors to tree
const updatedTree = appendNodeChildren(
data,
rootChild.id,
rootChild.children,
);
setData(updatedTree);
setTimeout(() => {
treeAPi?.select(pageId, { align: "auto" });
// focus on node and open all parents
treeApiRef.current.select(pageId);
}, 100);
});
}
}
};
fetchData();
}, [isDataLoaded.current, pageId]);
useEffect(() => {
setTimeout(() => {
treeApiRef.current?.select(pageId, { align: "auto" });
}, 200);
}, [treeAPi, pageId]);
}, [pageId]);
useEffect(() => {
if (treeApiRef.current) {
// @ts-ignore
setTreeApi(treeApiRef.current);
}
}, []);
useEffect(() => {
if (openTreeNodes) {
treeApiRef.current.state.nodes.open.unfiltered = openTreeNodes;
}
}, []);
return (
<div ref={rootElement} className={classes.treeContainer}>
<FillFlexParent>
{(dimens) => (
<div ref={mergedRef} className={classes.treeContainer}>
<Tree
data={data}
{...controllers}
{...dimens}
// @ts-ignore
ref={(t) => setTreeApi(t)}
width={width}
height={height}
ref={treeApiRef}
openByDefault={false}
disableMultiSelection={true}
className={classes.tree}
rowClassName={classes.row}
rowHeight={30}
overscanCount={8}
overscanCount={10}
dndRootElement={rootElement.current}
selectionFollowsFocus
onToggle={() => {
setOpenTreeNodes(treeApiRef.current.openState);
}}
>
{Node}
</Tree>
)}
</FillFlexParent>
</div>
);
}
function Node({ node, style, dragHandle }: NodeRendererProps<any>) {
function Node({ node, style, dragHandle, tree }: NodeRendererProps<any>) {
const navigate = useNavigate();
const updatePageMutation = useUpdatePageMutation();
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) {
@ -139,11 +221,12 @@ function Node({ node, style, dragHandle }: NodeRendererProps<any>) {
const newChildren = await queryClient.fetchQuery({
queryKey: ["sidebar-pages", params],
queryFn: () => getSidebarPages(params),
staleTime: 30 * 60 * 1000,
});
const childrenTree = buildTree(newChildren.items);
const updatedTreeData = updateTreeData(
const updatedTreeData = appendNodeChildren(
treeData,
node.data.id,
childrenTree,
@ -160,11 +243,11 @@ function Node({ node, style, dragHandle }: NodeRendererProps<any>) {
};
const handleUpdateNodeIcon = (nodeId: string, newIcon: string) => {
const updatedTree = updateTreeNodeIcon(treeData, node.id, newIcon);
const updatedTree = updateTreeNodeIcon(treeData, nodeId, newIcon);
setTreeData(updatedTree);
};
const handleEmojiIconClick = (e) => {
const handleEmojiIconClick = (e: any) => {
e.preventDefault();
e.stopPropagation();
};
@ -213,9 +296,10 @@ function Node({ node, style, dragHandle }: NodeRendererProps<any>) {
<span className={classes.text}>{node.data.name || "untitled"}</span>
<div className={classes.actions}>
<NodeMenu node={node} />
<NodeMenu node={node} treeApi={tree} />
<CreateNode
node={node}
treeApi={tree}
onExpandTree={() => handleLoadChildren(node)}
/>
</div>
@ -226,11 +310,10 @@ function Node({ node, style, dragHandle }: NodeRendererProps<any>) {
interface CreateNodeProps {
node: NodeApi<SpaceTreeNode>;
treeApi: TreeApi<SpaceTreeNode>;
onExpandTree?: () => void;
}
function CreateNode({ node, onExpandTree }: CreateNodeProps) {
const [treeApi] = useAtom(treeApiAtom);
function CreateNode({ node, treeApi, onExpandTree }: CreateNodeProps) {
function handleCreate() {
if (node.data.hasChildren && node.children.length === 0) {
node.toggle();
@ -259,9 +342,11 @@ function CreateNode({ node, onExpandTree }: CreateNodeProps) {
);
}
function NodeMenu({ node }: { node: NodeApi<SpaceTreeNode> }) {
const [treeApi] = useAtom(treeApiAtom);
interface NodeMenuProps {
node: NodeApi<SpaceTreeNode>;
treeApi: TreeApi<SpaceTreeNode>;
}
function NodeMenu({ node, treeApi }: NodeMenuProps) {
return (
<Menu shadow="md" width={200}>
<Menu.Target>
@ -283,6 +368,10 @@ function NodeMenu({ node }: { node: NodeApi<SpaceTreeNode> }) {
<Menu.Dropdown>
<Menu.Item
leftSection={<IconStar style={{ width: rem(14), height: rem(14) }} />}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
}}
>
Favorite
</Menu.Item>
@ -291,6 +380,10 @@ function NodeMenu({ node }: { node: NodeApi<SpaceTreeNode> }) {
<Menu.Item
leftSection={<IconLink style={{ width: rem(14), height: rem(14) }} />}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
}}
>
Copy link
</Menu.Item>

View File

@ -15,7 +15,7 @@
display: flex;
align-items: center;
height: 100%;
width: 100%;
width: 93%; /* not to overlap with scroll bar */
color: light-dark(var(--mantine-color-gray-7), var(--mantine-color-dark-0));

View File

@ -5,6 +5,7 @@ export type SpaceTreeNode = {
position: string;
slug?: string;
spaceId: string;
parentPageId: string;
hasChildren: boolean;
children: SpaceTreeNode[];
};

View File

@ -22,6 +22,7 @@ export function buildTree(pages: IPage[]): SpaceTreeNode[] {
position: page.position,
hasChildren: page.hasChildren,
spaceId: page.spaceId,
parentPageId: page.parentPageId,
children: [],
};
});
@ -97,3 +98,59 @@ export const updateTreeNodeIcon = (
return node;
});
};
export function buildTreeWithChildren(items: SpaceTreeNode[]): SpaceTreeNode[] {
const nodeMap = {};
let result: SpaceTreeNode[] = [];
// Create a reference object for each item with the specified structure
items.forEach((item) => {
nodeMap[item.id] = { ...item, children: [] };
});
// Build the tree array
items.forEach((item) => {
const node = nodeMap[item.id];
if (item.parentPageId !== null) {
// Find the parent node and add the current node to its children
nodeMap[item.parentPageId].children.push(node);
} else {
// If the item has no parent, it's a root node, so add it to the result array
result.push(node);
}
});
result = sortPositionKeys(result);
// Recursively sort the children of each node
function sortChildren(node: SpaceTreeNode) {
if (node.children.length > 0) {
node.hasChildren = true;
node.children = sortPositionKeys(node.children);
node.children.forEach(sortChildren);
}
}
result.forEach(sortChildren);
return result;
}
export function appendNodeChildren(
treeItems: SpaceTreeNode[],
nodeId: string,
children: SpaceTreeNode[],
) {
return treeItems.map((nodeItem) => {
if (nodeItem.id === nodeId) {
return { ...nodeItem, children };
}
if (nodeItem.children) {
return {
...nodeItem,
children: appendNodeChildren(nodeItem.children, nodeId, children),
};
}
return nodeItem;
});
}

View File

@ -104,4 +104,10 @@ export class PageController {
async movePage(@Body() movePageDto: MovePageDto) {
return this.pageService.movePage(movePageDto);
}
@HttpCode(HttpStatus.OK)
@Post('/breadcrumbs')
async getPageBreadcrumbs(@Body() dto: PageIdDto) {
return this.pageService.getPageBreadCrumbs(dto.pageId);
}
}

View File

@ -222,6 +222,59 @@ export class PageService {
// permissions
}
async getPageBreadCrumbs(childPageId: string) {
const ancestors = await this.db
.withRecursive('page_ancestors', (db) =>
db
.selectFrom('pages')
.select([
'id',
'title',
'icon',
'position',
'parentPageId',
'spaceId',
])
.select((eb) => this.withHasChildren(eb))
.where('id', '=', childPageId)
.unionAll((exp) =>
exp
.selectFrom('pages as p')
.select([
'p.id',
'p.title',
'p.icon',
'p.position',
'p.parentPageId',
'p.spaceId',
])
.select(
exp
.selectFrom('pages as child')
.select((eb) =>
eb
.case()
.when(eb.fn.countAll(), '>', 0)
.then(true)
.else(false)
.end()
.as('count'),
)
.whereRef('child.parentPageId', '=', 'id')
.limit(1)
.as('hasChildren'),
)
//.select((eb) => this.withHasChildren(eb))
.innerJoin('page_ancestors as pa', 'pa.parentPageId', 'p.id'),
),
)
.selectFrom('page_ancestors')
.selectAll()
.execute();
return ancestors.reverse();
}
async getRecentSpacePages(
spaceId: string,
pagination: PaginationOptions,