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, + }); + }; +};