Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix(study-tree-ui): fix folder loading #2325

Open
wants to merge 15 commits into
base: dev
Choose a base branch
from
10 changes: 7 additions & 3 deletions webapp/src/components/App/Studies/StudiesList/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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("/"));
Expand Down Expand Up @@ -265,14 +269,14 @@ function StudiesList(props: StudiesListProps) {
</Tooltip>
)}

{folder !== "root" && (
{!scanDisabled && (
<Tooltip title={t("studies.scanFolder") as string}>
<IconButton onClick={() => setConfirmFolderScan(true)}>
<IconButton onClick={() => setConfirmFolderScan(true)} disabled={scanDisabled}>
<RadarIcon />
</IconButton>
</Tooltip>
)}
{folder !== "root" && confirmFolderScan && (
{!isRootFolder && confirmFolderScan && (
<ConfirmationDialog
titleIcon={RadarIcon}
onCancel={() => {
Expand Down
31 changes: 14 additions & 17 deletions webapp/src/components/App/Studies/StudyTree/StudyTreeNode.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<TreeItemEnhanced
itemId={id}
label={studyTreeNode.name}
onClick={() => onNodeClick(id, studyTreeNode)}
>
<TreeItemEnhanced itemId={id} label={studyTreeNode.name}>
<TreeItemEnhanced itemId={id + "loading"} label={t("studies.tree.fetchFolderLoading")} />
</TreeItemEnhanced>
);
}

return (
<TreeItemEnhanced
itemId={id}
label={studyTreeNode.name}
onClick={() => onNodeClick(id, studyTreeNode)}
>
<TreeItemEnhanced itemId={id} label={studyTreeNode.name} onClick={() => onNodeClick(id)}>
{sortedChildren.map((child) => (
<StudyTreeNode
key={`${id}/${child.name}`}
studyTreeNode={child}
parentId={id}
itemsLoading={itemsLoading}
onNodeClick={onNodeClick}
/>
))}
</TreeItemEnhanced>
);
});
}
106 changes: 105 additions & 1 deletion webapp/src/components/App/Studies/StudyTree/__test__/utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down Expand Up @@ -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",
);
});
});
46 changes: 37 additions & 9 deletions webapp/src/components/App/Studies/StudyTree/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<string[]>([]);
const folder = useAppSelector((state) => getStudyFilters(state).folder, R.T);
const enqueueErrorSnackbar = useEnqueueErrorSnackbar();
const dispatch = useAppDispatch();
Expand All @@ -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]);

/**
Expand All @@ -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.
Expand All @@ -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) {
Expand All @@ -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(
Expand All @@ -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<Element, Event>,
itemId: string,
isExpanded: boolean,
) => {
if (isExpanded) {
updateTree(itemId, studiesTree);
}
};

const handleTreeItemClick = (itemId: string) => {
dispatch(updateStudyFilters({ folder: itemId }));
updateTree(itemId, studiesTree, studyTreeNode);
};

////////////////////////////////////////////////////////////////
Expand All @@ -133,10 +159,12 @@ function StudyTree() {
overflowY: "auto",
overflowX: "hidden",
}}
onItemExpansionToggle={handleItemExpansionToggle}
>
<StudyTreeNodeComponent
studyTreeNode={studiesTree}
parentId=""
itemsLoading={itemsLoading}
onNodeClick={handleTreeItemClick}
/>
</SimpleTreeView>
Expand Down
3 changes: 2 additions & 1 deletion webapp/src/components/App/Studies/StudyTree/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
44 changes: 44 additions & 0 deletions webapp/src/components/App/Studies/StudyTree/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Array<[StudyTreeNode, StudyTreeNode]>>((acc, leftNode) => {
const matchedRightNode = right.find((rightNode) => rightNode.name === leftNode.name);
if (matchedRightNode) {
acc.push([{ ...leftNode }, { ...matchedRightNode }]);
}
return acc;
}, []);
}
Loading
Loading