fix: sidebar list when changing workspace (#1150)

* init

* navigate in overview if current page is in deleted node

* fix: implement pagination in sidebar-pages queries

* fix: appendNodeChildren()

Preserve deeper children if they exist and remove node if deleted
This commit is contained in:
fuscodev
2025-06-08 04:27:09 +02:00
committed by GitHub
parent 69447fc375
commit ce1503af85
9 changed files with 326 additions and 59 deletions

View File

@ -103,7 +103,7 @@ export function TitleEditor({
spaceId: page.spaceId,
entity: ["pages"],
id: page.id,
payload: { title: page.title, slugId: page.slugId },
payload: { title: page.title, slugId: page.slugId, parentPageId: page.parentPageId, icon: page.icon },
};
if (page.title !== titleEditor.getText()) return;

View File

@ -1,5 +1,8 @@
import {
InfiniteData,
QueryKey,
useInfiniteQuery,
UseInfiniteQueryResult,
useMutation,
useQuery,
useQueryClient,
@ -14,6 +17,7 @@ import {
movePage,
getPageBreadcrumbs,
getRecentChanges,
getAllSidebarPages,
} from "@/features/page/services/page-service";
import {
IMovePage,
@ -56,7 +60,9 @@ export function useCreatePageMutation() {
const { t } = useTranslation();
return useMutation<IPage, Error, Partial<IPageInput>>({
mutationFn: (data) => createPage(data),
onSuccess: (data) => {},
onSuccess: (data) => {
invalidateOnCreatePage(data);
},
onError: (error) => {
notifications.show({ message: t("Failed to create page"), color: "red" });
},
@ -80,6 +86,8 @@ export function updatePageData(data: IPage) {
if (pageById) {
queryClient.setQueryData(["pages", data.id], { ...pageById, ...data });
}
invalidateOnUpdatePage(data.spaceId, data.parentPageId, data.id, data.title, data.icon);
}
export function useUpdateTitlePageMutation() {
@ -93,6 +101,8 @@ export function useUpdatePageMutation() {
mutationFn: (data) => updatePage(data),
onSuccess: (data) => {
updatePage(data);
invalidateOnUpdatePage(data.spaceId, data.parentPageId, data.id, data.title, data.icon);
},
});
}
@ -101,8 +111,9 @@ export function useDeletePageMutation() {
const { t } = useTranslation();
return useMutation({
mutationFn: (pageId: string) => deletePage(pageId),
onSuccess: () => {
onSuccess: (data, pageId) => {
notifications.show({ message: t("Page deleted successfully") });
invalidateOnDeletePage(pageId);
},
onError: (error) => {
notifications.show({ message: t("Failed to delete page"), color: "red" });
@ -113,15 +124,21 @@ export function useDeletePageMutation() {
export function useMovePageMutation() {
return useMutation<void, Error, IMovePage>({
mutationFn: (data) => movePage(data),
onSuccess: () => {
invalidateOnMovePage();
},
});
}
export function useGetSidebarPagesQuery(
data: SidebarPagesParams,
): UseQueryResult<IPagination<IPage>, Error> {
return useQuery({
export function useGetSidebarPagesQuery(data: SidebarPagesParams|null): UseInfiniteQueryResult<InfiniteData<IPagination<IPage>, unknown>> {
return useInfiniteQuery({
queryKey: ["sidebar-pages", data],
queryFn: () => getSidebarPages(data),
queryFn: ({ pageParam }) => getSidebarPages({ ...data, page: pageParam }),
initialPageParam: 1,
getPreviousPageParam: (firstPage) =>
firstPage.meta.hasPrevPage ? firstPage.meta.page - 1 : undefined,
getNextPageParam: (lastPage) =>
lastPage.meta.hasNextPage ? lastPage.meta.page + 1 : undefined,
});
}
@ -149,14 +166,16 @@ export function usePageBreadcrumbsQuery(
});
}
export async function fetchAncestorChildren(params: SidebarPagesParams) {
export async function fetchAllAncestorChildren(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),
queryFn: () => getAllSidebarPages(params),
staleTime: 30 * 60 * 1000,
});
return buildTree(response.items);
const allItems = response.pages.flatMap((page) => page.items);
return buildTree(allItems);
}
export function useRecentChangesQuery(
@ -168,3 +187,157 @@ export function useRecentChangesQuery(
refetchOnMount: true,
});
}
export function invalidateOnCreatePage(data: Partial<IPage>) {
const newPage: Partial<IPage> = {
creatorId: data.creatorId,
hasChildren: data.hasChildren,
icon: data.icon,
id: data.id,
parentPageId: data.parentPageId,
position: data.position,
slugId: data.slugId,
spaceId: data.spaceId,
title: data.title,
};
let queryKey: QueryKey = null;
if (data.parentPageId===null) {
queryKey = ['root-sidebar-pages', data.spaceId];
}else{
queryKey = ['sidebar-pages', {pageId: data.parentPageId, spaceId: data.spaceId}]
}
//update all sidebar pages
queryClient.setQueryData<InfiniteData<IPagination<Partial<IPage>>>>(queryKey, (old) => {
if (!old) return old;
return {
...old,
pages: old.pages.map((page,index) => {
if (index === old.pages.length - 1) {
return {
...page,
items: [...page.items, newPage],
};
}
return page;
}),
};
});
//update sidebar haschildren
if (data.parentPageId!==null){
//update sub sidebar pages haschildern
const subSideBarMatches = queryClient.getQueriesData({
queryKey: ['sidebar-pages'],
exact: false,
});
subSideBarMatches.forEach(([key, d]) => {
queryClient.setQueryData<InfiniteData<IPagination<IPage>>>(key, (old) => {
if (!old) return old;
return {
...old,
pages: old.pages.map((page) => ({
...page,
items: page.items.map((sidebarPage: IPage) =>
sidebarPage.id === data.parentPageId ? { ...sidebarPage, hasChildren: true } : sidebarPage
)
})),
};
});
});
//update root sidebar pages haschildern
const rootSideBarMatches = queryClient.getQueriesData({
queryKey: ['root-sidebar-pages', data.spaceId],
exact: false,
});
rootSideBarMatches.forEach(([key, d]) => {
queryClient.setQueryData<InfiniteData<IPagination<IPage>>>(key, (old) => {
if (!old) return old;
return {
...old,
pages: old.pages.map((page) => ({
...page,
items: page.items.map((sidebarPage: IPage) =>
sidebarPage.id === data.parentPageId ? { ...sidebarPage, hasChildren: true } : sidebarPage
)
})),
};
});
});
}
//update recent changes
queryClient.invalidateQueries({
queryKey: ["recent-changes", data.spaceId],
});
}
export function invalidateOnUpdatePage(spaceId: string, parentPageId: string, id: string, title: string, icon: string) {
let queryKey: QueryKey = null;
if(parentPageId===null){
queryKey = ['root-sidebar-pages', spaceId];
}else{
queryKey = ['sidebar-pages', {pageId: parentPageId, spaceId: spaceId}]
}
//update all sidebar pages
queryClient.setQueryData<InfiniteData<IPagination<IPage>>>(queryKey, (old) => {
if (!old) return old;
return {
...old,
pages: old.pages.map((page) => ({
...page,
items: page.items.map((sidebarPage: IPage) =>
sidebarPage.id === id ? { ...sidebarPage, title: title, icon: icon } : sidebarPage
)
})),
};
});
//update recent changes
queryClient.invalidateQueries({
queryKey: ["recent-changes", spaceId],
});
}
export function invalidateOnMovePage() {
//for move invalidate all sidebars for now (how to do???)
//invalidate all root sidebar pages
queryClient.invalidateQueries({
queryKey: ["root-sidebar-pages"],
});
//invalidate all sub sidebar pages
queryClient.invalidateQueries({
queryKey: ['sidebar-pages'],
});
// ---
}
export function invalidateOnDeletePage(pageId: string) {
//update all sidebar pages
const allSideBarMatches = queryClient.getQueriesData({
predicate: (query) =>
query.queryKey[0] === 'root-sidebar-pages' || query.queryKey[0] === 'sidebar-pages',
});
allSideBarMatches.forEach(([key, d]) => {
queryClient.setQueryData<InfiniteData<IPagination<IPage>>>(key, (old) => {
if (!old) return old;
return {
...old,
pages: old.pages.map((page) => ({
...page,
items: page.items.filter((sidebarPage: IPage) => sidebarPage.id !== pageId),
})),
};
});
});
//update recent changes
queryClient.invalidateQueries({
queryKey: ["recent-changes"],
});
}

View File

@ -10,6 +10,7 @@ import {
} from "@/features/page/types/page.types";
import { IAttachment, IPagination } from "@/lib/types.ts";
import { saveAs } from "file-saver";
import { InfiniteData } from "@tanstack/react-query";
export async function createPage(data: Partial<IPage>): Promise<IPage> {
const req = await api.post<IPage>("/pages/create", data);
@ -52,6 +53,32 @@ export async function getSidebarPages(
return req.data;
}
export async function getAllSidebarPages(
params: SidebarPagesParams,
): Promise<InfiniteData<IPagination<IPage>, unknown>> {
let page = 1;
let hasNextPage = false;
const pages: IPagination<IPage>[] = [];
const pageParams: number[] = [];
do {
const req = await api.post("/pages/sidebar-pages", { ...params, page: page });
const data: IPagination<IPage> = req.data;
pages.push(data);
pageParams.push(page);
hasNextPage = data.meta.hasNextPage;
page += 1;
} while (hasNextPage);
return {
pageParams,
pages,
};
}
export async function getPageBreadcrumbs(
pageId: string,
): Promise<Partial<IPage[]>> {

View File

@ -1,4 +1,19 @@
import { atom } from "jotai";
import { SpaceTreeNode } from "@/features/page/tree/types";
import { appendNodeChildren } from "../utils";
export const treeDataAtom = atom<SpaceTreeNode[]>([]);
// Atom
export const appendNodeChildrenAtom = atom(
null,
(
get,
set,
{ parentId, children }: { parentId: string; children: SpaceTreeNode[] }
) => {
const currentTree = get(treeDataAtom);
const updatedTree = appendNodeChildren(currentTree, parentId, children);
set(treeDataAtom, updatedTree);
}
);

View File

@ -2,7 +2,7 @@ import { NodeApi, NodeRendererProps, Tree, TreeApi } from "react-arborist";
import { atom, useAtom } from "jotai";
import { treeApiAtom } from "@/features/page/tree/atoms/tree-api-atom.ts";
import {
fetchAncestorChildren,
fetchAllAncestorChildren,
useGetRootSidebarPagesQuery,
usePageQuery,
useUpdatePageMutation,
@ -24,7 +24,7 @@ import {
IconPointFilled,
IconTrash,
} from "@tabler/icons-react";
import { treeDataAtom } from "@/features/page/tree/atoms/tree-data-atom.ts";
import { appendNodeChildrenAtom, treeDataAtom } from "@/features/page/tree/atoms/tree-data-atom.ts";
import clsx from "clsx";
import EmojiPicker from "@/components/ui/emoji-picker.tsx";
import { useTreeMutation } from "@/features/page/tree/hooks/use-tree-mutation.ts";
@ -140,7 +140,7 @@ export default function SpaceTree({ spaceId, readOnly }: SpaceTreeProps) {
if (ancestor.id === currentPage.id) {
return;
}
const children = await fetchAncestorChildren({
const children = await fetchAllAncestorChildren({
pageId: ancestor.id,
spaceId: ancestor.spaceId,
});
@ -237,6 +237,7 @@ function Node({ node, style, dragHandle, tree }: NodeRendererProps<any>) {
const { t } = useTranslation();
const updatePageMutation = useUpdatePageMutation();
const [treeData, setTreeData] = useAtom(treeDataAtom);
const [, appendChildren] = useAtom(appendNodeChildrenAtom);
const emit = useQueryEmit();
const { spaceSlug } = useParams();
const timerRef = useRef(null);
@ -262,9 +263,10 @@ function Node({ node, style, dragHandle, tree }: NodeRendererProps<any>) {
async function handleLoadChildren(node: NodeApi<SpaceTreeNode>) {
if (!node.data.hasChildren) return;
if (node.data.children && node.data.children.length > 0) {
return;
}
// in conflict with use-query-subscription.ts => case "addTreeNode","moveTreeNode" etc with websocket
// if (node.data.children && node.data.children.length > 0) {
// return;
// }
try {
const params: SidebarPagesParams = {
@ -272,21 +274,12 @@ function Node({ node, style, dragHandle, tree }: NodeRendererProps<any>) {
spaceId: node.data.spaceId,
};
const newChildren = await queryClient.fetchQuery({
queryKey: ["sidebar-pages", params],
queryFn: () => getSidebarPages(params),
staleTime: 10 * 60 * 1000,
const childrenTree = await fetchAllAncestorChildren(params);
appendChildren({
parentId: node.data.id,
children: childrenTree,
});
const childrenTree = buildTree(newChildren.items);
const updatedTreeData = appendNodeChildren(
treeData,
node.data.id,
childrenTree,
);
setTreeData(updatedTreeData);
} catch (error) {
console.error("Failed to fetch children:", error);
}
@ -304,17 +297,17 @@ function Node({ node, style, dragHandle, tree }: NodeRendererProps<any>) {
const handleEmojiSelect = (emoji: { native: string }) => {
handleUpdateNodeIcon(node.id, emoji.native);
updatePageMutation.mutateAsync({ pageId: node.id, icon: emoji.native });
setTimeout(() => {
emit({
operation: "updateOne",
spaceId: node.data.spaceId,
entity: ["pages"],
id: node.id,
payload: { icon: emoji.native },
});
}, 50);
updatePageMutation.mutateAsync({ pageId: node.id, icon: emoji.native }).then((data) => {
setTimeout(() => {
emit({
operation: "updateOne",
spaceId: node.data.spaceId,
entity: ["pages"],
id: node.id,
payload: { icon: emoji.native, parentPageId: data.parentPageId},
});
}, 50);
});
};
const handleRemoveEmoji = () => {
@ -576,6 +569,12 @@ interface PageArrowProps {
}
function PageArrow({ node, onExpandTree }: PageArrowProps) {
useEffect(() => {
if(node.isOpen){
onExpandTree();
}
}, []);
return (
<ActionIcon
size={20}

View File

@ -93,7 +93,7 @@ export function useTreeMutation<T>(spaceId: string) {
return data;
};
const onMove: MoveHandler<T> = (args: {
const onMove: MoveHandler<T> = async (args: {
dragIds: string[];
dragNodes: NodeApi<T>[];
parentId: string | null;
@ -176,7 +176,7 @@ export function useTreeMutation<T>(spaceId: string) {
};
try {
movePageMutation.mutateAsync(payload);
await movePageMutation.mutateAsync(payload);
setTimeout(() => {
emit({
@ -206,6 +206,23 @@ export function useTreeMutation<T>(spaceId: string) {
}
};
const isPageInNode = (
node: { data: SpaceTreeNode; children?: any[] },
pageSlug: string
): boolean => {
if (node.data.slugId === pageSlug) {
return true;
}
for (const item of node.children) {
if (item.data.slugId === pageSlug) {
return true;
} else {
return isPageInNode(item, pageSlug);
}
}
return false;
};
const onDelete: DeleteHandler<T> = async (args: { ids: string[] }) => {
try {
await deletePageMutation.mutateAsync(args.ids[0]);
@ -218,8 +235,7 @@ export function useTreeMutation<T>(spaceId: string) {
tree.drop({ id: args.ids[0] });
setData(tree.data);
// navigate only if the current url is same as the deleted page
if (pageSlug && node.data.slugId === pageSlug.split("-")[1]) {
if (pageSlug && isPageInNode(node, pageSlug.split("-")[1])) {
navigate(getSpaceUrl(spaceSlug));
}

View File

@ -164,16 +164,35 @@ export function appendNodeChildren(
nodeId: string,
children: SpaceTreeNode[],
) {
return treeItems.map((nodeItem) => {
if (nodeItem.id === nodeId) {
return { ...nodeItem, children };
}
if (nodeItem.children) {
// Preserve deeper children if they exist and remove node if deleted
return treeItems.map((node) => {
if (node.id === nodeId) {
const newIds = new Set(children.map(c => c.id));
const existingMap = new Map(
(node.children ?? []).filter(c => newIds.has(c.id)).map(c => [c.id, c])
);
const merged = children.map((newChild) => {
const existing = existingMap.get(newChild.id);
return existing && existing.children
? { ...newChild, children: existing.children }
: newChild;
});
return {
...nodeItem,
children: appendNodeChildren(nodeItem.children, nodeId, children),
...node,
children: merged,
};
}
return nodeItem;
if (node.children) {
return {
...node,
children: appendNodeChildren(node.children, nodeId, children),
};
}
return node;
});
}

View File

@ -1,4 +1,5 @@
import { SpaceTreeNode } from "@/features/page/tree/types.ts";
import { IPage } from "@/features/page/types/page.types";
export type InvalidateEvent = {
operation: "invalidate";
@ -17,7 +18,7 @@ export type UpdateEvent = {
spaceId: string;
entity: Array<string>;
id: string;
payload: Partial<any>;
payload: Partial<IPage>;
};
export type DeleteEvent = {
@ -25,7 +26,7 @@ export type DeleteEvent = {
spaceId: string;
entity: Array<string>;
id: string;
payload?: Partial<any>;
payload?: Partial<IPage>;
};
export type AddTreeNodeEvent = {

View File

@ -1,8 +1,11 @@
import React from "react";
import { socketAtom } from "@/features/websocket/atoms/socket-atom.ts";
import { useAtom } from "jotai";
import { useQueryClient } from "@tanstack/react-query";
import { InfiniteData, useQueryClient } from "@tanstack/react-query";
import { WebSocketEvent } from "@/features/websocket/types";
import { IPage } from "../page/types/page.types";
import { IPagination } from "@/lib/types";
import { invalidateOnCreatePage, invalidateOnDeletePage, invalidateOnMovePage, invalidateOnUpdatePage } from "../page/queries/page-query";
import { RQ_KEY } from "../comment/queries/comment-query";
export const useQuerySubscription = () => {
@ -27,6 +30,16 @@ export const useQuerySubscription = () => {
queryKey: RQ_KEY(data.pageId),
});
break;
case "addTreeNode":
invalidateOnCreatePage(data.payload.data);
break;
case "moveTreeNode":
invalidateOnMovePage();
break;
case "deleteTreeNode":
const pageId = data.payload.node.id;
invalidateOnDeletePage(pageId);
break;
case "updateOne":
entity = data.entity[0];
if (entity === "pages") {
@ -43,7 +56,11 @@ export const useQuerySubscription = () => {
...data.payload,
});
}
if (entity === "pages") {
invalidateOnUpdatePage(data.spaceId, data.payload.parentPageId, data.id, data.payload.title, data.payload.icon);
}
/*
queryClient.setQueriesData(
{ queryKey: [data.entity, data.id] },
@ -55,7 +72,7 @@ export const useQuerySubscription = () => {
: update(oldData as Record<string, unknown>);
},
);
*/
*/
break;
}
});