From a4954afba1ee4e746f18cf9cc037584e2ca612bb Mon Sep 17 00:00:00 2001 From: Nathan Panchout Date: Thu, 8 Jan 2026 14:39:26 +0100 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8(frontend)=20enhance=20API=20and=20dri?= =?UTF-8?q?ver=20functionality?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Updated the fetchAPI to accept boolean parameters, added new enums for item filters, and expanded the Driver class with methods for managing favorite items and link configurations. Introduced utility functions for managing paginated lists and improved item handling in the explorer component. Removed unused constants and optimized query handling for better performance. --- .../apps/drive/src/features/api/fetchApi.ts | 2 +- .../src/features/drivers/DTOs/AccessesDTO.ts | 29 +- .../apps/drive/src/features/drivers/Driver.ts | 37 ++- .../drivers/implementations/StandardDriver.ts | 108 ++++++- .../apps/drive/src/features/drivers/types.ts | 24 ++ .../src/features/explorer/api/useMoveItem.tsx | 40 +-- .../components/ExplorerDndProvider.tsx | 64 +++- .../components/tree/hooks/useAddTreeNode.tsx | 44 --- .../tree/hooks/useDeleteTreeNode.tsx | 64 ---- .../drive/src/features/explorer/constants.ts | 4 - .../features/explorer/hooks/useDeleteItem.tsx | 10 +- .../explorer/hooks/useInfiniteItems.ts | 70 +++-- .../features/explorer/hooks/useMutations.ts | 297 ++++++------------ .../explorer/hooks/useMutationsAccesses.ts | 87 +++++ .../explorer/hooks/useOptimisticPagination.ts | 235 ++++++++++++++ .../features/explorer/hooks/useQueries.tsx | 49 ++- .../explorer/hooks/useRefreshItems.ts | 165 ++++++++++ 17 files changed, 915 insertions(+), 414 deletions(-) delete mode 100644 src/frontend/apps/drive/src/features/explorer/components/tree/hooks/useAddTreeNode.tsx delete mode 100644 src/frontend/apps/drive/src/features/explorer/components/tree/hooks/useDeleteTreeNode.tsx delete mode 100644 src/frontend/apps/drive/src/features/explorer/constants.ts create mode 100644 src/frontend/apps/drive/src/features/explorer/hooks/useMutationsAccesses.ts create mode 100644 src/frontend/apps/drive/src/features/explorer/hooks/useOptimisticPagination.ts create mode 100644 src/frontend/apps/drive/src/features/explorer/hooks/useRefreshItems.ts diff --git a/src/frontend/apps/drive/src/features/api/fetchApi.ts b/src/frontend/apps/drive/src/features/api/fetchApi.ts index 3629e621..8e2f948d 100644 --- a/src/frontend/apps/drive/src/features/api/fetchApi.ts +++ b/src/frontend/apps/drive/src/features/api/fetchApi.ts @@ -33,7 +33,7 @@ export interface fetchAPIOptions { export const fetchAPI = async ( input: string, - init?: RequestInit & { params?: Record }, + init?: RequestInit & { params?: Record }, options?: fetchAPIOptions ) => { const apiUrl = new URL(`${baseApiUrl("1.0")}${input}`); diff --git a/src/frontend/apps/drive/src/features/drivers/DTOs/AccessesDTO.ts b/src/frontend/apps/drive/src/features/drivers/DTOs/AccessesDTO.ts index bf78d834..97790a72 100644 --- a/src/frontend/apps/drive/src/features/drivers/DTOs/AccessesDTO.ts +++ b/src/frontend/apps/drive/src/features/drivers/DTOs/AccessesDTO.ts @@ -1,20 +1,25 @@ -import { Role } from "../types"; +import { LinkReach, LinkRole, Role } from "../types"; export type DTOCreateAccess = { - itemId: string; - userId: string; - role: Role; + itemId: string; + userId: string; + role: Role; }; - export type DTOUpdateAccess = { - itemId: string; - accessId: string; - user_id: string; - role: Role; + itemId: string; + accessId: string; + user_id: string; + role: Role; }; export type DTODeleteAccess = { - itemId: string; - accessId: string; -} \ No newline at end of file + itemId: string; + accessId: string; +}; + +export type DTOUpdateLinkConfiguration = { + itemId: string; + link_reach: LinkReach; + link_role?: LinkRole | null; +}; diff --git a/src/frontend/apps/drive/src/features/drivers/Driver.ts b/src/frontend/apps/drive/src/features/drivers/Driver.ts index 06d52040..7e580de1 100644 --- a/src/frontend/apps/drive/src/features/drivers/Driver.ts +++ b/src/frontend/apps/drive/src/features/drivers/Driver.ts @@ -2,6 +2,7 @@ import { DTOCreateAccess, DTODeleteAccess, DTOUpdateAccess, + DTOUpdateLinkConfiguration, } from "./DTOs/AccessesDTO"; import { DTOCreateInvitation, @@ -27,6 +28,17 @@ export enum ItemFiltersScope { NOT_DELETED = "not_deleted", } +export enum ItemFiltersOrdering { + CREATED_AT_ASC = "created_at", + CREATED_AT_DESC = "-created_at", + UPDATED_AT_ASC = "updated_at", + UPDATED_AT_DESC = "-updated_at", + TITLE_ASC = "title", + TITLE_DESC = "-title", + TYPE_ASC = "type", + TYPE_DESC = "-type", +} + export type ItemFilters = { type?: ItemType; title?: string; @@ -35,6 +47,9 @@ export type ItemFilters = { page?: number; page_size?: number; workspaces?: WorkspaceType; + is_creator_me?: boolean; + ordering?: string; + is_favorite?: boolean; }; export type PaginatedChildrenResult = { @@ -68,8 +83,8 @@ export abstract class Driver { abstract getItemBreadcrumb(id: string): Promise; abstract updateItem(item: Partial): Promise; abstract restoreItems(ids: string[]): Promise; - abstract moveItem(id: string, parentId: string): Promise; - abstract moveItems(ids: string[], parentId: string): Promise; + abstract moveItem(id: string, parentId?: string): Promise; + abstract moveItems(ids: string[], parentId?: string): Promise; abstract getChildren( id: string, filters?: ItemFilters @@ -77,9 +92,21 @@ export abstract class Driver { abstract searchItems(filters?: ItemFilters): Promise; // Accesses - abstract getItemAccesses(itemId: string): Promise>; + + abstract getRecentItems( + filters?: ItemFilters + ): Promise; + abstract getFavoriteItems( + filters?: ItemFilters + ): Promise; + abstract createFavoriteItem(itemId: string): Promise; + abstract deleteFavoriteItem(itemId: string): Promise; + abstract getItemAccesses(itemId: string): Promise; abstract createAccess(data: DTOCreateAccess): Promise; - abstract updateAccess(payload: DTOUpdateAccess): Promise; + abstract updateAccess(payload: DTOUpdateAccess): Promise; + abstract updateLinkConfiguration( + payload: DTOUpdateLinkConfiguration + ): Promise; abstract deleteAccess(payload: DTODeleteAccess): Promise; // Invitations abstract getItemInvitations(itemId: string): Promise>; @@ -100,7 +127,7 @@ export abstract class Driver { abstract updateWorkspace(item: Partial): Promise; abstract deleteWorkspace(id: string): Promise; abstract createFile(data: { - parentId: string; + parentId?: string; filename: string; }): Promise; abstract deleteItems(ids: string[]): Promise; diff --git a/src/frontend/apps/drive/src/features/drivers/implementations/StandardDriver.ts b/src/frontend/apps/drive/src/features/drivers/implementations/StandardDriver.ts index 763914b5..42e60542 100644 --- a/src/frontend/apps/drive/src/features/drivers/implementations/StandardDriver.ts +++ b/src/frontend/apps/drive/src/features/drivers/implementations/StandardDriver.ts @@ -1,11 +1,20 @@ import { fetchAPI } from "@/features/api/fetchApi"; -import { Driver, Entitlements, ItemFilters, UserFilters, PaginatedChildrenResult } from "../Driver"; +import { + Driver, + Entitlements, + ItemFilters, + UserFilters, + PaginatedChildrenResult, +} from "../Driver"; import { DTODeleteInvitation, DTOCreateInvitation, DTOUpdateInvitation, } from "../DTOs/InvitationDTO"; -import { DTOCreateAccess } from "../DTOs/AccessesDTO"; +import { + DTOCreateAccess, + DTOUpdateLinkConfiguration, +} from "../DTOs/AccessesDTO"; import { DTOUpdateAccess } from "../DTOs/AccessesDTO"; import { Access, @@ -112,7 +121,7 @@ export class StandardDriver extends Driver { async getChildren( id: string, - filters?: ItemFilters + filters?: ItemFilters, ): Promise { const params = { page: 1, @@ -142,14 +151,17 @@ export class StandardDriver extends Driver { return jsonToItem(data); } - async moveItem(id: string, parentId: string): Promise { + async moveItem(id: string, parentId?: string): Promise { + const payload = { + ...(parentId ? { target_item_id: parentId } : {}), + }; await fetchAPI(`items/${id}/move/`, { method: "POST", - body: JSON.stringify({ target_item_id: parentId }), + body: JSON.stringify(payload), }); } - async getItemAccesses(itemId: string): Promise> { + async getItemAccesses(itemId: string): Promise { const response = await fetchAPI(`items/${itemId}/accesses/`); const data = await response.json(); return data; @@ -171,15 +183,30 @@ export class StandardDriver extends Driver { }); } + async updateLinkConfiguration( + payload: DTOUpdateLinkConfiguration, + ): Promise { + const { itemId, ...rest } = payload; + await fetchAPI(`items/${itemId}/link-configuration/`, { + method: "PUT", + body: JSON.stringify(rest), + }); + } + async updateAccess({ itemId, accessId, ...payload - }: DTOUpdateAccess): Promise { + }: DTOUpdateAccess): Promise { const response = await fetchAPI(`items/${itemId}/accesses/${accessId}/`, { method: "PATCH", body: JSON.stringify(payload), }); + + if (response.status === 204) { + return; + } + const data = await response.json(); return data; } @@ -201,7 +228,7 @@ export class StandardDriver extends Driver { `items/${payload.itemId}/invitations/${payload.invitationId}/`, { method: "DELETE", - } + }, ); } @@ -211,7 +238,7 @@ export class StandardDriver extends Driver { { method: "PATCH", body: JSON.stringify(payload), - } + }, ); const data = await response.json(); return data; @@ -223,7 +250,7 @@ export class StandardDriver extends Driver { return data; } - async moveItems(ids: string[], parentId: string): Promise { + async moveItems(ids: string[], parentId?: string): Promise { for (const id of ids) { await this.moveItem(id, parentId); } @@ -234,7 +261,8 @@ export class StandardDriver extends Driver { parentId?: string; }): Promise { const { parentId, ...rest } = data; - const response = await fetchAPI(`items/${parentId}/children/`, { + const url = parentId ? `items/${parentId}/children/` : `items/`; + const response = await fetchAPI(url, { method: "POST", body: JSON.stringify({ ...rest, @@ -268,15 +296,63 @@ export class StandardDriver extends Driver { return this.deleteItems([id]); } + async getRecentItems( + filters?: ItemFilters, + ): Promise { + const response = await fetchAPI(`items/recents/`, { + params: { ...filters, page_size: 200 }, + }); + const data = await response.json(); + return { + children: jsonToItems(data.results), + pagination: { + currentPage: filters?.page ?? 1, + totalCount: data.count, + hasMore: data.next !== null, + }, + }; + } + + async getFavoriteItems( + filters?: ItemFilters, + ): Promise { + const response = await fetchAPI(`items/favorite_list/`, { + params: { ...filters, page_size: 200 }, + }); + + const data = await response.json(); + return { + children: jsonToItems(data.results), + pagination: { + currentPage: filters?.page ?? 1, + totalCount: data.count, + hasMore: data.next !== null, + }, + }; + } + + async createFavoriteItem(itemId: string): Promise { + await fetchAPI(`items/${itemId}/favorite/`, { + method: "POST", + }); + } + + async deleteFavoriteItem(itemId: string): Promise { + await fetchAPI(`items/${itemId}/favorite/`, { + method: "DELETE", + }); + } + async createFile(data: { - parentId: string; + parentId?: string; file: File; filename: string; progressHandler?: (progress: number) => void; }): Promise { const { parentId, file, progressHandler, ...rest } = data; + const url = parentId ? `items/${parentId}/children/` : `items/`; const response = await fetchAPI( - `items/${parentId}/children/`, + url, { method: "POST", body: JSON.stringify({ @@ -289,7 +365,7 @@ export class StandardDriver extends Driver { // We don't want to redirect to the login page in this case, instead // we want to show an error. redirectOn40x: false, - } + }, ); const item = jsonToItem(await response.json()); if (!item.policy) { @@ -374,7 +450,7 @@ const jsonToItem = (data: any): Item => { export const uploadFile = ( url: string, file: File, - progressHandler: (progress: number) => void + progressHandler: (progress: number) => void, ) => new Promise((resolve, reject) => { const xhr = new XMLHttpRequest(); @@ -400,7 +476,7 @@ export const uploadFile = ( xhr.upload.addEventListener("progress", (progressEvent) => { if (progressEvent.lengthComputable) { progressHandler( - Math.floor((progressEvent.loaded / progressEvent.total) * 100) + Math.floor((progressEvent.loaded / progressEvent.total) * 100), ); } }); diff --git a/src/frontend/apps/drive/src/features/drivers/types.ts b/src/frontend/apps/drive/src/features/drivers/types.ts index 55315bb4..5c36987d 100644 --- a/src/frontend/apps/drive/src/features/drivers/types.ts +++ b/src/frontend/apps/drive/src/features/drivers/types.ts @@ -26,6 +26,7 @@ export enum ItemUploadState { export type ItemBreadcrumb = { id: string; + originalId?: string; // Used to identify all occurrences of the same item in the tree title: string; path: string; depth: number; @@ -34,6 +35,7 @@ export type ItemBreadcrumb = { export type Item = { id: string; + originalId?: string; // Used to identify all occurrences of the same item in the tree title: string; filename: string; creator: { @@ -42,12 +44,17 @@ export type Item = { short_name: string; }; type: ItemType; + ancestors_link_reach: LinkReach | null; + ancestors_link_role: LinkRole | null; + computed_link_reach: LinkReach | null; + computed_link_role: LinkRole | null; deleted_at?: Date; upload_state: string; updated_at: Date; description: string; is_wopi_supported?: boolean; created_at: Date; + is_favorite?: boolean; children?: Item[]; parents?: Item[]; breadcrumb?: ItemBreadcrumb[]; @@ -61,6 +68,7 @@ export type Item = { size?: number; mimetype?: string; user_roles?: Role[]; + user_role?: Role; link_reach?: LinkReach; link_role?: LinkRole; abilities: { @@ -74,6 +82,7 @@ export type Item = { link_configuration: boolean; media_auth: boolean; move: boolean; + link_select_options: Record; partial_update: boolean; restore: boolean; retrieve: boolean; @@ -86,6 +95,11 @@ export type Item = { export type TreeItemData = Omit & { parentId?: string; + /** + * The original item ID (without tree path prefix). + * Used to identify all occurrences of the same item in the tree. + */ + originalId: string; }; export type TreeItem = TreeViewDataType; @@ -101,6 +115,16 @@ export type Access = { role: string; team: string; user: User; + is_explicit: boolean; + max_role: Role; + max_ancestors_role: Role; + max_ancestors_role_item_id: string; + parent_id_max_role?: string; // Just for UI purposes + item: { + id: string; + path: string; + depth: number; + }; abilities: { destroy: boolean; partial_update: boolean; diff --git a/src/frontend/apps/drive/src/features/explorer/api/useMoveItem.tsx b/src/frontend/apps/drive/src/features/explorer/api/useMoveItem.tsx index 0f663652..45b76d83 100644 --- a/src/frontend/apps/drive/src/features/explorer/api/useMoveItem.tsx +++ b/src/frontend/apps/drive/src/features/explorer/api/useMoveItem.tsx @@ -1,17 +1,24 @@ import { getDriver } from "@/features/config/Config"; -import { Item } from "@/features/drivers/types"; -import { PaginatedChildrenResult } from "@gouvfr-lasuite/ui-kit"; import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { useRemoveItemsFromPaginatedList } from "../hooks/useOptimisticPagination"; +import { + getMyFilesQueryKey, + getRecentItemsQueryKey, + getSharedWithMeQueryKey, +} from "@/utils/defaultRoutes"; export const useMoveItems = () => { type MoveItemPayload = { ids: string[]; - parentId: string; - oldParentId: string; + parentId?: string; + oldParentId?: string; }; const queryClient = useQueryClient(); const driver = getDriver(); + + const removeItems = useRemoveItemsFromPaginatedList(); + return useMutation({ mutationFn: async (payload: MoveItemPayload) => { await driver.moveItems(payload.ids, payload.parentId); @@ -27,25 +34,12 @@ export const useMoveItems = () => { }); }, onSuccess: (data, payload: MoveItemPayload) => { - const queriesData = queryClient.getQueriesData({ - queryKey: ["items", payload.oldParentId, "children", "infinite"], - }); - - queriesData.forEach((query) => { - const key = query[0]; - const data: { pages: PaginatedChildrenResult[] } = JSON.parse( - JSON.stringify(query[1]) - ) as { - pages: PaginatedChildrenResult[]; - }; - - data.pages.forEach((page) => { - page.children = page.children?.filter( - (child) => !payload.ids.includes(child.id) - ); - }); - - queryClient.setQueryData(key, data); + removeItems(["items", payload.oldParentId], payload.ids); + removeItems(getMyFilesQueryKey(), payload.ids); + removeItems(getSharedWithMeQueryKey(), payload.ids); + removeItems(getRecentItemsQueryKey(), payload.ids); + queryClient.invalidateQueries({ + queryKey: ["items", payload.parentId], }); }, onError: (err, variables) => { diff --git a/src/frontend/apps/drive/src/features/explorer/components/ExplorerDndProvider.tsx b/src/frontend/apps/drive/src/features/explorer/components/ExplorerDndProvider.tsx index 1a417ceb..93d8516e 100644 --- a/src/frontend/apps/drive/src/features/explorer/components/ExplorerDndProvider.tsx +++ b/src/frontend/apps/drive/src/features/explorer/components/ExplorerDndProvider.tsx @@ -12,7 +12,11 @@ import { } from "@dnd-kit/core"; import { getEventCoordinates } from "@dnd-kit/utilities"; import { useMoveItems } from "../api/useMoveItem"; -import { useGlobalExplorer } from "./GlobalExplorerContext"; +import { + itemToTreeItem, + useGlobalExplorer, + getOriginalIdFromTreeId, +} from "./GlobalExplorerContext"; import { Item, TreeItem } from "@/features/drivers/types"; import { ExplorerDragOverlay } from "./tree/ExploreDragOverlay"; import { TreeViewNodeTypeEnum, useTreeContext } from "@gouvfr-lasuite/ui-kit"; @@ -23,6 +27,8 @@ import { ConfirmationMoveState, ExplorerTreeMoveConfirmationModal, } from "./tree/ExplorerTreeMoveConfirmationModal"; +import { DefaultRoute } from "@/utils/defaultRoutes"; +import { useMutationCreateFavoriteItem } from "../hooks/useMutations"; const activationConstraint = { distance: 20, @@ -52,12 +58,13 @@ export const useDragItemContext = () => { export const ExplorerDndProvider = ({ children }: ExplorerDndProviderProps) => { const moveConfirmationModal = useModal(); const [overedItemIds, setOveredItemIds] = useState>( - {} + {}, ); const [moveState, setMoveState] = useState( - undefined + undefined, ); const { itemId, selectedItems, setSelectedItems } = useGlobalExplorer(); + const { mutateAsync: createFavoriteItem } = useMutationCreateFavoriteItem(); const treeContext = useTreeContext(); @@ -72,6 +79,12 @@ export const ExplorerDndProvider = ({ children }: ExplorerDndProviderProps) => { const keyboardSensor = useSensor(KeyboardSensor, {}); const sensors = useSensors(mouseSensor, touchSensor, keyboardSensor); + const handleCreateFavoriteItem = async (item: Item) => { + await createFavoriteItem(item.id); + // Generate a unique tree ID for the favorite item + const itemTree = itemToTreeItem(item, DefaultRoute.FAVORITES, true); + treeContext?.treeData.addChild(DefaultRoute.FAVORITES, itemTree); + }; const handleDragStart = (ev: DragStartEvent) => { document.body.style.cursor = "grabbing"; @@ -108,19 +121,36 @@ export const ExplorerDndProvider = ({ children }: ExplorerDndProviderProps) => { // Reset the selected items after the move setSelectedItems([]); }, - } + }, ); }; const handleDragEnd = async ({ active, over }: DragEndEvent) => { document.body.style.cursor = "default"; - const activeItem = active.data.current?.item as Item; - const overItem = over?.data.current?.item as Item; + const activeItemRaw = active.data.current?.item as Item; + const overItemRaw = over?.data.current?.item as Item; - if (!activeItem || !overItem) { + // Extract the original item ID from the tree ID (handles favorites path format) + const activeItem = { + ...activeItemRaw, + id: getOriginalIdFromTreeId(activeItemRaw.id), + }; + const overItemId = overItemRaw?.id + ? getOriginalIdFromTreeId(overItemRaw.id) + : undefined; + + if (overItemId === DefaultRoute.FAVORITES && activeItem) { + await handleCreateFavoriteItem(activeItem); return; } + + if (!activeItem || !overItemRaw || !overItemId) { + return; + } + + const overItem = { ...overItemRaw, id: overItemId }; + if (activeItem.id === overItem.id) { return; } @@ -139,6 +169,7 @@ export const ExplorerDndProvider = ({ children }: ExplorerDndProviderProps) => { sourceItem: activeItem, targetItem: overItem, }); + setOveredItemIds({}); moveConfirmationModal.open(); return; } @@ -212,7 +243,16 @@ export const snapToTopLeft: Modifier = ({ }; export const canDrop = (activeItem: Item, overItem: Item | TreeItem) => { - if (activeItem.id === overItem.id) { + // Extract the original item ID from the tree ID (handles favorites path format) + const overItemId = overItem?.id + ? getOriginalIdFromTreeId(overItem.id) + : undefined; + const activeItemId = getOriginalIdFromTreeId(activeItem.id); + + if (overItemId === DefaultRoute.FAVORITES) { + return true; + } + if (activeItemId === overItemId) { return false; } @@ -244,14 +284,6 @@ export const canDrop = (activeItem: Item, overItem: Item | TreeItem) => { return false; } - if (activePathSegments.length === 1 && overPathSegments.length === 1) { - return activePathSegments[0] === overPathSegments[0]; - } - - if (activePathSegments.length < 2) { - return false; - } - if (overPathSegments.length < 1) { return false; } diff --git a/src/frontend/apps/drive/src/features/explorer/components/tree/hooks/useAddTreeNode.tsx b/src/frontend/apps/drive/src/features/explorer/components/tree/hooks/useAddTreeNode.tsx deleted file mode 100644 index e8e2c658..00000000 --- a/src/frontend/apps/drive/src/features/explorer/components/tree/hooks/useAddTreeNode.tsx +++ /dev/null @@ -1,44 +0,0 @@ -import { Item, TreeItem } from "@/features/drivers/types"; -import { - TreeViewDataType, - TreeViewNodeTypeEnum, - useTreeContext, -} from "@gouvfr-lasuite/ui-kit"; -import { useTranslation } from "react-i18next"; -import { itemToTreeItem, useGlobalExplorer } from "../../GlobalExplorerContext"; -import { WorkspaceCategory } from "../../../constants"; - -export const useAddWorkspaceNode = () => { - const treeContext = useTreeContext(); - const { t } = useTranslation(); - const { refreshMobileNodes } = useGlobalExplorer(); - const addWorkspaceNode = (data: Item) => { - const sharedNode = treeContext?.treeData.getNode( - WorkspaceCategory.SHARED_SPACE - ); - if (!sharedNode) { - const publicWorkspaceNode: TreeViewDataType = { - id: WorkspaceCategory.SHARED_SPACE, - nodeType: TreeViewNodeTypeEnum.SIMPLE_NODE, - childrenCount: 1, - label: t("explorer.tree.shared_space"), - children: [itemToTreeItem(data)], - pagination: { - currentPage: 1, - hasMore: false, - }, - }; - treeContext?.treeData.addRootNode(publicWorkspaceNode, 1); - } else { - treeContext?.treeData.addChild( - WorkspaceCategory.SHARED_SPACE, - itemToTreeItem(data), - 0 - ); - } - // Refresh mobile nodes to ensure the workspace category is displayed if needed - refreshMobileNodes(); - }; - - return { addWorkspaceNode }; -}; diff --git a/src/frontend/apps/drive/src/features/explorer/components/tree/hooks/useDeleteTreeNode.tsx b/src/frontend/apps/drive/src/features/explorer/components/tree/hooks/useDeleteTreeNode.tsx deleted file mode 100644 index 92fa9f35..00000000 --- a/src/frontend/apps/drive/src/features/explorer/components/tree/hooks/useDeleteTreeNode.tsx +++ /dev/null @@ -1,64 +0,0 @@ -import { TreeItem } from "@/features/drivers/types"; -import { useTreeContext } from "@gouvfr-lasuite/ui-kit"; -import { WorkspaceCategory } from "../../../constants"; - -export const useDeleteTreeNode = () => { - const treeContext = useTreeContext(); - - const deleteTreeNode = ( - nodeId: string, - // eslint-disable-next-line @typescript-eslint/no-unused-vars - _redirectToParent: boolean = false - ) => { - // Get the node to delete - const node = treeContext?.treeData.getNode(nodeId); - if (!node) return; // If the node is not found, return - - const parentId = treeContext?.treeData.getParentId(nodeId); - - if ( - parentId !== WorkspaceCategory.SHARED_SPACE && - parentId !== WorkspaceCategory.PUBLIC_SPACE - ) { - treeContext?.treeData.deleteNode(nodeId); - - return; - } - - // This function checks if the node to be deleted is a direct child of the root "shared" or "public" nodes. - // If it is, it deletes the node. If that root node only had this single child, - // it also deletes the root node itself (removing the shared/public section from the tree when empty). - const checkRootNode = (parentKey: string): boolean => { - const rootNode = treeContext?.treeData.getNode(parentKey); - - if (!rootNode) return false; - - // Check if nodeId is a direct child of the given root node (shared/public section) - const isChildOfRoot = rootNode?.children?.some( - (child) => child.id === nodeId - ); - - if (!isChildOfRoot) return false; - - // If this is the last child, we will remove the root as well - const isLastChild = rootNode.children?.length === 1; - - // Delete the target node from the tree - treeContext?.treeData.deleteNode(nodeId); - - // If it was the last child, remove the section/root node as well - if (isLastChild) { - treeContext?.treeData.deleteNode(parentKey); - } - - return true; - }; - - const isSharedLastChild = checkRootNode(WorkspaceCategory.SHARED_SPACE); - if (!isSharedLastChild) { - checkRootNode(WorkspaceCategory.PUBLIC_SPACE); - } - }; - - return { deleteTreeNode }; -}; diff --git a/src/frontend/apps/drive/src/features/explorer/constants.ts b/src/frontend/apps/drive/src/features/explorer/constants.ts deleted file mode 100644 index 8f78c78d..00000000 --- a/src/frontend/apps/drive/src/features/explorer/constants.ts +++ /dev/null @@ -1,4 +0,0 @@ -export enum WorkspaceCategory { - SHARED_SPACE = "SHARED_SPACE", - PUBLIC_SPACE = "PUBLIC_SPACE", -} diff --git a/src/frontend/apps/drive/src/features/explorer/hooks/useDeleteItem.tsx b/src/frontend/apps/drive/src/features/explorer/hooks/useDeleteItem.tsx index 29270105..12542242 100644 --- a/src/frontend/apps/drive/src/features/explorer/hooks/useDeleteItem.tsx +++ b/src/frontend/apps/drive/src/features/explorer/hooks/useDeleteItem.tsx @@ -4,20 +4,26 @@ import { } from "@/features/ui/components/toaster/Toaster"; import { useMutationDeleteItems } from "./useMutations"; import { useTranslation } from "react-i18next"; +import { useTreeUtils } from "./useTreeUtils"; export const useDeleteItem = () => { const { t } = useTranslation(); + const treeUtils = useTreeUtils(); const deleteItemsMutation = useMutationDeleteItems(); + const deleteItems = async (itemIds: string[]) => { try { await deleteItemsMutation.mutateAsync(itemIds); + for (const itemId of itemIds) { + treeUtils.deleteAllByOriginalId(itemId); + } addToast( delete {t("explorer.actions.delete.toast", { count: itemIds.length })} - + , ); } catch { addToast( @@ -28,7 +34,7 @@ export const useDeleteItem = () => { count: itemIds.length, })} - + , ); } }; diff --git a/src/frontend/apps/drive/src/features/explorer/hooks/useInfiniteItems.ts b/src/frontend/apps/drive/src/features/explorer/hooks/useInfiniteItems.ts index 6dbc239f..23ec0da4 100644 --- a/src/frontend/apps/drive/src/features/explorer/hooks/useInfiniteItems.ts +++ b/src/frontend/apps/drive/src/features/explorer/hooks/useInfiniteItems.ts @@ -1,29 +1,57 @@ import { useInfiniteQuery } from "@tanstack/react-query"; import { getDriver } from "@/features/config/Config"; -import { ItemFilters } from "@/features/drivers/Driver"; +import { ItemFilters, PaginatedChildrenResult } from "@/features/drivers/Driver"; + +type Fetcher = (filters: ItemFilters) => Promise; + +const createInfiniteItemsHook = ( + defaultQueryKey: string[], + fetcher: Fetcher +) => { + return ( + filters: ItemFilters = {}, + queryKey: string[] = defaultQueryKey, + enabled: boolean = true + ) => { + const effectiveQueryKey = [ + ...queryKey, + ...(Object.keys(filters).length ? [JSON.stringify(filters)] : []), + ]; + return useInfiniteQuery({ + queryKey: effectiveQueryKey, + queryFn: ({ pageParam = 1 }) => { + return fetcher({ + page: pageParam, + ...filters, + }); + }, + getNextPageParam: (lastPage) => { + return lastPage.pagination.hasMore + ? lastPage.pagination.currentPage + 1 + : undefined; + }, + initialPageParam: 1, + enabled: enabled, + }); + }; +}; export const useInfiniteItems = ( filters: ItemFilters = {}, + queryKey: string[] = ["items", "infinite"], enabled: boolean = true ) => { - return useInfiniteQuery({ - queryKey: [ - "items", - "infinite", - ...(Object.keys(filters).length ? [JSON.stringify(filters)] : []), - ], - queryFn: ({ pageParam = 1 }) => { - return getDriver().getItems({ - page: pageParam, - ...filters, - }); - }, - getNextPageParam: (lastPage) => { - return lastPage.pagination.hasMore - ? lastPage.pagination.currentPage + 1 - : undefined; - }, - initialPageParam: 1, - enabled: enabled, - }); + const fetcher: Fetcher = filters.is_favorite + ? (f) => getDriver().getFavoriteItems(f) + : (f) => getDriver().getItems(f); + + return createInfiniteItemsHook( + ["items", "infinite"], + fetcher + )(filters, queryKey, enabled); }; + +export const useInfiniteRecentItems = createInfiniteItemsHook( + ["items", "recent", "infinite"], + (filters) => getDriver().getRecentItems(filters) +); diff --git a/src/frontend/apps/drive/src/features/explorer/hooks/useMutations.ts b/src/frontend/apps/drive/src/features/explorer/hooks/useMutations.ts index 4676ccc9..915aa914 100644 --- a/src/frontend/apps/drive/src/features/explorer/hooks/useMutations.ts +++ b/src/frontend/apps/drive/src/features/explorer/hooks/useMutations.ts @@ -1,21 +1,37 @@ import { getDriver } from "@/features/config/Config"; import { Item } from "@/features/drivers/types"; import { useMutation, useQueryClient } from "@tanstack/react-query"; -import { useGlobalExplorer } from "../components/GlobalExplorerContext"; +import { + useGlobalExplorer, + generateTreeId, +} from "../components/GlobalExplorerContext"; +import { + useAddItemToPaginatedList, + useRemoveItemsFromPaginatedList, +} from "./useOptimisticPagination"; +import { useTreeContext } from "@gouvfr-lasuite/ui-kit"; +import { + useRefreshQueryCacheAfterMutation, + useDeleteMutationCallbacks, + useRefreshItemCache, + useRefreshFavoriteCache, +} from "./useRefreshItems"; +import { DefaultRoute } from "@/utils/defaultRoutes"; + +// ============================================================================ +// MUTATIONS +// ============================================================================ export const useMutationCreateFile = () => { const driver = getDriver(); - const queryClient = useQueryClient(); + const refresh = useRefreshQueryCacheAfterMutation(); + return useMutation({ mutationFn: async (...payload: Parameters) => { return driver.createFile(...payload); }, onSuccess: (data, variables) => { - if (variables.parentId) { - queryClient.invalidateQueries({ - queryKey: ["items", variables.parentId], - }); - } + refresh(variables.parentId); }, meta: { showErrorOn403: true, @@ -25,53 +41,25 @@ export const useMutationCreateFile = () => { export const useMutationDeleteItems = () => { const driver = getDriver(); - const queryClient = useQueryClient(); const { item } = useGlobalExplorer(); + + const mutationCallbacks = useDeleteMutationCallbacks( + item?.originalId ?? item?.id, + ); + return useMutation({ mutationFn: async (...payload: Parameters) => { await driver.deleteItems(...payload); }, - onMutate: async (itemIds) => { - // Cancel any outgoing refetches - await queryClient.cancelQueries({ - queryKey: ["items", item!.id, "children"], - }); - - // Snapshot the previous value - const previousItems = queryClient.getQueryData([ - "items", - item!.id, - "children", - ]); - - // Optimistically update to the new value - queryClient.setQueryData( - ["items", item!.id, "children"], - (old: Item[]) => - old ? old.filter((i: Item) => !itemIds.includes(i.id)) : old - ); - - // Return a context object with the snapshotted value - return { previousItems }; - }, - onError: (err, variables, context) => { - // If the mutation fails, use the context returned from onMutate to roll back - queryClient.setQueryData( - ["items", item!.id, "children"], - context?.previousItems - ); - }, - onSuccess: () => { - queryClient.invalidateQueries({ - queryKey: ["items", item!.id], - }); - }, + ...mutationCallbacks, }); }; export const useMutationHardDeleteItems = () => { const driver = getDriver(); - const queryClient = useQueryClient(); + const mutationCallbacks = useDeleteMutationCallbacks(undefined, [ + ["items", "trash"], + ]); return useMutation({ mutationFn: async ( @@ -79,107 +67,76 @@ export const useMutationHardDeleteItems = () => { ) => { await driver.hardDeleteItems(...payload); }, - onMutate: async (itemIds) => { - // Cancel any outgoing refetches - await queryClient.cancelQueries({ - queryKey: ["items", "trash"], - }); - - // Snapshot the previous value - const previousItems = queryClient.getQueryData(["items", "trash"]); - - // Optimistically update to the new value - queryClient.setQueryData(["items", "trash"], (old: Item[]) => - old ? old.filter((i: Item) => !itemIds.includes(i.id)) : old - ); - - // Return a context object with the snapshotted value - return { previousItems }; - }, - onError: (err, variables, context) => { - // If the mutation fails, use the context returned from onMutate to roll back - queryClient.setQueryData(["items", "trash"], context?.previousItems); - }, - onSuccess: () => { - queryClient.invalidateQueries({ - queryKey: ["items", "trash"], - }); - }, + ...mutationCallbacks, }); }; export const useMutationRenameItem = () => { const driver = getDriver(); - const queryClient = useQueryClient(); - const { item } = useGlobalExplorer(); + const refreshItemCache = useRefreshItemCache(); + return useMutation({ mutationFn: async (...payload: Parameters) => { await driver.updateItem(...payload); }, - onMutate: async (itemUpdated) => { - await queryClient.cancelQueries({ - queryKey: ["items", item!.id, "children"], - }); - const previousItems = queryClient.getQueryData([ - "items", - item!.id, - "children", - ]); - queryClient.setQueryData( - ["items", item!.id, "children"], - (old: Item[]) => - old - ? old.map((i: Item) => - i.id === itemUpdated.id ? { ...i, ...itemUpdated } : i - ) - : old - ); - return { previousItems }; + onMutate: async (...payload: Parameters) => { + if (!payload[0].id) { + return; + } + await refreshItemCache(payload[0].id!, { title: payload[0].title }); }, - onError: (err, variables, context) => { - queryClient.setQueryData( - ["items", item!.id, "children"], - context?.previousItems - ); + onError: (_error, variables) => { + if (!variables.id) { + return; + } + + refreshItemCache(variables.id); }, - onSuccess: () => { - queryClient.invalidateQueries({ - queryKey: ["items", item!.id, "children"], - }); + + onSuccess: (_, itemUpdated) => { + if (!itemUpdated?.id) { + return; + } + refreshItemCache(itemUpdated.id, itemUpdated); }, }); }; export const useMutationCreateFolder = () => { - const queryClient = useQueryClient(); const driver = getDriver(); + const addItemToTopOfPaginatedList = useAddItemToPaginatedList(); return useMutation({ mutationFn: (...payload: Parameters) => { return driver.createFolder(...payload); }, onSuccess: (data, variables) => { - if (variables.parentId) { - queryClient.invalidateQueries({ - queryKey: ["items", variables.parentId], - }); - } + const queryKey = variables.parentId + ? ["items", variables.parentId, "children"] + : ["items", "infinite", JSON.stringify({ is_creator_me: true })]; + addItemToTopOfPaginatedList(queryKey, data); }, }); }; -export const useMutationUpdateItem = () => { +export const useMutationUpdateLinkConfiguration = () => { const driver = getDriver(); + const refreshItemCache = useRefreshItemCache(); const queryClient = useQueryClient(); - const { item } = useGlobalExplorer(); + const refreshQueryCacheAfterMutation = useRefreshQueryCacheAfterMutation(); return useMutation({ - mutationFn: async (...payload: Parameters) => { - await driver.updateItem(...payload); + mutationFn: async ( + ...payload: Parameters + ) => { + await driver.updateLinkConfiguration(...payload); }, - onSuccess: () => { + onSuccess: (_, variables) => { queryClient.invalidateQueries({ - queryKey: ["items", item!.id], - exact: true, + queryKey: ["items", variables.itemId], + }); + + queryClient.invalidateQueries({ + queryKey: ["itemAccesses"], }); }, }); @@ -248,98 +205,48 @@ export const useMutationUpdateWorkspace = () => { }); }; -// TODO: Make optimistic once the tree is implemented -export const useMutationDeleteWorskpace = () => { - const queryClient = useQueryClient(); +export const useMutationCreateFavoriteItem = () => { const driver = getDriver(); + const refreshFavoriteCache = useRefreshFavoriteCache(); + const refreshItemCache = useRefreshItemCache(); + return useMutation({ - mutationFn: (...payload: Parameters) => { - return driver.deleteWorkspace(...payload); + mutationFn: (...payload: Parameters) => { + return driver.createFavoriteItem(...payload); }, - onSuccess: () => { - queryClient.invalidateQueries({ - queryKey: ["items"], - }); + onSuccess: (_, itemId: string) => { + refreshFavoriteCache(itemId, true); + refreshItemCache(itemId, { is_favorite: true }); }, }); }; -export const useMutationCreateAccess = () => { +export const useMutationDeleteFavoriteItem = () => { const driver = getDriver(); - + const treeContext = useTreeContext(); + const removeItems = useRemoveItemsFromPaginatedList(); + const refreshFavoriteCache = useRefreshFavoriteCache(); + const refreshItemCache = useRefreshItemCache(); return useMutation({ - mutationFn: (...payload: Parameters) => { - return driver.createAccess(...payload); - }, - }); -}; - -export const useMutationCreateInvitation = () => { - const driver = getDriver(); - return useMutation({ - mutationFn: (...payload: Parameters) => { - return driver.createInvitation(...payload); - }, - }); -}; - -export const useMutationUpdateInvitation = () => { - const driver = getDriver(); - const queryClient = useQueryClient(); - return useMutation({ - mutationFn: (...payload: Parameters) => { - return driver.updateInvitation(...payload); - }, - onSuccess: (_, variables) => { - queryClient.invalidateQueries({ - queryKey: ["itemInvitations", variables.itemId], - }); - }, - }); -}; - -export const useMutationUpdateAccess = () => { - const driver = getDriver(); - const queryClient = useQueryClient(); - return useMutation({ - mutationFn: (...payload: Parameters) => { - return driver.updateAccess(...payload); - }, - onSuccess: (_data, variables) => { - queryClient.invalidateQueries({ - queryKey: ["itemAccesses", variables.itemId], - }); - }, - }); -}; - -export const useMutationDeleteAccess = () => { - const driver = getDriver(); - const queryClient = useQueryClient(); - return useMutation({ - mutationFn: (...payload: Parameters) => { - return driver.deleteAccess(...payload); - }, - onSuccess: (_, variables) => { - queryClient.invalidateQueries({ - queryKey: ["itemAccesses", variables.itemId], - }); - }, - }); -}; - -export const useMutationDeleteInvitation = () => { - const driver = getDriver(); - const queryClient = useQueryClient(); - return useMutation({ - mutationFn: (...payload: Parameters) => { - return driver.deleteInvitation(...payload); - }, - onSuccess: (_, variables) => { - queryClient.invalidateQueries({ - queryKey: ["itemInvitations", variables.itemId], - }); + mutationFn: (...payload: Parameters) => { + return driver.deleteFavoriteItem(...payload); + }, + onSuccess: (_data, itemId: string) => { + // Only delete the root favorite node (directly under favorites) + // Children of opened favorite folders should remain visible + const rootFavoriteTreeId = generateTreeId( + itemId, + DefaultRoute.FAVORITES, + true, + ); + treeContext?.treeData.deleteNode(rootFavoriteTreeId); + removeItems( + ["items", "infinite", JSON.stringify({ is_favorite: true })], + [itemId], + ); + refreshItemCache(itemId, { is_favorite: false }); + refreshFavoriteCache(itemId, false); }, }); }; diff --git a/src/frontend/apps/drive/src/features/explorer/hooks/useMutationsAccesses.ts b/src/frontend/apps/drive/src/features/explorer/hooks/useMutationsAccesses.ts new file mode 100644 index 00000000..bdaa81fc --- /dev/null +++ b/src/frontend/apps/drive/src/features/explorer/hooks/useMutationsAccesses.ts @@ -0,0 +1,87 @@ +import { getDriver } from "@/features/config/Config"; +import { useMutation } from "@tanstack/react-query"; +import { useOnSuccessAccessOrInvitationMutation } from "./useRefreshItems"; + +// ============================================================================ +// ACCESS & INVITATION MUTATIONS +// ============================================================================ + +export const useMutationCreateAccess = () => { + const driver = getDriver(); + const onSuccessAccessOrInvitation = useOnSuccessAccessOrInvitationMutation(); + return useMutation({ + mutationFn: (...payload: Parameters) => { + return driver.createAccess(...payload); + }, + onSuccess: (_, variables) => { + onSuccessAccessOrInvitation(variables.itemId, false); + }, + }); +}; + +export const useMutationCreateInvitation = () => { + const driver = getDriver(); + const onSuccessAccessOrInvitation = useOnSuccessAccessOrInvitationMutation(); + + return useMutation({ + mutationFn: (...payload: Parameters) => { + return driver.createInvitation(...payload); + }, + onSuccess: (_, variables) => { + onSuccessAccessOrInvitation(variables.itemId, true); + }, + }); +}; + +export const useMutationUpdateInvitation = () => { + const driver = getDriver(); + + const onSuccessAccessOrInvitation = useOnSuccessAccessOrInvitationMutation(); + return useMutation({ + mutationFn: (...payload: Parameters) => { + return driver.updateInvitation(...payload); + }, + onSuccess: (_, variables) => { + onSuccessAccessOrInvitation(variables.itemId, true); + }, + }); +}; + +export const useMutationUpdateAccess = () => { + const driver = getDriver(); + const onSuccessAccessOrInvitation = useOnSuccessAccessOrInvitationMutation(); + return useMutation({ + mutationFn: (...payload: Parameters) => { + return driver.updateAccess(...payload); + }, + onSuccess: (_data, variables) => { + onSuccessAccessOrInvitation(variables.itemId, false); + }, + }); +}; + +export const useMutationDeleteAccess = () => { + const driver = getDriver(); + const onSuccessAccessOrInvitation = useOnSuccessAccessOrInvitationMutation(); + return useMutation({ + mutationFn: (...payload: Parameters) => { + return driver.deleteAccess(...payload); + }, + onSuccess: (_, variables) => { + onSuccessAccessOrInvitation(variables.itemId, false); + }, + }); +}; + +export const useMutationDeleteInvitation = () => { + const driver = getDriver(); + const onSuccessAccessOrInvitation = useOnSuccessAccessOrInvitationMutation(); + return useMutation({ + mutationFn: (...payload: Parameters) => { + return driver.deleteInvitation(...payload); + }, + onSuccess: (_, variables) => { + onSuccessAccessOrInvitation(variables.itemId, true); + }, + }); +}; diff --git a/src/frontend/apps/drive/src/features/explorer/hooks/useOptimisticPagination.ts b/src/frontend/apps/drive/src/features/explorer/hooks/useOptimisticPagination.ts new file mode 100644 index 00000000..bfc7cd16 --- /dev/null +++ b/src/frontend/apps/drive/src/features/explorer/hooks/useOptimisticPagination.ts @@ -0,0 +1,235 @@ +import { Item } from "@/features/drivers/types"; +import { PaginatedChildrenResult } from "@gouvfr-lasuite/ui-kit"; +import { QueryClient, QueryKey, useQueryClient } from "@tanstack/react-query"; + +/** + * Adds an item to the top of the first page of a paginated infinite query list. + * This function finds all queries matching the queryKey pattern and updates them + * by prepending the new item to the first page. + * + * @param queryClient - The react-query QueryClient instance + * @param queryKey - The query key pattern to match (can be partial) + * @param newItem - The item to add at the top of the list + */ +export const addItemToTopOfPaginatedList = ( + queryClient: QueryClient, + queryKey: QueryKey, + newItem: Item +): void => { + // Get all queries matching the queryKey pattern + const queriesData = queryClient.getQueriesData({ + queryKey, + }); + + queriesData.forEach((query) => { + const key = query[0]; + const data = query[1] as + | { pages: PaginatedChildrenResult[] } + | undefined; + + if (!data || !data.pages || data.pages.length === 0) { + return; + } + + // Deep clone to avoid mutating the original data + const updatedData: { pages: PaginatedChildrenResult[] } = JSON.parse( + JSON.stringify(data) + ); + + // Add the new item to the top of the first page + if (updatedData.pages[0]) { + // Check if item already exists to avoid duplicates + const itemExists = updatedData.pages.some((page) => + page.children?.some((child) => child.id === newItem.id) + ); + + if (!itemExists) { + updatedData.pages[0].children = [ + newItem, + ...(updatedData.pages[0].children || []), + ]; + } + } + + // Update the query data + queryClient.setQueryData(key, updatedData); + }); +}; + +/** + * Removes items from a paginated infinite query list. + * This function finds all queries matching the queryKey pattern and updates them + * by filtering out the items with the specified IDs from all pages. + * + * @param queryClient - The react-query QueryClient instance + * @param queryKey - The query key pattern to match (can be partial) + * @param itemIds - The IDs of items to remove from the list + */ +export const removeItemsFromPaginatedList = ( + queryClient: QueryClient, + queryKey: QueryKey, + itemIds: string[] +): void => { + // Get all queries matching the queryKey pattern + const queriesData = queryClient.getQueriesData({ + queryKey, + }); + + queriesData.forEach((query) => { + const key = query[0]; + const data = query[1] as + | { pages: PaginatedChildrenResult[] } + | undefined; + + if (!data || !data.pages || data.pages.length === 0) { + return; + } + + // Deep clone to avoid mutating the original data + const updatedData: { pages: PaginatedChildrenResult[] } = JSON.parse( + JSON.stringify(data) + ); + + // Remove items from all pages + updatedData.pages.forEach((page) => { + page.children = page.children?.filter( + (child) => !itemIds.includes(child.id) + ); + }); + + // Update the query data + queryClient.setQueryData(key, updatedData); + }); +}; + +/** + * Hook that returns a function to add an item to the top of a paginated list. + * This is a convenience hook that provides access to the queryClient. + * + * @example + * ```tsx + * const addItemToTop = useAddItemToPaginatedList(); + * + * // In a mutation's onSuccess callback: + * onSuccess: (newItem) => { + * addItemToTop( + * ["items", parentId, "children", "infinite"], + * newItem + * ); + * } + * ``` + * + * @returns A function that takes a queryKey and an item, and adds the item to the top of the list + */ +export const useAddItemToPaginatedList = () => { + const queryClient = useQueryClient(); + + return (queryKey: QueryKey, newItem: Item) => { + addItemToTopOfPaginatedList(queryClient, queryKey, newItem); + }; +}; + +/** + * Hook that returns a function to remove items from a paginated list. + * This is a convenience hook that provides access to the queryClient. + * + * @example + * ```tsx + * const removeItems = useRemoveItemsFromPaginatedList(); + * + * // In a mutation's onSuccess callback: + * onSuccess: () => { + * removeItems( + * ["items", parentId, "children", "infinite"], + * ["item-id-1", "item-id-2"] + * ); + * } + * ``` + * + * @returns A function that takes a queryKey and item IDs, and removes those items from the list + */ +export const useRemoveItemsFromPaginatedList = () => { + const queryClient = useQueryClient(); + + return (queryKey: QueryKey, itemIds: string[]) => { + removeItemsFromPaginatedList(queryClient, queryKey, itemIds); + }; +}; + +/** + * Updates a partial item in a paginated infinite query list. + * This function finds all queries matching the queryKey pattern and updates them + * by merging the partial update with the existing item in all pages. + * + * @param queryClient - The react-query QueryClient instance + * @param queryKey - The query key pattern to match (can be partial) + * @param itemId - The ID of the item to update + * @param partialUpdate - The partial item data to merge with the existing item + */ +export const updateItemInPaginatedList = ( + queryClient: QueryClient, + queryKey: QueryKey, + itemId: string, + partialUpdate: Partial +): void => { + // Get all queries matching the queryKey pattern + const queriesData = queryClient.getQueriesData({ + queryKey, + }); + + queriesData.forEach((query) => { + const key = query[0]; + const data = query[1] as + | { pages: PaginatedChildrenResult[] } + | undefined; + + if (!data || !data.pages || data.pages.length === 0) { + return; + } + + // Deep clone to avoid mutating the original data + const updatedData: { pages: PaginatedChildrenResult[] } = JSON.parse( + JSON.stringify(data) + ); + + // Update item in all pages + updatedData.pages.forEach((page) => { + if (page.children) { + page.children = page.children.map((child) => + child.id === itemId ? { ...child, ...partialUpdate } : child + ); + } + }); + + // Update the query data + queryClient.setQueryData(key, updatedData); + }); +}; + +/** + * Hook that returns a function to update a partial item in a paginated list. + * This is a convenience hook that provides access to the queryClient. + * + * @example + * ```tsx + * const updateItem = useUpdateItemInPaginatedList(); + * + * // In a mutation's onSuccess callback: + * onSuccess: (updatedItem) => { + * updateItem( + * ["items", parentId, "children", "infinite"], + * updatedItem.id, + * { title: updatedItem.title, is_favorite: updatedItem.is_favorite } + * ); + * } + * ``` + * + * @returns A function that takes a queryKey, itemId, and partial update, and updates the item in the list + */ +export const useUpdateItemInPaginatedList = () => { + const queryClient = useQueryClient(); + + return (queryKey: QueryKey, itemId: string, partialUpdate: Partial) => { + updateItemInPaginatedList(queryClient, queryKey, itemId, partialUpdate); + }; +}; diff --git a/src/frontend/apps/drive/src/features/explorer/hooks/useQueries.tsx b/src/frontend/apps/drive/src/features/explorer/hooks/useQueries.tsx index e75d1e60..e1a23626 100644 --- a/src/frontend/apps/drive/src/features/explorer/hooks/useQueries.tsx +++ b/src/frontend/apps/drive/src/features/explorer/hooks/useQueries.tsx @@ -1,15 +1,28 @@ +import { APIError } from "@/features/api/APIError"; import { getDriver } from "@/features/config/Config"; -import { useInfiniteQuery, useQuery } from "@tanstack/react-query"; +import { ItemFilters } from "@/features/drivers/Driver"; +import { Item } from "@/features/drivers/types"; +import { HookUseQueryOptions } from "@/utils/useQueries"; +import { + useInfiniteQuery, + useQuery, + UseQueryOptions, + UseQueryResult, +} from "@tanstack/react-query"; -export const useInfiniteItemAccesses = (itemId: string) => { - const driver = getDriver(); - return useInfiniteQuery({ +export const useFavoriteItems = () => { + return useQuery({ + queryKey: ["items", "favorites"], + queryFn: () => getDriver().getFavoriteItems(), + }); +}; + +export const useItemAccesses = (itemId: string) => { + return useQuery({ queryKey: ["itemAccesses", itemId], - queryFn: () => driver.getItemAccesses(itemId), - initialPageParam: 1, - getNextPageParam(lastPage, allPages) { - return lastPage.next ? allPages.length + 1 : undefined; - }, + queryFn: () => getDriver().getItemAccesses(itemId), + staleTime: 0, + gcTime: 0, }); }; @@ -41,7 +54,21 @@ export const useItems = () => { }); }; -export const getRootItems = async () => { - const result = await getDriver().getItems(); +export const getRootItems = async (filters?: ItemFilters) => { + const result = await getDriver().getItems(filters); return result.children; }; + +export const useItem = ( + itemId: string, + options?: HookUseQueryOptions, +): UseQueryResult => { + return useQuery({ + queryKey: ["items", itemId], + queryFn: () => getDriver().getItem(itemId), + ...(options as Omit< + UseQueryOptions, + "queryKey" | "queryFn" + >), + }); +}; diff --git a/src/frontend/apps/drive/src/features/explorer/hooks/useRefreshItems.ts b/src/frontend/apps/drive/src/features/explorer/hooks/useRefreshItems.ts new file mode 100644 index 00000000..e59a67bd --- /dev/null +++ b/src/frontend/apps/drive/src/features/explorer/hooks/useRefreshItems.ts @@ -0,0 +1,165 @@ +import { useQueryClient, QueryKey } from "@tanstack/react-query"; +import { Item } from "@/features/drivers/types"; +import { + useRemoveItemsFromPaginatedList, + useUpdateItemInPaginatedList, +} from "./useOptimisticPagination"; +import { useTreeContext } from "@gouvfr-lasuite/ui-kit"; +import { DefaultRoute } from "@/utils/defaultRoutes"; +import { generateTreeId } from "../components/GlobalExplorerContext"; + +export const useGetQueryKeyToRefresh = () => { + return (parentId?: string) => { + const queryKeys = [["items", "infinite"]]; + if (parentId) { + queryKeys.push(["items", parentId, "children"]); + } + // let queryKey = parentId ? ["items", parentId, "children"] : []; + // if (queryKeyForRoute.length > 0) { + // queryKey = queryKeyForRoute; + // } + return queryKeys; + }; +}; + +export const useRefreshQueryCacheAfterMutation = () => { + const queryClient = useQueryClient(); + const getQueryKey = useGetQueryKeyToRefresh(); + + return (parentId?: string) => { + const queryKey = getQueryKey(parentId); + + for (const key of queryKey) { + queryClient.invalidateQueries({ + queryKey: key, + }); + } + }; +}; + +export const useDeleteMutationCallbacks = ( + parentId?: string, + defaultQueryKey?: string[][], +) => { + const queryClient = useQueryClient(); + const getQueryKey = useGetQueryKeyToRefresh(); + const removeItems = useRemoveItemsFromPaginatedList(); + const queryKeys = defaultQueryKey ?? getQueryKey(parentId); + + const onMutate = async (itemIds: string[]) => { + const returnPreviousItems: Map = new Map(); + queryKeys.forEach(async (key) => { + await queryClient.cancelQueries({ + queryKey: key, + }); + const previousItems = queryClient.getQueryData(key); + returnPreviousItems.set(key, previousItems ?? []); + removeItems(key, itemIds); + }); + + return { previousItems: returnPreviousItems }; + }; + + const onError = (_err: unknown, _variables: unknown, context: unknown) => { + const returnPreviousItems = context as { + previousItems: Map; + }; + returnPreviousItems.previousItems.forEach((previousItems, key) => { + queryClient.setQueryData(key, previousItems); + }); + }; + + const onSuccess = () => { + if (queryKeys.length === 0) { + return; + } + + queryClient.invalidateQueries({ + queryKey: queryKeys, + }); + }; + + return { onMutate, onError, onSuccess }; +}; + +// Explanation: +// The function below is used to refresh the cache for certain queries after a mutation (creation, deletion, update) +// on items/files in the explorer. It takes as an argument the id of the parent whose list of children needs to be refreshed. +// It uses the QueryClient from react-query to force reloading/invalidating queries associated with the parent key: +// - This prevents the UI from becoming out of sync with the backend state after a mutation. +export const useRefreshItemCache = () => { + const queryClient = useQueryClient(); + + const updateItemInPaginatedList = useUpdateItemInPaginatedList(); + return async ( + itemId: string, + partialUpdate?: Partial, + moreQueriesToInvalidate?: QueryKey[], + ) => { + if (partialUpdate) { + updateItemInPaginatedList(["items"], itemId, partialUpdate); + queryClient.setQueryData(["item", itemId], (old: Item) => { + return { + ...old, + ...partialUpdate, + }; + }); + } else { + queryClient.invalidateQueries({ + queryKey: ["items"], + }); + queryClient.invalidateQueries({ + queryKey: ["item", itemId], + }); + moreQueriesToInvalidate?.forEach((queryKey) => { + queryClient.invalidateQueries({ + queryKey, + }); + }); + } + }; +}; + +export const useOnSuccessAccessOrInvitationMutation = () => { + const queryClient = useQueryClient(); + const refreshItemCache = useRefreshItemCache(); + return (itemId: string, isInvitation: boolean = false) => { + refreshItemCache(itemId); + queryClient.invalidateQueries({ + queryKey: ["items", itemId, "children"], + }); + + if (isInvitation) { + queryClient.invalidateQueries({ + queryKey: ["itemInvitations", itemId], + }); + } else { + queryClient.invalidateQueries({ + queryKey: ["itemAccesses", itemId], + }); + } + }; +}; + +export const useRefreshFavoriteCache = () => { + const queryClient = useQueryClient(); + const treeContext = useTreeContext(); + + return (itemId: string, isFavorite: boolean) => { + const moreQueriesToInvalidate: QueryKey[] = [ + ["items", "infinite", JSON.stringify({ is_favorite: isFavorite })], + ["item", itemId], + ]; + + const rootFavoriteTreeId = generateTreeId( + itemId, + DefaultRoute.FAVORITES, + true, + ); + treeContext?.treeData.deleteNode(rootFavoriteTreeId); + + queryClient.invalidateQueries({ + queryKey: moreQueriesToInvalidate, + }); + }; +};