From d1e6b3b92d8c889bb2f2bf1052b0c0dd3a7ec0f7 Mon Sep 17 00:00:00 2001 From: Anis SMAIL Date: Thu, 30 Jan 2025 13:28:16 +0100 Subject: [PATCH 01/14] 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/14] 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/14] 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/14] 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/14] 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/14] 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/14] 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/14] 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/14] 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/14] 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/14] 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/14] 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/14] 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/14] 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";