From d1e6b3b92d8c889bb2f2bf1052b0c0dd3a7ec0f7 Mon Sep 17 00:00:00 2001 From: Anis SMAIL Date: Thu, 30 Jan 2025 13:28:16 +0100 Subject: [PATCH 01/19] add item loading mechanic, replace onClickNode by onExpandedChange --- .../App/Studies/StudyTree/StudyTreeNode.tsx | 18 +++------- .../App/Studies/StudyTree/index.tsx | 33 +++++++++++-------- .../components/App/Studies/StudyTree/types.ts | 2 +- 3 files changed, 25 insertions(+), 28 deletions(-) diff --git a/webapp/src/components/App/Studies/StudyTree/StudyTreeNode.tsx b/webapp/src/components/App/Studies/StudyTree/StudyTreeNode.tsx index 7d3644f06f..335fcd6859 100644 --- a/webapp/src/components/App/Studies/StudyTree/StudyTreeNode.tsx +++ b/webapp/src/components/App/Studies/StudyTree/StudyTreeNode.tsx @@ -21,10 +21,10 @@ import { t } from "i18next"; export default memo(function StudyTreeNode({ studyTreeNode, parentId, - onNodeClick, + itemsLoading, }: StudyTreeNodeProps) { - const isLoadingFolder = studyTreeNode.hasChildren && studyTreeNode.children.length === 0; const id = parentId ? `${parentId}/${studyTreeNode.name}` : studyTreeNode.name; + const isLoadingFolder = itemsLoading.includes(id); const sortedChildren = useMemo( () => R.sortBy(R.prop("name"), studyTreeNode.children), @@ -33,28 +33,20 @@ export default memo(function StudyTreeNode({ if (isLoadingFolder) { return ( - onNodeClick(id, studyTreeNode)} - > + ); } return ( - onNodeClick(id, studyTreeNode)} - > + {sortedChildren.map((child) => ( ))} diff --git a/webapp/src/components/App/Studies/StudyTree/index.tsx b/webapp/src/components/App/Studies/StudyTree/index.tsx index 8f33cd0ab0..0054ccc112 100644 --- a/webapp/src/components/App/Studies/StudyTree/index.tsx +++ b/webapp/src/components/App/Studies/StudyTree/index.tsx @@ -20,7 +20,7 @@ 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 { useState, type SyntheticEvent } from "react"; import useEnqueueErrorSnackbar from "@/hooks/useEnqueueErrorSnackbar"; import useUpdateEffectOnce from "@/hooks/useUpdateEffectOnce"; import { fetchAndInsertSubfolders, fetchAndInsertWorkspaces } from "./utils"; @@ -31,6 +31,7 @@ import StudyTreeNodeComponent from "./StudyTreeNode"; 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 +42,7 @@ 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", initialStudiesTree); }, [initialStudiesTree]); /** @@ -60,12 +61,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("root/default")) { // 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. @@ -89,7 +91,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,17 +108,23 @@ function StudyTree() { ); } setStudiesTree(treeAfterSubfoldersUpdate); + setItemsLoading(itemsLoading.filter((e) => e !== itemId)); } //////////////////////////////////////////////////////////////// // Event Handlers //////////////////////////////////////////////////////////////// - const handleTreeItemClick = async (itemId: string, studyTreeNode: StudyTreeNode) => { - dispatch(updateStudyFilters({ folder: itemId })); - updateTree(itemId, studiesTree, studyTreeNode); + const handleItemExpansionToggle = async ( + event: SyntheticEvent, + itemId: string, + isExpanded: boolean, + ) => { + if (isExpanded) { + dispatch(updateStudyFilters({ folder: itemId })); + updateTree(itemId, studiesTree); + } }; - //////////////////////////////////////////////////////////////// // JSX //////////////////////////////////////////////////////////////// @@ -133,12 +141,9 @@ 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..d91833701b 100644 --- a/webapp/src/components/App/Studies/StudyTree/types.ts +++ b/webapp/src/components/App/Studies/StudyTree/types.ts @@ -30,5 +30,5 @@ export interface NonStudyFolderDTO { export interface StudyTreeNodeProps { studyTreeNode: StudyTreeNode; parentId: string; - onNodeClick: (id: string, node: StudyTreeNode) => void; + itemsLoading: string[]; } From 907bb73f18284e878e3c173b283ec9afd8f9f6b0 Mon Sep 17 00:00:00 2001 From: Anis SMAIL Date: Thu, 30 Jan 2025 14:26:40 +0100 Subject: [PATCH 02/19] diable scan for default folder --- webapp/src/components/App/Studies/StudiesList/index.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/webapp/src/components/App/Studies/StudiesList/index.tsx b/webapp/src/components/App/Studies/StudiesList/index.tsx index dba3866d98..2be733bf91 100644 --- a/webapp/src/components/App/Studies/StudiesList/index.tsx +++ b/webapp/src/components/App/Studies/StudiesList/index.tsx @@ -87,6 +87,7 @@ function StudiesList(props: StudiesListProps) { const [selectionMode, setSelectionMode] = useState(false); const [confirmFolderScan, setConfirmFolderScan] = useState(false); const [isRecursiveScan, setIsRecursiveScan] = useState(false); + const scanDisabled: boolean = !!folder && folder.startsWith("root/default"); useEffect(() => { setFolderList(folder.split("/")); @@ -267,7 +268,7 @@ function StudiesList(props: StudiesListProps) { {folder !== "root" && ( - setConfirmFolderScan(true)}> + setConfirmFolderScan(true)} disabled={scanDisabled}> From 037cbb797ec13ce14747b030f9d15208d47ec530 Mon Sep 17 00:00:00 2001 From: Anis SMAIL Date: Thu, 30 Jan 2025 14:32:22 +0100 Subject: [PATCH 03/19] fix npm lint --- webapp/src/components/App/Studies/StudyTree/index.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/webapp/src/components/App/Studies/StudyTree/index.tsx b/webapp/src/components/App/Studies/StudyTree/index.tsx index 0054ccc112..404e74a279 100644 --- a/webapp/src/components/App/Studies/StudyTree/index.tsx +++ b/webapp/src/components/App/Studies/StudyTree/index.tsx @@ -20,7 +20,8 @@ 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, type SyntheticEvent } from "react"; +import { useState } from "react"; +import React from "react"; import useEnqueueErrorSnackbar from "@/hooks/useEnqueueErrorSnackbar"; import useUpdateEffectOnce from "@/hooks/useUpdateEffectOnce"; import { fetchAndInsertSubfolders, fetchAndInsertWorkspaces } from "./utils"; @@ -116,7 +117,7 @@ function StudyTree() { //////////////////////////////////////////////////////////////// const handleItemExpansionToggle = async ( - event: SyntheticEvent, + event: React.SyntheticEvent, itemId: string, isExpanded: boolean, ) => { From d41ed19250d027aeee81d836304c77ca0ba90a67 Mon Sep 17 00:00:00 2001 From: Anis SMAIL Date: Thu, 30 Jan 2025 14:35:57 +0100 Subject: [PATCH 04/19] fix npm lint --- webapp/src/components/App/Studies/StudyTree/index.tsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/webapp/src/components/App/Studies/StudyTree/index.tsx b/webapp/src/components/App/Studies/StudyTree/index.tsx index 404e74a279..0657b8db97 100644 --- a/webapp/src/components/App/Studies/StudyTree/index.tsx +++ b/webapp/src/components/App/Studies/StudyTree/index.tsx @@ -20,8 +20,7 @@ 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 from "react"; +import React, { useState } from "react"; import useEnqueueErrorSnackbar from "@/hooks/useEnqueueErrorSnackbar"; import useUpdateEffectOnce from "@/hooks/useUpdateEffectOnce"; import { fetchAndInsertSubfolders, fetchAndInsertWorkspaces } from "./utils"; From 76ccf7b9385f84ab73a594c62ff2ed0f727ac709 Mon Sep 17 00:00:00 2001 From: Anis SMAIL Date: Thu, 30 Jan 2025 14:46:38 +0100 Subject: [PATCH 05/19] refactor --- webapp/src/components/App/Studies/StudiesList/index.tsx | 3 ++- webapp/src/components/App/Studies/StudyTree/index.tsx | 7 ++++--- webapp/src/components/common/utils/constants.ts | 3 +++ 3 files changed, 9 insertions(+), 4 deletions(-) diff --git a/webapp/src/components/App/Studies/StudiesList/index.tsx b/webapp/src/components/App/Studies/StudiesList/index.tsx index 2be733bf91..20398f1990 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 } from "@/components/common/utils/constants"; const CARD_TARGET_WIDTH = 500; const CARD_HEIGHT = 250; @@ -87,7 +88,7 @@ function StudiesList(props: StudiesListProps) { const [selectionMode, setSelectionMode] = useState(false); const [confirmFolderScan, setConfirmFolderScan] = useState(false); const [isRecursiveScan, setIsRecursiveScan] = useState(false); - const scanDisabled: boolean = !!folder && folder.startsWith("root/default"); + const scanDisabled: boolean = !!folder && folder.startsWith(DEFAULT_WORKSPACE_PREFIX); useEffect(() => { setFolderList(folder.split("/")); diff --git a/webapp/src/components/App/Studies/StudyTree/index.tsx b/webapp/src/components/App/Studies/StudyTree/index.tsx index 0657b8db97..df1f1e401d 100644 --- a/webapp/src/components/App/Studies/StudyTree/index.tsx +++ b/webapp/src/components/App/Studies/StudyTree/index.tsx @@ -27,6 +27,7 @@ import { 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"; function StudyTree() { const initialStudiesTree = useAppSelector(getStudiesTree); @@ -42,7 +43,7 @@ function StudyTree() { useUpdateEffectOnce(() => { // be carefull to pass initialStudiesTree and not studiesTree at rootNode parameter // otherwise we'll lose the default workspace - updateTree("root", initialStudiesTree); + updateTree(ROOT_FOLDER_NAME, initialStudiesTree); }, [initialStudiesTree]); /** @@ -62,7 +63,7 @@ function StudyTree() { * @param selectedNode - The node of the item clicked */ async function updateTree(itemId: string, rootNode: StudyTreeNode) { - if (itemId.startsWith("root/default")) { + 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; @@ -80,7 +81,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) { 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`; From f4d7db308d2c3cad34f1977d61932b48ae438051 Mon Sep 17 00:00:00 2001 From: Anis SMAIL Date: Thu, 30 Jan 2025 18:17:26 +0100 Subject: [PATCH 06/19] fix filter not selected when we click on folder name --- .../App/Studies/StudiesList/index.tsx | 10 ++++++---- .../App/Studies/StudyTree/StudyTreeNode.tsx | 4 +++- .../components/App/Studies/StudyTree/index.tsx | 17 +++++++++++++++-- .../components/App/Studies/StudyTree/types.ts | 1 + 4 files changed, 25 insertions(+), 7 deletions(-) diff --git a/webapp/src/components/App/Studies/StudiesList/index.tsx b/webapp/src/components/App/Studies/StudiesList/index.tsx index 20398f1990..823dde9524 100644 --- a/webapp/src/components/App/Studies/StudiesList/index.tsx +++ b/webapp/src/components/App/Studies/StudiesList/index.tsx @@ -63,7 +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 } from "@/components/common/utils/constants"; +import { DEFAULT_WORKSPACE_PREFIX, ROOT_FOLDER_NAME } from "@/components/common/utils/constants"; const CARD_TARGET_WIDTH = 500; const CARD_HEIGHT = 250; @@ -88,7 +88,9 @@ function StudiesList(props: StudiesListProps) { const [selectionMode, setSelectionMode] = useState(false); const [confirmFolderScan, setConfirmFolderScan] = useState(false); const [isRecursiveScan, setIsRecursiveScan] = useState(false); - const scanDisabled: boolean = !!folder && folder.startsWith(DEFAULT_WORKSPACE_PREFIX); + const isInDefaultWorkspace = !!folder && folder.startsWith(DEFAULT_WORKSPACE_PREFIX); + const isRootFolder = folder === ROOT_FOLDER_NAME; + const scanDisabled: boolean = isInDefaultWorkspace || isRootFolder; useEffect(() => { setFolderList(folder.split("/")); @@ -267,14 +269,14 @@ function StudiesList(props: StudiesListProps) { )} - {folder !== "root" && ( + {!scanDisabled && ( 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 335fcd6859..4bad0cba6e 100644 --- a/webapp/src/components/App/Studies/StudyTree/StudyTreeNode.tsx +++ b/webapp/src/components/App/Studies/StudyTree/StudyTreeNode.tsx @@ -22,6 +22,7 @@ export default memo(function StudyTreeNode({ studyTreeNode, parentId, itemsLoading, + onNodeClick, }: StudyTreeNodeProps) { const id = parentId ? `${parentId}/${studyTreeNode.name}` : studyTreeNode.name; const isLoadingFolder = itemsLoading.includes(id); @@ -40,13 +41,14 @@ export default memo(function StudyTreeNode({ } return ( - + onNodeClick(id)}> {sortedChildren.map((child) => ( ))} diff --git a/webapp/src/components/App/Studies/StudyTree/index.tsx b/webapp/src/components/App/Studies/StudyTree/index.tsx index df1f1e401d..044a76708e 100644 --- a/webapp/src/components/App/Studies/StudyTree/index.tsx +++ b/webapp/src/components/App/Studies/StudyTree/index.tsx @@ -116,16 +116,24 @@ function StudyTree() { // Event Handlers //////////////////////////////////////////////////////////////// + // 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) { - dispatch(updateStudyFilters({ folder: itemId })); updateTree(itemId, studiesTree); } }; + + const handleTreeItemClick = (itemId: string) => { + dispatch(updateStudyFilters({ folder: itemId })); + }; + //////////////////////////////////////////////////////////////// // JSX //////////////////////////////////////////////////////////////// @@ -144,7 +152,12 @@ function StudyTree() { }} onItemExpansionToggle={handleItemExpansionToggle} > - + ); } diff --git a/webapp/src/components/App/Studies/StudyTree/types.ts b/webapp/src/components/App/Studies/StudyTree/types.ts index d91833701b..438cdbff20 100644 --- a/webapp/src/components/App/Studies/StudyTree/types.ts +++ b/webapp/src/components/App/Studies/StudyTree/types.ts @@ -31,4 +31,5 @@ export interface StudyTreeNodeProps { studyTreeNode: StudyTreeNode; parentId: string; itemsLoading: string[]; + onNodeClick: (id: string) => void; } From d1a090732c223c06ee538f3c92c676523a3774d0 Mon Sep 17 00:00:00 2001 From: Anis SMAIL Date: Fri, 31 Jan 2025 10:57:04 +0100 Subject: [PATCH 07/19] fix sort --- webapp/src/components/App/Studies/StudyTree/StudyTreeNode.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/webapp/src/components/App/Studies/StudyTree/StudyTreeNode.tsx b/webapp/src/components/App/Studies/StudyTree/StudyTreeNode.tsx index 4bad0cba6e..ee7aeac36b 100644 --- a/webapp/src/components/App/Studies/StudyTree/StudyTreeNode.tsx +++ b/webapp/src/components/App/Studies/StudyTree/StudyTreeNode.tsx @@ -28,7 +28,7 @@ export default memo(function StudyTreeNode({ const isLoadingFolder = itemsLoading.includes(id); const sortedChildren = useMemo( - () => R.sortBy(R.prop("name"), studyTreeNode.children), + () => R.sortBy(R.compose(R.toLower, R.prop("name")), studyTreeNode.children), [studyTreeNode.children], ); From b5adc7efdd9475a63ddfe1fdc345994b46a34911 Mon Sep 17 00:00:00 2001 From: Anis SMAIL Date: Wed, 5 Feb 2025 14:45:46 +0100 Subject: [PATCH 08/19] sync study tree state with state from database --- .../Studies/StudyTree/__test__/utils.test.ts | 106 +++++++++++++++++- .../App/Studies/StudyTree/index.tsx | 11 +- .../components/App/Studies/StudyTree/utils.ts | 41 +++++++ 3 files changed, 156 insertions(+), 2 deletions(-) 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 044a76708e..a846b53b7d 100644 --- a/webapp/src/components/App/Studies/StudyTree/index.tsx +++ b/webapp/src/components/App/Studies/StudyTree/index.tsx @@ -23,11 +23,16 @@ import * as R from "ramda"; 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); @@ -46,6 +51,10 @@ function StudyTree() { updateTree(ROOT_FOLDER_NAME, initialStudiesTree); }, [initialStudiesTree]); + useUpdateEffect(() => { + setStudiesTree((currentState) => mergeDeepRightStudyTree(currentState, initialStudiesTree)); + }, [initialStudiesTree]); + /** * This function is called at the initialization of the component and when the user clicks on a folder. * diff --git a/webapp/src/components/App/Studies/StudyTree/utils.ts b/webapp/src/components/App/Studies/StudyTree/utils.ts index 405ecb6ceb..aee6896209 100644 --- a/webapp/src/components/App/Studies/StudyTree/utils.ts +++ b/webapp/src/components/App/Studies/StudyTree/utils.ts @@ -15,6 +15,7 @@ import * as api from "../../../../services/api/study"; import type { StudyMetadata } from "../../../../common/types"; import type { StudyTreeNode, NonStudyFolderDTO } from "./types"; +import * as R from "ramda"; /** * Builds a tree structure from a list of study metadata. @@ -275,3 +276,43 @@ 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 lTree + * @param rTree + * @returns a new tree with the data from rTree merged into lTree. + */ +export function mergeDeepRightStudyTree(lTree: StudyTreeNode, rTree: StudyTreeNode): StudyTreeNode { + const onlyLeft = lTree.children.filter((e) => !rTree.children.some((ee) => ee.name === e.name)); + const onlyRight = rTree.children.filter((e) => !lTree.children.some((ee) => ee.name === e.name)); + const both = innerJoin(lTree.children, rTree.children); + const bothAfterMerge = both.map((e) => mergeDeepRightStudyTree(e[0], e[1])); + const childrenAfterMerge = [...onlyLeft, ...bothAfterMerge, ...onlyRight]; + return { + ...rTree, + 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[], +): [StudyTreeNode, StudyTreeNode][] { + return left.reduce<[StudyTreeNode, StudyTreeNode][]>((acc, leftNode) => { + const matchedRightNode = right.find((rightNode) => rightNode.name === leftNode.name); + if (matchedRightNode) { + acc.push([{ ...leftNode }, { ...matchedRightNode }]); + } + return acc; + }, []); +} From 5538d17829847e5893c854d3a2c278bd372c350b Mon Sep 17 00:00:00 2001 From: Anis SMAIL Date: Wed, 5 Feb 2025 15:01:57 +0100 Subject: [PATCH 09/19] remove unused import --- webapp/src/components/App/Studies/StudyTree/utils.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/webapp/src/components/App/Studies/StudyTree/utils.ts b/webapp/src/components/App/Studies/StudyTree/utils.ts index aee6896209..daf66c38b2 100644 --- a/webapp/src/components/App/Studies/StudyTree/utils.ts +++ b/webapp/src/components/App/Studies/StudyTree/utils.ts @@ -15,7 +15,6 @@ import * as api from "../../../../services/api/study"; import type { StudyMetadata } from "../../../../common/types"; import type { StudyTreeNode, NonStudyFolderDTO } from "./types"; -import * as R from "ramda"; /** * Builds a tree structure from a list of study metadata. From 39ed424d2f32e3ab5afce24f39855244d391746f Mon Sep 17 00:00:00 2001 From: Anis SMAIL Date: Wed, 5 Feb 2025 15:18:49 +0100 Subject: [PATCH 10/19] harmonize names --- .../components/App/Studies/StudyTree/utils.ts | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/webapp/src/components/App/Studies/StudyTree/utils.ts b/webapp/src/components/App/Studies/StudyTree/utils.ts index daf66c38b2..35e529e28a 100644 --- a/webapp/src/components/App/Studies/StudyTree/utils.ts +++ b/webapp/src/components/App/Studies/StudyTree/utils.ts @@ -280,18 +280,22 @@ export async function fetchAndInsertWorkspaces(studyTree: StudyTreeNode): Promis * This function is used when we want to get updates of rTree withouth loosing data from lTree. * * - * @param lTree - * @param rTree + * @param left + * @param right * @returns a new tree with the data from rTree merged into lTree. */ -export function mergeDeepRightStudyTree(lTree: StudyTreeNode, rTree: StudyTreeNode): StudyTreeNode { - const onlyLeft = lTree.children.filter((e) => !rTree.children.some((ee) => ee.name === e.name)); - const onlyRight = rTree.children.filter((e) => !lTree.children.some((ee) => ee.name === e.name)); - const both = innerJoin(lTree.children, rTree.children); +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 { - ...rTree, + ...right, children: childrenAfterMerge, }; } From a136c5a04bd47c8f139ba5d57bebc8725dbb5f4a Mon Sep 17 00:00:00 2001 From: Anis SMAIL Date: Wed, 5 Feb 2025 15:45:00 +0100 Subject: [PATCH 11/19] fix build --- webapp/src/components/App/Studies/StudyTree/utils.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/webapp/src/components/App/Studies/StudyTree/utils.ts b/webapp/src/components/App/Studies/StudyTree/utils.ts index 35e529e28a..bb758cdb89 100644 --- a/webapp/src/components/App/Studies/StudyTree/utils.ts +++ b/webapp/src/components/App/Studies/StudyTree/utils.ts @@ -308,9 +308,9 @@ export function mergeDeepRightStudyTree(left: StudyTreeNode, right: StudyTreeNod * @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[], -): [StudyTreeNode, StudyTreeNode][] { + left: Array, + right: Array, +): Array<[StudyTreeNode, StudyTreeNode]> { return left.reduce<[StudyTreeNode, StudyTreeNode][]>((acc, leftNode) => { const matchedRightNode = right.find((rightNode) => rightNode.name === leftNode.name); if (matchedRightNode) { From 6d636c4d37a51f7547906754b5e640636ab22f7a Mon Sep 17 00:00:00 2001 From: Anis SMAIL Date: Thu, 6 Feb 2025 09:42:13 +0100 Subject: [PATCH 12/19] refactor --- webapp/src/components/App/Studies/StudyTree/utils.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/webapp/src/components/App/Studies/StudyTree/utils.ts b/webapp/src/components/App/Studies/StudyTree/utils.ts index bb758cdb89..20958866dd 100644 --- a/webapp/src/components/App/Studies/StudyTree/utils.ts +++ b/webapp/src/components/App/Studies/StudyTree/utils.ts @@ -308,10 +308,10 @@ export function mergeDeepRightStudyTree(left: StudyTreeNode, right: StudyTreeNod * @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: Array, - right: Array, + left: StudyTreeNode[], + right: StudyTreeNode[], ): Array<[StudyTreeNode, StudyTreeNode]> { - return left.reduce<[StudyTreeNode, StudyTreeNode][]>((acc, leftNode) => { + return left.reduce>((acc, leftNode) => { const matchedRightNode = right.find((rightNode) => rightNode.name === leftNode.name); if (matchedRightNode) { acc.push([{ ...leftNode }, { ...matchedRightNode }]); From 4dd94e0ca4d91f79569f9c4dd809668868885632 Mon Sep 17 00:00:00 2001 From: Anis SMAIL Date: Mon, 10 Feb 2025 17:06:42 +0100 Subject: [PATCH 13/19] fix broken arrow --- .../App/Studies/StudyTree/StudyTreeNode.tsx | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/webapp/src/components/App/Studies/StudyTree/StudyTreeNode.tsx b/webapp/src/components/App/Studies/StudyTree/StudyTreeNode.tsx index ee7aeac36b..90128c5a03 100644 --- a/webapp/src/components/App/Studies/StudyTree/StudyTreeNode.tsx +++ b/webapp/src/components/App/Studies/StudyTree/StudyTreeNode.tsx @@ -18,7 +18,7 @@ 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, @@ -26,13 +26,16 @@ export default memo(function StudyTreeNode({ }: StudyTreeNodeProps) { 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.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 ( @@ -53,4 +56,4 @@ export default memo(function StudyTreeNode({ ))} ); -}); +} From 56dd8c4276f2e00f04408b044ba718236bbad7e8 Mon Sep 17 00:00:00 2001 From: Anis SMAIL Date: Mon, 10 Feb 2025 17:17:53 +0100 Subject: [PATCH 14/19] fix build --- webapp/src/components/App/Studies/StudyTree/StudyTreeNode.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/webapp/src/components/App/Studies/StudyTree/StudyTreeNode.tsx b/webapp/src/components/App/Studies/StudyTree/StudyTreeNode.tsx index 90128c5a03..d60ad2fd74 100644 --- a/webapp/src/components/App/Studies/StudyTree/StudyTreeNode.tsx +++ b/webapp/src/components/App/Studies/StudyTree/StudyTreeNode.tsx @@ -12,7 +12,7 @@ * 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"; From 72ab5f3931b031084e1240c33c9fa50aed9b8095 Mon Sep 17 00:00:00 2001 From: Anis SMAIL Date: Mon, 17 Feb 2025 16:52:01 +0100 Subject: [PATCH 15/19] wip --- .../Studies/StudyTree/__test__/fixtures.ts | 119 ------------- .../Studies/StudyTree/__test__/utils.test.ts | 161 +----------------- .../App/Studies/StudyTree/index.tsx | 73 +++----- .../components/App/Studies/StudyTree/utils.ts | 106 +----------- 4 files changed, 25 insertions(+), 434 deletions(-) diff --git a/webapp/src/components/App/Studies/StudyTree/__test__/fixtures.ts b/webapp/src/components/App/Studies/StudyTree/__test__/fixtures.ts index 616d018500..54b38c2837 100644 --- a/webapp/src/components/App/Studies/StudyTree/__test__/fixtures.ts +++ b/webapp/src/components/App/Studies/StudyTree/__test__/fixtures.ts @@ -12,26 +12,6 @@ * This file is part of the Antares project. */ -import { StudyType, type StudyMetadata } from "@/common/types"; - -function createStudyMetadata(folder: string, workspace: string): StudyMetadata { - return { - id: "test-study-id", - name: "Test Study", - creationDate: "2024-01-01", - modificationDate: "2024-01-02", - owner: { id: 1, name: "Owner 1" }, - type: StudyType.RAW, - version: "v1", - workspace, - managed: false, - archived: false, - groups: [], - folder, - publicMode: "NONE", - }; -} - export const FIXTURES = { basicTree: { name: "Basic tree with single level", @@ -229,102 +209,3 @@ export const FIXTURES = { }, }, }; - -export const FIXTURES_BUILD_STUDY_TREE = { - simpleCase: { - name: "Basic case", - studies: [createStudyMetadata("studies/team1/myFolder", "workspace")], - expected: { - name: "root", - path: "", - children: [ - { - name: "default", - path: "/default", - children: [], - }, - { - name: "workspace", - path: "/workspace", - children: [ - { - name: "studies", - path: "/workspace/studies", - children: [ - { - name: "team1", - path: "/workspace/studies/team1", - children: [], - }, - ], - }, - ], - }, - ], - }, - }, - multiplieStudies: { - name: "Multiple studies case", - studies: [ - createStudyMetadata("studies/team1/study", "workspace"), - createStudyMetadata("studies/team2/study", "workspace"), - createStudyMetadata("studies/team3/study", "workspace"), - createStudyMetadata("archives/team4/study", "workspace2"), - ], - expected: { - name: "root", - path: "", - children: [ - { - name: "default", - path: "/default", - children: [], - }, - { - name: "workspace", - path: "/workspace", - children: [ - { - name: "studies", - path: "/workspace/studies", - children: [ - { - name: "team1", - path: "/workspace/studies/team1", - children: [], - }, - { - name: "team2", - path: "/workspace/studies/team2", - children: [], - }, - { - name: "team3", - path: "/workspace/studies/team3", - children: [], - }, - ], - }, - ], - }, - { - name: "workspace2", - path: "/workspace2", - children: [ - { - name: "archives", - path: "/workspace2/archives", - children: [ - { - name: "team4", - path: "/workspace2/archives/team4", - children: [], - }, - ], - }, - ], - }, - ], - }, - }, -}; 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 70e9f2cd6f..7a5ca7e84b 100644 --- a/webapp/src/components/App/Studies/StudyTree/__test__/utils.test.ts +++ b/webapp/src/components/App/Studies/StudyTree/__test__/utils.test.ts @@ -12,14 +12,8 @@ * This file is part of the Antares project. */ -import { FIXTURES, FIXTURES_BUILD_STUDY_TREE } from "./fixtures"; -import { - buildStudyTree, - insertFoldersIfNotExist, - insertWorkspacesIfNotExist, - mergeDeepRightStudyTree, - innerJoin, -} from "../utils"; +import { FIXTURES } from "./fixtures"; +import { insertFoldersIfNotExist } from "../utils"; import type { NonStudyFolderDTO, StudyTreeNode } from "../types"; describe("StudyTree Utils", () => { @@ -64,156 +58,5 @@ describe("StudyTree Utils", () => { const result = insertFoldersIfNotExist(tree, [invalidFolder]); expect(result).toEqual(tree); }); - - test("should handle empty workspaces", () => { - const tree: StudyTreeNode = { - name: "Root", - path: "/", - children: [ - { - name: "a", - path: "/a", - children: [{ name: "suba", path: "/a/suba", children: [] }], - }, - ], - }; - const workspaces: string[] = []; - const result = insertWorkspacesIfNotExist(tree, workspaces); - expect(result).toEqual(tree); - }); - - test("should merge workspaces", () => { - const tree: StudyTreeNode = { - name: "Root", - path: "/", - children: [ - { - name: "a", - path: "/a", - children: [{ name: "suba", path: "/a/suba", children: [] }], - }, - ], - }; - const expected: StudyTreeNode = { - name: "Root", - path: "/", - children: [ - { - name: "a", - path: "/a", - children: [{ name: "suba", path: "/a/suba", children: [] }], - }, - { name: "workspace1", path: "/workspace1", children: [] }, - { name: "workspace2", path: "/workspace2", children: [] }, - ], - }; - - const workspaces = ["a", "workspace1", "workspace2"]; - const result = insertWorkspacesIfNotExist(tree, workspaces); - expect(result).toEqual(expected); - }); - - test.each(Object.values(FIXTURES_BUILD_STUDY_TREE))("$name", ({ studies, expected }) => { - const result = buildStudyTree(studies); - 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 a846b53b7d..099c602287 100644 --- a/webapp/src/components/App/Studies/StudyTree/index.tsx +++ b/webapp/src/components/App/Studies/StudyTree/index.tsx @@ -12,7 +12,7 @@ * This file is part of the Antares project. */ -import type { StudyTreeNode } from "./types"; +import type { NonStudyFolderDTO } from "./types"; import useAppSelector from "../../../../redux/hooks/useAppSelector"; import { getStudiesTree, getStudyFilters } from "../../../../redux/selectors"; import useAppDispatch from "../../../../redux/hooks/useAppDispatch"; @@ -22,14 +22,8 @@ import { getParentPaths } from "../../../../utils/pathUtils"; import * as R from "ramda"; import React, { useState } from "react"; import useEnqueueErrorSnackbar from "@/hooks/useEnqueueErrorSnackbar"; -import useUpdateEffectOnce from "@/hooks/useUpdateEffectOnce"; -import { - mergeDeepRightStudyTree, - fetchAndInsertSubfolders, - fetchAndInsertWorkspaces, -} from "./utils"; +import { fetchSubfolders, insertFoldersIfNotExist } 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"; @@ -37,22 +31,16 @@ import { useUpdateEffect } from "react-use"; function StudyTree() { const initialStudiesTree = useAppSelector(getStudiesTree); const [studiesTree, setStudiesTree] = useState(initialStudiesTree); + const [subFolders, setSubFolders] = useState([]); const [itemsLoading, setItemsLoading] = useState([]); const folder = useAppSelector((state) => getStudyFilters(state).folder, R.T); const enqueueErrorSnackbar = useEnqueueErrorSnackbar(); const dispatch = useAppDispatch(); const [t] = useTranslation(); - // Initialize folders once we have the tree - // we use useUpdateEffectOnce because at first render initialStudiesTree isn't initialized - useUpdateEffectOnce(() => { - // be carefull to pass initialStudiesTree and not studiesTree at rootNode parameter - // otherwise we'll lose the default workspace - updateTree(ROOT_FOLDER_NAME, initialStudiesTree); - }, [initialStudiesTree]); - useUpdateEffect(() => { - setStudiesTree((currentState) => mergeDeepRightStudyTree(currentState, initialStudiesTree)); + const nextStudiesTree = insertFoldersIfNotExist(initialStudiesTree, subFolders); + setStudiesTree(nextStudiesTree); }, [initialStudiesTree]); /** @@ -71,53 +59,36 @@ 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) { + async function updateTree(itemId: string) { 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. - // This made updateTree override studiesTree with an empty tree during the first call. This caused a bug where we didn't see the default - // workspace in the UI, as it was overridden by an empty tree at start and then the get workspaces api never returns the default workspace. - // Now we don't use the closure anymore, we pass the root tree as a parameter. Thus at the first call of updateTree, we pass initialStudiesTree. - // You may think why we don't just capture initialStudiesTree then, it's because in the following call we need to pass studiesTree. - let treeAfterWorkspacesUpdate = rootNode; - - 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 the user clicks on the root folder, we don't update the tree, + // there're no subfolders under the root only workspaces. if (itemId === ROOT_FOLDER_NAME) { - try { - treeAfterWorkspacesUpdate = await fetchAndInsertWorkspaces(rootNode); - } catch (error) { - enqueueErrorSnackbar(t("studies.tree.error.failToFetchWorkspace"), toError(error)); - } - pathsToFetch = treeAfterWorkspacesUpdate.children - .filter((t) => t.name !== "default") // We don't fetch the default workspace subfolders, api don't allow it - .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 = [itemId]; + return; } - - const [treeAfterSubfoldersUpdate, failedPath] = await fetchAndInsertSubfolders( - pathsToFetch, - treeAfterWorkspacesUpdate, - ); - if (failedPath.length > 0) { + // usefull flag to display "loading..." message + setItemsLoading([...itemsLoading, itemId]); + // fetch subfolders and inesrt them to the tree + try { + const newSubFolders = await fetchSubfolders(itemId); + // use union to prioritize new subfolders + const nextSubfolders = R.unionWith(R.eqBy(R.prop("path")), newSubFolders, subFolders); + setSubFolders(nextSubfolders); + const nextStudiesTree = insertFoldersIfNotExist(studiesTree, nextSubfolders); + setStudiesTree(nextStudiesTree); + } catch { enqueueErrorSnackbar( t("studies.tree.error.failToFetchFolder", { - path: failedPath.join(" "), + path: itemId, interpolation: { escapeValue: false }, }), "", ); } - setStudiesTree(treeAfterSubfoldersUpdate); setItemsLoading(itemsLoading.filter((e) => e !== itemId)); } @@ -135,7 +106,7 @@ function StudyTree() { isExpanded: boolean, ) => { if (isExpanded) { - updateTree(itemId, studiesTree); + updateTree(itemId); } }; diff --git a/webapp/src/components/App/Studies/StudyTree/utils.ts b/webapp/src/components/App/Studies/StudyTree/utils.ts index 20958866dd..9126f55422 100644 --- a/webapp/src/components/App/Studies/StudyTree/utils.ts +++ b/webapp/src/components/App/Studies/StudyTree/utils.ts @@ -160,7 +160,7 @@ export function insertFoldersIfNotExist( * @param path - path of the subfolder to fetch, should sart with root, e.g. root/workspace/folder1 * @returns list of subfolders under the given path */ -async function fetchSubfolders(path: string): Promise { +export async function fetchSubfolders(path: string): Promise { if (path === "root") { console.error("this function should not be called with path 'root'", path); // Under root there're workspaces not subfolders @@ -215,107 +215,3 @@ export async function fetchAndInsertSubfolders( [studiesTree, []], ); } - -/** - * Insert a workspace into the study tree if it doesn't exist already. - * - * This function doesn't mutate the tree, it returns a new tree with the workspace inserted. - * - * @param workspace - key of the workspace - * @param stydyTree - study tree to insert the workspace into - * @returns study tree with the empty workspace inserted if it wasn't already there. - */ -function insertWorkspaceIfNotExist(stydyTree: StudyTreeNode, workspace: string): StudyTreeNode { - const emptyNode = { - name: workspace, - path: `/${workspace}`, - children: [], - }; - if (stydyTree.children.some((child) => child.name === workspace)) { - return stydyTree; - } - return { - ...stydyTree, - children: [...stydyTree.children, emptyNode], - }; -} - -/** - * Insert several workspaces into the study tree if they don't exist already in the tree. - * - * This function doesn't mutate the tree, it returns a new tree with the workspaces inserted. - * - * The workspaces are inserted in the order they are given. - * - * @param workspaces - workspaces to insert into the tree - * @param stydyTree - study tree to insert the workspaces into - * @returns study tree with the empty workspaces inserted if they weren't already there. - */ -export function insertWorkspacesIfNotExist( - stydyTree: StudyTreeNode, - workspaces: string[], -): StudyTreeNode { - return workspaces.reduce( - (acc, workspace) => insertWorkspaceIfNotExist(acc, workspace), - stydyTree, - ); -} - -/** - * Fetch and insert the workspaces into the study tree. - * - * Workspaces are inserted only if they don't exist already in the tree. - * - * This function doesn't mutate the tree, it returns a new tree with the workspaces inserted. - * - * @param studyTree - study tree to insert the workspaces into - * @returns study tree with the workspaces inserted if they weren't already there. - */ -export async function fetchAndInsertWorkspaces(studyTree: StudyTreeNode): Promise { - 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; - }, []); -} From 9caf25564d4b93b4082be8b4c69f7e3d76a532c0 Mon Sep 17 00:00:00 2001 From: Anis SMAIL Date: Tue, 18 Feb 2025 10:25:34 +0100 Subject: [PATCH 16/19] remove more useless code --- .../components/App/Studies/StudyTree/utils.ts | 31 ------------------- 1 file changed, 31 deletions(-) diff --git a/webapp/src/components/App/Studies/StudyTree/utils.ts b/webapp/src/components/App/Studies/StudyTree/utils.ts index 9126f55422..8704e7e8c2 100644 --- a/webapp/src/components/App/Studies/StudyTree/utils.ts +++ b/webapp/src/components/App/Studies/StudyTree/utils.ts @@ -184,34 +184,3 @@ export async function fetchSubfolders(path: string): Promise { - const results = await Promise.allSettled(paths.map((path) => fetchSubfolders(path))); - return results.reduce<[StudyTreeNode, string[]]>( - ([tree, failed], result, index) => { - if (result.status === "fulfilled") { - return [insertFoldersIfNotExist(tree, result.value), failed]; - } - console.error("Failed to load path:", paths[index], result.reason); - return [tree, [...failed, paths[index]]]; - }, - [studiesTree, []], - ); -} From 22ace7b7c104c05f06a39cf4b0df147de261d24a Mon Sep 17 00:00:00 2001 From: Anis SMAIL Date: Tue, 18 Feb 2025 16:02:54 +0100 Subject: [PATCH 17/19] add timeout to _list_dir request --- webapp/src/services/api/study.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/webapp/src/services/api/study.ts b/webapp/src/services/api/study.ts index aba1fd2e44..6f52bb9b13 100644 --- a/webapp/src/services/api/study.ts +++ b/webapp/src/services/api/study.ts @@ -68,6 +68,9 @@ export const getWorkspaces = async () => { export const getFolders = async (workspace: string, folderPath: string) => { const res = await client.get( `/v1/private/explorer/${workspace}/_list_dir?path=${encodeURIComponent(folderPath)}`, + { + timeout: 1000 * 3, // Wait for 3 seconds + }, ); return res.data; }; From 378683614e70763c6ba16779b55b2a37f6cb6415 Mon Sep 17 00:00:00 2001 From: Anis SMAIL Date: Tue, 18 Feb 2025 16:04:18 +0100 Subject: [PATCH 18/19] remove useless messages --- webapp/public/locales/en/main.json | 1 - webapp/public/locales/fr/main.json | 1 - 2 files changed, 2 deletions(-) diff --git a/webapp/public/locales/en/main.json b/webapp/public/locales/en/main.json index 02e2a57afe..675caba656 100644 --- a/webapp/public/locales/en/main.json +++ b/webapp/public/locales/en/main.json @@ -679,7 +679,6 @@ "studies.exportOutputFilter": "Export filtered output", "studies.selectOutput": "Select an output", "studies.variant": "Variant", - "studies.tree.error.failToFetchWorkspace": "Failed to load workspaces", "studies.tree.error.failToFetchFolder": "Failed to load subfolders for {{path}}", "studies.tree.fetchFolderLoading": "Loading...", "variants.createNewVariant": "Create new variant", diff --git a/webapp/public/locales/fr/main.json b/webapp/public/locales/fr/main.json index 06f27aa593..443f6c36d5 100644 --- a/webapp/public/locales/fr/main.json +++ b/webapp/public/locales/fr/main.json @@ -678,7 +678,6 @@ "studies.exportOutput": "Exporter une sortie", "studies.exportOutputFilter": "Exporter une sortie filtrée", "studies.selectOutput": "Selectionnez une sortie", - "studies.tree.error.failToFetchWorkspace": "Échec lors de la récupération de l'espace de travail", "studies.tree.error.failToFetchFolder": "Échec lors de la récupération des sous dossiers de {{path}}", "studies.tree.fetchFolderLoading": "Chargement...", "variants.createNewVariant": "Créer une nouvelle variante", From 5dc11874d61dc10826a13f3fa04b579b1a3436d1 Mon Sep 17 00:00:00 2001 From: Anis SMAIL Date: Tue, 18 Feb 2025 16:10:14 +0100 Subject: [PATCH 19/19] update function doc --- webapp/src/components/App/Studies/StudyTree/index.tsx | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/webapp/src/components/App/Studies/StudyTree/index.tsx b/webapp/src/components/App/Studies/StudyTree/index.tsx index 099c602287..e00e9b79a1 100644 --- a/webapp/src/components/App/Studies/StudyTree/index.tsx +++ b/webapp/src/components/App/Studies/StudyTree/index.tsx @@ -44,7 +44,7 @@ function StudyTree() { }, [initialStudiesTree]); /** - * This function is called at the initialization of the component and when the user clicks on a folder. + * This function is called when the user clicks on a folder. * * The study tree is built from the studies in the database. There's a scan process that run on the server * to update continuously the studies in the database. @@ -55,9 +55,7 @@ function StudyTree() { * * To enable this, we'll fetch the subfolders of a folder when the user clicks on it using the explorer API. * - * @param itemId - The id of the item clicked - * @param rootNode - The root node of the tree - * @param selectedNode - The node of the item clicked + * @param itemId - The id of the item clicked, which is its path */ async function updateTree(itemId: string) { if (itemId.startsWith(DEFAULT_WORKSPACE_PREFIX)) {