diff --git a/webapp/src/components/App/Studies/StudiesList/index.tsx b/webapp/src/components/App/Studies/StudiesList/index.tsx index dba3866d98..823dde9524 100644 --- a/webapp/src/components/App/Studies/StudiesList/index.tsx +++ b/webapp/src/components/App/Studies/StudiesList/index.tsx @@ -63,6 +63,7 @@ import { scanFolder } from "../../../../services/api/study"; import useEnqueueErrorSnackbar from "../../../../hooks/useEnqueueErrorSnackbar"; import ConfirmationDialog from "../../../common/dialogs/ConfirmationDialog"; import CheckBoxFE from "@/components/common/fieldEditors/CheckBoxFE"; +import { DEFAULT_WORKSPACE_PREFIX, ROOT_FOLDER_NAME } from "@/components/common/utils/constants"; const CARD_TARGET_WIDTH = 500; const CARD_HEIGHT = 250; @@ -87,6 +88,9 @@ function StudiesList(props: StudiesListProps) { const [selectionMode, setSelectionMode] = useState(false); const [confirmFolderScan, setConfirmFolderScan] = useState(false); const [isRecursiveScan, setIsRecursiveScan] = useState(false); + const isInDefaultWorkspace = !!folder && folder.startsWith(DEFAULT_WORKSPACE_PREFIX); + const isRootFolder = folder === ROOT_FOLDER_NAME; + const scanDisabled: boolean = isInDefaultWorkspace || isRootFolder; useEffect(() => { setFolderList(folder.split("/")); @@ -265,14 +269,14 @@ function StudiesList(props: StudiesListProps) { )} - {folder !== "root" && ( + {!scanDisabled && ( - setConfirmFolderScan(true)}> + setConfirmFolderScan(true)} disabled={scanDisabled}> )} - {folder !== "root" && confirmFolderScan && ( + {!isRootFolder && confirmFolderScan && ( { diff --git a/webapp/src/components/App/Studies/StudyTree/StudyTreeNode.tsx b/webapp/src/components/App/Studies/StudyTree/StudyTreeNode.tsx index 7d3644f06f..d60ad2fd74 100644 --- a/webapp/src/components/App/Studies/StudyTree/StudyTreeNode.tsx +++ b/webapp/src/components/App/Studies/StudyTree/StudyTreeNode.tsx @@ -12,51 +12,48 @@ * This file is part of the Antares project. */ -import { memo, useMemo } from "react"; +import { useMemo } from "react"; import * as R from "ramda"; import type { StudyTreeNodeProps } from "./types"; import TreeItemEnhanced from "@/components/common/TreeItemEnhanced"; import { t } from "i18next"; -export default memo(function StudyTreeNode({ +export default function StudyTreeNode({ studyTreeNode, parentId, + itemsLoading, onNodeClick, }: StudyTreeNodeProps) { - const isLoadingFolder = studyTreeNode.hasChildren && studyTreeNode.children.length === 0; const id = parentId ? `${parentId}/${studyTreeNode.name}` : studyTreeNode.name; - + const isLoadingFolder = itemsLoading.includes(id); + const hasUnloadedChildern = studyTreeNode.hasChildren && studyTreeNode.children.length === 0; const sortedChildren = useMemo( - () => R.sortBy(R.prop("name"), studyTreeNode.children), + () => R.sortBy(R.compose(R.toLower, R.prop("name")), studyTreeNode.children), [studyTreeNode.children], ); - if (isLoadingFolder) { + // Either the user clicked on the folder and we need to show the folder is loading + // Or the explorer api says that this folder has children so we need to load at least one element + // so the arrow to explanse the element is displayed which indicate to the user that this is a folder + if (isLoadingFolder || hasUnloadedChildern) { return ( - onNodeClick(id, studyTreeNode)} - > + ); } return ( - onNodeClick(id, studyTreeNode)} - > + onNodeClick(id)}> {sortedChildren.map((child) => ( ))} ); -}); +} diff --git a/webapp/src/components/App/Studies/StudyTree/__test__/utils.test.ts b/webapp/src/components/App/Studies/StudyTree/__test__/utils.test.ts index b3dad5ee8c..70e9f2cd6f 100644 --- a/webapp/src/components/App/Studies/StudyTree/__test__/utils.test.ts +++ b/webapp/src/components/App/Studies/StudyTree/__test__/utils.test.ts @@ -13,7 +13,13 @@ */ import { FIXTURES, FIXTURES_BUILD_STUDY_TREE } from "./fixtures"; -import { buildStudyTree, insertFoldersIfNotExist, insertWorkspacesIfNotExist } from "../utils"; +import { + buildStudyTree, + insertFoldersIfNotExist, + insertWorkspacesIfNotExist, + mergeDeepRightStudyTree, + innerJoin, +} from "../utils"; import type { NonStudyFolderDTO, StudyTreeNode } from "../types"; describe("StudyTree Utils", () => { @@ -112,4 +118,102 @@ describe("StudyTree Utils", () => { expect(result).toEqual(expected); }); }); + + test("merge two trees", () => { + const lTree: StudyTreeNode = { + name: "root", + path: "/", + children: [ + { name: "A", path: "/A", children: [] }, + { name: "B", path: "/B", children: [] }, + ], + }; + const rTree: StudyTreeNode = { + name: "root", + path: "/", + children: [ + { name: "A", path: "/A1", children: [] }, + { name: "C", path: "/C", children: [] }, + ], + }; + + const mergedTree = mergeDeepRightStudyTree(lTree, rTree); + assert(mergedTree.children.length === 3, "Merged tree should have 3 children"); + assert( + mergedTree.children.some((child) => child.name === "A"), + "Node A should be in merged tree", + ); + assert( + mergedTree.children.some((child) => child.name === "B"), + "Node B should be in merged tree", + ); + assert( + mergedTree.children.some((child) => child.name === "C"), + "Node C should be in merged tree", + ); + assert( + mergedTree.children.some((child) => child.name === "A" && child.path === "/A1"), + "Node A path should be /A1", + ); + }); + + test("merge two trees, empty tree case", () => { + const emptyTree: StudyTreeNode = { name: "root", path: "/", children: [] }; + const singleNodeTree: StudyTreeNode = { + name: "root", + path: "/", + children: [{ name: "A", path: "/A", children: [] }], + }; + + assert( + mergeDeepRightStudyTree(emptyTree, emptyTree).children.length === 0, + "Merging two empty trees should return an empty tree", + ); + + assert( + mergeDeepRightStudyTree(singleNodeTree, emptyTree).children.length === 1, + "Merging a tree with an empty tree should keep original children", + ); + + assert( + mergeDeepRightStudyTree(emptyTree, singleNodeTree).children.length === 1, + "Merging an empty tree with a tree should adopt its children", + ); + }); + + test("inner join", () => { + const tree1: StudyTreeNode[] = [ + { name: "A", path: "/A", children: [] }, + { name: "B", path: "/B", children: [] }, + ]; + const tree2: StudyTreeNode[] = [ + { name: "A", path: "/A1", children: [] }, + { name: "C", path: "/C", children: [] }, + ]; + + const result = innerJoin(tree1, tree2); + assert(result.length === 1, "Should match one node"); + assert(result[0][0].name === "A" && result[0][1].name === "A"); + + const result2 = innerJoin(tree1, tree1); + assert(result2.length === 2, "Should match both nodes"); + assert(result2[0][0].name === "A" && result2[0][1].name === "A"); + assert(result2[1][0].name === "B" && result2[1][1].name === "B"); + }); + + test("inner join, empty tree case", () => { + const tree1: StudyTreeNode[] = []; + const tree2: StudyTreeNode[] = []; + assert(innerJoin(tree1, tree2).length === 0, "Empty trees should return no matches"); + + const tree3: StudyTreeNode[] = [{ name: "X", path: "/X", children: [] }]; + assert( + innerJoin(tree3, tree2).length === 0, + "Tree with unmatched node should return no matches", + ); + assert( + innerJoin(tree3, tree2).length === 0, + "Tree with unmatched node should return no matches", + ); + }); }); diff --git a/webapp/src/components/App/Studies/StudyTree/index.tsx b/webapp/src/components/App/Studies/StudyTree/index.tsx index 8f33cd0ab0..a846b53b7d 100644 --- a/webapp/src/components/App/Studies/StudyTree/index.tsx +++ b/webapp/src/components/App/Studies/StudyTree/index.tsx @@ -20,17 +20,24 @@ import { updateStudyFilters } from "../../../../redux/ducks/studies"; import { SimpleTreeView } from "@mui/x-tree-view/SimpleTreeView"; import { getParentPaths } from "../../../../utils/pathUtils"; import * as R from "ramda"; -import { useState } from "react"; +import React, { useState } from "react"; import useEnqueueErrorSnackbar from "@/hooks/useEnqueueErrorSnackbar"; import useUpdateEffectOnce from "@/hooks/useUpdateEffectOnce"; -import { fetchAndInsertSubfolders, fetchAndInsertWorkspaces } from "./utils"; +import { + mergeDeepRightStudyTree, + fetchAndInsertSubfolders, + fetchAndInsertWorkspaces, +} from "./utils"; import { useTranslation } from "react-i18next"; import { toError } from "@/utils/fnUtils"; import StudyTreeNodeComponent from "./StudyTreeNode"; +import { DEFAULT_WORKSPACE_PREFIX, ROOT_FOLDER_NAME } from "@/components/common/utils/constants"; +import { useUpdateEffect } from "react-use"; function StudyTree() { const initialStudiesTree = useAppSelector(getStudiesTree); const [studiesTree, setStudiesTree] = useState(initialStudiesTree); + const [itemsLoading, setItemsLoading] = useState([]); const folder = useAppSelector((state) => getStudyFilters(state).folder, R.T); const enqueueErrorSnackbar = useEnqueueErrorSnackbar(); const dispatch = useAppDispatch(); @@ -41,7 +48,11 @@ function StudyTree() { useUpdateEffectOnce(() => { // be carefull to pass initialStudiesTree and not studiesTree at rootNode parameter // otherwise we'll lose the default workspace - updateTree("root", initialStudiesTree, initialStudiesTree); + updateTree(ROOT_FOLDER_NAME, initialStudiesTree); + }, [initialStudiesTree]); + + useUpdateEffect(() => { + setStudiesTree((currentState) => mergeDeepRightStudyTree(currentState, initialStudiesTree)); }, [initialStudiesTree]); /** @@ -60,12 +71,13 @@ function StudyTree() { * @param rootNode - The root node of the tree * @param selectedNode - The node of the item clicked */ - async function updateTree(itemId: string, rootNode: StudyTreeNode, selectedNode: StudyTreeNode) { - if (selectedNode.path.startsWith("/default")) { + async function updateTree(itemId: string, rootNode: StudyTreeNode) { + if (itemId.startsWith(DEFAULT_WORKSPACE_PREFIX)) { // we don't update the tree if the user clicks on the default workspace // api doesn't allow to fetch the subfolders of the default workspace return; } + setItemsLoading([...itemsLoading, itemId]); // Bug fix : this function used to take only the itemId and the selectedNode, and we used to initialize treeAfterWorkspacesUpdate // with the studiesTree closure, referencing directly the state, like this : treeAfterWorkspacesUpdate = studiesTree; // The thing is at the first render studiesTree was empty. @@ -78,7 +90,7 @@ function StudyTree() { let pathsToFetch: string[] = []; // If the user clicks on the root folder, we fetch the workspaces and insert them. // Then we fetch the direct subfolders of the workspaces. - if (itemId === "root") { + if (itemId === ROOT_FOLDER_NAME) { try { treeAfterWorkspacesUpdate = await fetchAndInsertWorkspaces(rootNode); } catch (error) { @@ -89,7 +101,7 @@ function StudyTree() { .map((child) => `root${child.path}`); } else { // If the user clicks on a folder, we add the path of the clicked folder to the list of paths to fetch. - pathsToFetch = [`root${selectedNode.path}`]; + pathsToFetch = [itemId]; } const [treeAfterSubfoldersUpdate, failedPath] = await fetchAndInsertSubfolders( @@ -106,15 +118,29 @@ function StudyTree() { ); } setStudiesTree(treeAfterSubfoldersUpdate); + setItemsLoading(itemsLoading.filter((e) => e !== itemId)); } //////////////////////////////////////////////////////////////// // Event Handlers //////////////////////////////////////////////////////////////// - const handleTreeItemClick = async (itemId: string, studyTreeNode: StudyTreeNode) => { + // we need to handle both the expand event and the onClick event + // because the onClick isn't triggered when user click on arrow + // Also the expanse event isn't triggered when the item doesn't have any subfolder + // but we stil want to apply the filter on the clicked folder + const handleItemExpansionToggle = async ( + event: React.SyntheticEvent, + itemId: string, + isExpanded: boolean, + ) => { + if (isExpanded) { + updateTree(itemId, studiesTree); + } + }; + + const handleTreeItemClick = (itemId: string) => { dispatch(updateStudyFilters({ folder: itemId })); - updateTree(itemId, studiesTree, studyTreeNode); }; //////////////////////////////////////////////////////////////// @@ -133,10 +159,12 @@ function StudyTree() { overflowY: "auto", overflowX: "hidden", }} + onItemExpansionToggle={handleItemExpansionToggle} > diff --git a/webapp/src/components/App/Studies/StudyTree/types.ts b/webapp/src/components/App/Studies/StudyTree/types.ts index bdeeab28c2..438cdbff20 100644 --- a/webapp/src/components/App/Studies/StudyTree/types.ts +++ b/webapp/src/components/App/Studies/StudyTree/types.ts @@ -30,5 +30,6 @@ export interface NonStudyFolderDTO { export interface StudyTreeNodeProps { studyTreeNode: StudyTreeNode; parentId: string; - onNodeClick: (id: string, node: StudyTreeNode) => void; + itemsLoading: string[]; + onNodeClick: (id: string) => void; } diff --git a/webapp/src/components/App/Studies/StudyTree/utils.ts b/webapp/src/components/App/Studies/StudyTree/utils.ts index 405ecb6ceb..20958866dd 100644 --- a/webapp/src/components/App/Studies/StudyTree/utils.ts +++ b/webapp/src/components/App/Studies/StudyTree/utils.ts @@ -275,3 +275,47 @@ export async function fetchAndInsertWorkspaces(studyTree: StudyTreeNode): Promis const workspaces = await api.getWorkspaces(); return insertWorkspacesIfNotExist(studyTree, workspaces); } + +/** + * This function is used when we want to get updates of rTree withouth loosing data from lTree. + * + * + * @param left + * @param right + * @returns a new tree with the data from rTree merged into lTree. + */ +export function mergeDeepRightStudyTree(left: StudyTreeNode, right: StudyTreeNode): StudyTreeNode { + const onlyLeft = left.children.filter( + (eLeft) => !right.children.some((eRight) => eLeft.name === eRight.name), + ); + const onlyRight = right.children.filter( + (eRight) => !left.children.some((eLeft) => eLeft.name === eRight.name), + ); + const both = innerJoin(left.children, right.children); + const bothAfterMerge = both.map((e) => mergeDeepRightStudyTree(e[0], e[1])); + const childrenAfterMerge = [...onlyLeft, ...bothAfterMerge, ...onlyRight]; + return { + ...right, + children: childrenAfterMerge, + }; +} + +/** + * This function joins based on the name property. + * + * @param left + * @param right + * @returns list of tuples where the first element is from the left list and the second element is from the right list. + */ +export function innerJoin( + left: StudyTreeNode[], + right: StudyTreeNode[], +): Array<[StudyTreeNode, StudyTreeNode]> { + return left.reduce>((acc, leftNode) => { + const matchedRightNode = right.find((rightNode) => rightNode.name === leftNode.name); + if (matchedRightNode) { + acc.push([{ ...leftNode }, { ...matchedRightNode }]); + } + return acc; + }, []); +} diff --git a/webapp/src/components/common/utils/constants.ts b/webapp/src/components/common/utils/constants.ts index 3b6bf977cd..cfbf85e258 100644 --- a/webapp/src/components/common/utils/constants.ts +++ b/webapp/src/components/common/utils/constants.ts @@ -21,3 +21,6 @@ export const PUBLIC_MODE_LIST: GenericInfo[] = [ { id: "EDIT", name: "global.edit" }, { id: "FULL", name: "study.fullPublicMode" }, ]; + +export const ROOT_FOLDER_NAME = "root"; +export const DEFAULT_WORKSPACE_PREFIX = `${ROOT_FOLDER_NAME}/default`;