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