diff --git a/apps/client/src/features/editor/title-editor.tsx b/apps/client/src/features/editor/title-editor.tsx index e5ac7da6..869cb1eb 100644 --- a/apps/client/src/features/editor/title-editor.tsx +++ b/apps/client/src/features/editor/title-editor.tsx @@ -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; diff --git a/apps/client/src/features/page/queries/page-query.ts b/apps/client/src/features/page/queries/page-query.ts index 6842cc9e..6a460c68 100644 --- a/apps/client/src/features/page/queries/page-query.ts +++ b/apps/client/src/features/page/queries/page-query.ts @@ -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>({ 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({ mutationFn: (data) => movePage(data), + onSuccess: () => { + invalidateOnMovePage(); + }, }); } -export function useGetSidebarPagesQuery( - data: SidebarPagesParams, -): UseQueryResult, Error> { - return useQuery({ +export function useGetSidebarPagesQuery(data: SidebarPagesParams|null): UseInfiniteQueryResult, 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) { + const newPage: Partial = { + 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>>>(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>>(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>>(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>>(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>>(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"], + }); +} \ No newline at end of file diff --git a/apps/client/src/features/page/services/page-service.ts b/apps/client/src/features/page/services/page-service.ts index f058750f..a8e3d256 100644 --- a/apps/client/src/features/page/services/page-service.ts +++ b/apps/client/src/features/page/services/page-service.ts @@ -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"; import { IFileTask } from '@/features/file-task/types/file-task.types.ts'; export async function createPage(data: Partial): Promise { @@ -53,6 +54,32 @@ export async function getSidebarPages( return req.data; } +export async function getAllSidebarPages( + params: SidebarPagesParams, +): Promise, unknown>> { + let page = 1; + let hasNextPage = false; + const pages: IPagination[] = []; + const pageParams: number[] = []; + + do { + const req = await api.post("/pages/sidebar-pages", { ...params, page: page }); + + const data: IPagination = 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> { diff --git a/apps/client/src/features/page/tree/atoms/tree-data-atom.ts b/apps/client/src/features/page/tree/atoms/tree-data-atom.ts index e3910cb8..7d0ec503 100644 --- a/apps/client/src/features/page/tree/atoms/tree-data-atom.ts +++ b/apps/client/src/features/page/tree/atoms/tree-data-atom.ts @@ -1,4 +1,19 @@ import { atom } from "jotai"; import { SpaceTreeNode } from "@/features/page/tree/types"; +import { appendNodeChildren } from "../utils"; export const treeDataAtom = atom([]); + +// 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); + } +); diff --git a/apps/client/src/features/page/tree/components/space-tree.tsx b/apps/client/src/features/page/tree/components/space-tree.tsx index 1df62678..da5b1832 100644 --- a/apps/client/src/features/page/tree/components/space-tree.tsx +++ b/apps/client/src/features/page/tree/components/space-tree.tsx @@ -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) { 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) { async function handleLoadChildren(node: NodeApi) { 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) { 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) { 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 ( (spaceId: string) { return data; }; - const onMove: MoveHandler = (args: { + const onMove: MoveHandler = async (args: { dragIds: string[]; dragNodes: NodeApi[]; parentId: string | null; @@ -176,7 +176,7 @@ export function useTreeMutation(spaceId: string) { }; try { - movePageMutation.mutateAsync(payload); + await movePageMutation.mutateAsync(payload); setTimeout(() => { emit({ @@ -206,6 +206,23 @@ export function useTreeMutation(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 = async (args: { ids: string[] }) => { try { await deletePageMutation.mutateAsync(args.ids[0]); @@ -218,8 +235,7 @@ export function useTreeMutation(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)); } diff --git a/apps/client/src/features/page/tree/utils/utils.ts b/apps/client/src/features/page/tree/utils/utils.ts index 7ae84e38..910799c8 100644 --- a/apps/client/src/features/page/tree/utils/utils.ts +++ b/apps/client/src/features/page/tree/utils/utils.ts @@ -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; }); } diff --git a/apps/client/src/features/websocket/types/types.ts b/apps/client/src/features/websocket/types/types.ts index 48e7d819..bda76b4e 100644 --- a/apps/client/src/features/websocket/types/types.ts +++ b/apps/client/src/features/websocket/types/types.ts @@ -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; id: string; - payload: Partial; + payload: Partial; }; export type DeleteEvent = { @@ -25,7 +26,7 @@ export type DeleteEvent = { spaceId: string; entity: Array; id: string; - payload?: Partial; + payload?: Partial; }; export type AddTreeNodeEvent = { diff --git a/apps/client/src/features/websocket/use-query-subscription.ts b/apps/client/src/features/websocket/use-query-subscription.ts index c9e53aa6..5ca0db44 100644 --- a/apps/client/src/features/websocket/use-query-subscription.ts +++ b/apps/client/src/features/websocket/use-query-subscription.ts @@ -1,8 +1,16 @@ 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 +35,15 @@ export const useQuerySubscription = () => { queryKey: RQ_KEY(data.pageId), }); break; + case "addTreeNode": + invalidateOnCreatePage(data.payload.data); + break; + case "moveTreeNode": + invalidateOnMovePage(); + break; + case "deleteTreeNode": + invalidateOnDeletePage(data.payload.node.id); + break; case "updateOne": entity = data.entity[0]; if (entity === "pages") { @@ -37,13 +54,23 @@ export const useQuerySubscription = () => { } // only update if data was already in cache - if(queryClient.getQueryData([...data.entity, queryKeyId])){ + if (queryClient.getQueryData([...data.entity, queryKeyId])) { queryClient.setQueryData([...data.entity, queryKeyId], { ...queryClient.getQueryData([...data.entity, queryKeyId]), ...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] },