From 763f3707a969da706d3b5b004103a1676e3b02ba Mon Sep 17 00:00:00 2001 From: Anis Date: Wed, 18 Dec 2024 18:12:38 +0100 Subject: [PATCH] feat(ui-studies): add on click fetch and display list of non studies folder (#2224) There's a scan process that run on the server to update continuously the studies in the database. However this process can take a long time, and the user shouldn't wait for hours before he can see a study he knows is already uploaded. The scan button now by default run a non recursive scan, that way it takes less time. When the user clicks on a folder we fetch its subfolders using the explorer API. This enable the user to navigate to subfolders that aren't discovered by the scan yet, so subfolders that wouldn't be visible before hours if we rely only on the scan. By combining these two features, the user won't need to wait the full scan to complete, instead , he'll walk into the tree and run a fast scan process only subfolder he needs. This commit is mostly a front commit but also make small adjustment on the back. --- antarest/study/model.py | 17 +- antarest/study/storage/explorer_service.py | 6 +- antarest/study/web/explorer_blueprint.py | 6 +- .../explorer_blueprint/test_explorer.py | 6 +- .../storage/business/test_explorer_service.py | 12 +- webapp/public/locales/en/main.json | 5 + webapp/public/locales/fr/main.json | 6 +- webapp/src/components/App/Studies/SideNav.tsx | 2 +- .../App/Studies/StudiesList/index.tsx | 44 +++- .../src/components/App/Studies/StudyTree.tsx | 77 ------- .../Studies/StudyTree/__test__/fixtures.ts | 157 +++++++++++++ .../Studies/StudyTree/__test__/utils.test.tsx | 113 ++++++++++ .../App/Studies/StudyTree/index.tsx | 154 +++++++++++++ .../components/App/Studies/StudyTree/utils.ts | 208 ++++++++++++++++++ webapp/src/components/App/Studies/utils.ts | 7 + webapp/src/redux/ducks/studies.ts | 2 +- webapp/src/services/api/study.ts | 38 +++- 17 files changed, 750 insertions(+), 110 deletions(-) delete mode 100644 webapp/src/components/App/Studies/StudyTree.tsx create mode 100644 webapp/src/components/App/Studies/StudyTree/__test__/fixtures.ts create mode 100644 webapp/src/components/App/Studies/StudyTree/__test__/utils.test.tsx create mode 100644 webapp/src/components/App/Studies/StudyTree/index.tsx create mode 100644 webapp/src/components/App/Studies/StudyTree/utils.ts diff --git a/antarest/study/model.py b/antarest/study/model.py index b8378aa356..207662aeea 100644 --- a/antarest/study/model.py +++ b/antarest/study/model.py @@ -19,7 +19,7 @@ from pathlib import Path from antares.study.version import StudyVersion -from pydantic import BeforeValidator, PlainSerializer, field_validator +from pydantic import BeforeValidator, PlainSerializer, computed_field, field_validator from sqlalchemy import ( # type: ignore Boolean, Column, @@ -323,7 +323,7 @@ class StudyFolder: groups: t.List[Group] -class NonStudyFolder(AntaresBaseModel): +class NonStudyFolderDTO(AntaresBaseModel): """ DTO used by the explorer to list directories that aren't studies directory, this will be usefull for the front so the user can navigate in the hierarchy @@ -333,6 +333,19 @@ class NonStudyFolder(AntaresBaseModel): workspace: str name: str + @computed_field(alias="parentPath") + def parent_path(self) -> Path: + """ + This computed field is convenient for the front. + + This field is also aliased as parentPath to match the front-end naming convention. + + Returns: the parent path of the current directory. Starting with the workspace as a root directory (we want /workspafe/folder1/sub... and not workspace/folder1/fsub... ). + """ + workspace_path = Path(f"/{self.workspace}") + full_path = workspace_path.joinpath(self.path) + return full_path.parent + class WorkspaceMetadata(AntaresBaseModel): """ diff --git a/antarest/study/storage/explorer_service.py b/antarest/study/storage/explorer_service.py index 5610f3e5f8..fa9ef7fa30 100644 --- a/antarest/study/storage/explorer_service.py +++ b/antarest/study/storage/explorer_service.py @@ -14,7 +14,7 @@ from typing import List from antarest.core.config import Config -from antarest.study.model import DEFAULT_WORKSPACE_NAME, NonStudyFolder, WorkspaceMetadata +from antarest.study.model import DEFAULT_WORKSPACE_NAME, NonStudyFolderDTO, WorkspaceMetadata from antarest.study.storage.utils import ( get_folder_from_workspace, get_workspace_from_config, @@ -33,7 +33,7 @@ def list_dir( self, workspace_name: str, workspace_directory_path: str, - ) -> List[NonStudyFolder]: + ) -> List[NonStudyFolderDTO]: """ return a list of all directories under workspace_directory_path, that aren't studies. """ @@ -44,7 +44,7 @@ def list_dir( if child.is_dir() and not is_study_folder(child) and not should_ignore_folder_for_scan(child): # we don't want to expose the full absolute path on the server child_rel_path = child.relative_to(workspace.path) - directories.append(NonStudyFolder(path=child_rel_path, workspace=workspace_name, name=child.name)) + directories.append(NonStudyFolderDTO(path=child_rel_path, workspace=workspace_name, name=child.name)) return directories def list_workspaces( diff --git a/antarest/study/web/explorer_blueprint.py b/antarest/study/web/explorer_blueprint.py index 0981ba5214..b453cab787 100644 --- a/antarest/study/web/explorer_blueprint.py +++ b/antarest/study/web/explorer_blueprint.py @@ -18,7 +18,7 @@ from antarest.core.config import Config from antarest.core.jwt import JWTUser from antarest.login.auth import Auth -from antarest.study.model import NonStudyFolder, WorkspaceMetadata +from antarest.study.model import NonStudyFolderDTO, WorkspaceMetadata from antarest.study.storage.explorer_service import Explorer logger = logging.getLogger(__name__) @@ -40,13 +40,13 @@ def create_explorer_routes(config: Config, explorer: Explorer) -> APIRouter: @bp.get( "/explorer/{workspace}/_list_dir", summary="For a given directory, list sub directories that aren't studies", - response_model=List[NonStudyFolder], + response_model=List[NonStudyFolderDTO], ) def list_dir( workspace: str, path: str, current_user: JWTUser = Depends(auth.get_current_user), - ) -> List[NonStudyFolder]: + ) -> List[NonStudyFolderDTO]: """ Endpoint to list sub directories of a given directory Args: diff --git a/tests/integration/explorer_blueprint/test_explorer.py b/tests/integration/explorer_blueprint/test_explorer.py index dbb6f83ebc..31990b1781 100644 --- a/tests/integration/explorer_blueprint/test_explorer.py +++ b/tests/integration/explorer_blueprint/test_explorer.py @@ -14,7 +14,7 @@ import pytest from starlette.testclient import TestClient -from antarest.study.model import NonStudyFolder, WorkspaceMetadata +from antarest.study.model import NonStudyFolderDTO, WorkspaceMetadata BAD_REQUEST_STATUS_CODE = 400 # Status code for directory listing with invalid parameters @@ -65,9 +65,9 @@ def test_explorer(client: TestClient, admin_access_token: str, study_tree: Path) ) res.raise_for_status() directories_res = res.json() - directories_res = [NonStudyFolder(**d) for d in directories_res] + directories_res = [NonStudyFolderDTO(**d) for d in directories_res] directorires_expected = [ - NonStudyFolder( + NonStudyFolderDTO( path=Path("folder/trash"), workspace="ext", name="trash", diff --git a/tests/storage/business/test_explorer_service.py b/tests/storage/business/test_explorer_service.py index 883e79cfca..37a7c0c033 100644 --- a/tests/storage/business/test_explorer_service.py +++ b/tests/storage/business/test_explorer_service.py @@ -15,7 +15,7 @@ import pytest from antarest.core.config import Config, StorageConfig, WorkspaceConfig -from antarest.study.model import DEFAULT_WORKSPACE_NAME, NonStudyFolder, WorkspaceMetadata +from antarest.study.model import DEFAULT_WORKSPACE_NAME, NonStudyFolderDTO, WorkspaceMetadata from antarest.study.storage.explorer_service import Explorer @@ -85,8 +85,7 @@ def test_list_dir_empty_string(config_scenario_a: Config): result = explorer.list_dir("diese", "") assert len(result) == 1 - workspace_path = config_scenario_a.get_workspace_path(workspace="diese") - assert result[0] == NonStudyFolder(path=Path("folder"), workspace="diese", name="folder") + assert result[0] == NonStudyFolderDTO(path=Path("folder"), workspace="diese", name="folder") @pytest.mark.unit_test @@ -95,11 +94,10 @@ def test_list_dir_several_subfolders(config_scenario_a: Config): result = explorer.list_dir("diese", "folder") assert len(result) == 3 - workspace_path = config_scenario_a.get_workspace_path(workspace="diese") folder_path = Path("folder") - assert NonStudyFolder(path=(folder_path / "subfolder1"), workspace="diese", name="subfolder1") in result - assert NonStudyFolder(path=(folder_path / "subfolder2"), workspace="diese", name="subfolder2") in result - assert NonStudyFolder(path=(folder_path / "subfolder3"), workspace="diese", name="subfolder3") in result + assert NonStudyFolderDTO(path=(folder_path / "subfolder1"), workspace="diese", name="subfolder1") in result + assert NonStudyFolderDTO(path=(folder_path / "subfolder2"), workspace="diese", name="subfolder2") in result + assert NonStudyFolderDTO(path=(folder_path / "subfolder3"), workspace="diese", name="subfolder3") in result @pytest.mark.unit_test diff --git a/webapp/public/locales/en/main.json b/webapp/public/locales/en/main.json index 250a75f2e6..1f13b6e551 100644 --- a/webapp/public/locales/en/main.json +++ b/webapp/public/locales/en/main.json @@ -642,7 +642,9 @@ "studies.studylaunched": "{{studyname}} launched!", "studies.copySuffix": "Copy", "studies.filters.strictfolder": "Show only direct folder children", + "studies.filters.showAllDescendants": "Show all children", "studies.scanFolder": "Scan folder", + "studies.requestDeepScan": "Recursive scan", "studies.moveStudy": "Move", "studies.movefolderplaceholder": "Path separated by '/'", "studies.importcopy": "Copy to database", @@ -674,6 +676,9 @@ "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.error.detailsInConsole": "Details logged in the console", "variants.createNewVariant": "Create new variant", "variants.newVariant": "New variant", "variants.newCommand": "Add new command", diff --git a/webapp/public/locales/fr/main.json b/webapp/public/locales/fr/main.json index 98d6ee71b9..14dbcda923 100644 --- a/webapp/public/locales/fr/main.json +++ b/webapp/public/locales/fr/main.json @@ -642,7 +642,9 @@ "studies.studylaunched": "{{studyname}} lancé(s) !", "studies.copySuffix": "Copie", "studies.filters.strictfolder": "Afficher uniquement les descendants directs", + "studies.filters.showAllDescendants": "Voir les sous-dossiers", "studies.scanFolder": "Scanner le dossier", + "studies.requestDeepScan": "Scan récursif", "studies.moveStudy": "Déplacer", "studies.movefolderplaceholder": "Chemin séparé par des '/'", "studies.importcopy": "Copier en base", @@ -673,7 +675,9 @@ "studies.exportOutput": "Exporter une sortie", "studies.exportOutputFilter": "Exporter une sortie filtrée", "studies.selectOutput": "Selectionnez une sortie", - "studies.variant": "Variante", + "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.error.detailsInConsole": "Détails de l'érreur dans la console", "variants.createNewVariant": "Créer une nouvelle variante", "variants.newVariant": "Nouvelle variante", "variants.newCommand": "Ajouter une nouvelle commande", diff --git a/webapp/src/components/App/Studies/SideNav.tsx b/webapp/src/components/App/Studies/SideNav.tsx index c0009ce7d2..b966f27fd6 100644 --- a/webapp/src/components/App/Studies/SideNav.tsx +++ b/webapp/src/components/App/Studies/SideNav.tsx @@ -16,7 +16,7 @@ import { useNavigate } from "react-router"; import { Box, Typography, List, ListItem, ListItemText } from "@mui/material"; import { useTranslation } from "react-i18next"; import { STUDIES_SIDE_NAV_WIDTH } from "../../../theme"; -import StudyTree from "./StudyTree"; +import StudyTree from "@/components/App/Studies/StudyTree"; import useAppSelector from "../../../redux/hooks/useAppSelector"; import { getFavoriteStudies } from "../../../redux/selectors"; diff --git a/webapp/src/components/App/Studies/StudiesList/index.tsx b/webapp/src/components/App/Studies/StudiesList/index.tsx index 6d90785471..b8b32f1646 100644 --- a/webapp/src/components/App/Studies/StudiesList/index.tsx +++ b/webapp/src/components/App/Studies/StudiesList/index.tsx @@ -33,7 +33,8 @@ import AutoSizer from "react-virtualized-auto-sizer"; import HomeIcon from "@mui/icons-material/Home"; import ArrowUpwardIcon from "@mui/icons-material/ArrowUpward"; import ArrowDownwardIcon from "@mui/icons-material/ArrowDownward"; -import FolderOffIcon from "@mui/icons-material/FolderOff"; +import FolderIcon from "@mui/icons-material/Folder"; +import AccountTreeIcon from "@mui/icons-material/AccountTree"; import RadarIcon from "@mui/icons-material/Radar"; import { FixedSizeGrid, GridOnScrollProps } from "react-window"; import { v4 as uuidv4 } from "uuid"; @@ -61,6 +62,7 @@ import RefreshButton from "../RefreshButton"; import { scanFolder } from "../../../../services/api/study"; import useEnqueueErrorSnackbar from "../../../../hooks/useEnqueueErrorSnackbar"; import ConfirmationDialog from "../../../common/dialogs/ConfirmationDialog"; +import CheckBoxFE from "@/components/common/fieldEditors/CheckBoxFE"; const CARD_TARGET_WIDTH = 500; const CARD_HEIGHT = 250; @@ -88,6 +90,7 @@ function StudiesList(props: StudiesListProps) { const [selectedStudies, setSelectedStudies] = useState([]); const [selectionMode, setSelectionMode] = useState(false); const [confirmFolderScan, setConfirmFolderScan] = useState(false); + const [isRecursiveScan, setIsRecursiveScan] = useState(false); useEffect(() => { setFolderList(folder.split("/")); @@ -156,13 +159,18 @@ function StudiesList(props: StudiesListProps) { try { // Remove "/root" from the path const folder = folderList.slice(1).join("/"); - await scanFolder(folder); + await scanFolder(folder, isRecursiveScan); setConfirmFolderScan(false); + setIsRecursiveScan(false); } catch (e) { enqueueErrorSnackbar(t("studies.error.scanFolder"), e as AxiosError); } }; + const handleRecursiveScan = () => { + setIsRecursiveScan(!isRecursiveScan); + }; + //////////////////////////////////////////////////////////////// // Utils //////////////////////////////////////////////////////////////// @@ -249,13 +257,21 @@ function StudiesList(props: StudiesListProps) { ({`${studyIds.length} ${t("global.studies").toLowerCase()}`}) - - - - - + + {strictFolderFilter ? ( + + + + + + ) : ( + + + + + + )} + {folder !== "root" && ( setConfirmFolderScan(true)}> @@ -266,12 +282,20 @@ function StudiesList(props: StudiesListProps) { {folder !== "root" && confirmFolderScan && ( setConfirmFolderScan(false)} + onCancel={() => { + setConfirmFolderScan(false); + setIsRecursiveScan(false); + }} onConfirm={handleFolderScan} alert="warning" open > {`${t("studies.scanFolder")} ${folder}?`} + )} diff --git a/webapp/src/components/App/Studies/StudyTree.tsx b/webapp/src/components/App/Studies/StudyTree.tsx deleted file mode 100644 index 7208caaec4..0000000000 --- a/webapp/src/components/App/Studies/StudyTree.tsx +++ /dev/null @@ -1,77 +0,0 @@ -/** - * Copyright (c) 2024, RTE (https://www.rte-france.com) - * - * See AUTHORS.txt - * - * This Source Code Form is subject to the terms of the Mozilla Public - * License, v. 2.0. If a copy of the MPL was not distributed with this - * file, You can obtain one at http://mozilla.org/MPL/2.0/. - * - * SPDX-License-Identifier: MPL-2.0 - * - * This file is part of the Antares project. - */ - -import { StudyTreeNode } from "./utils"; -import useAppSelector from "../../../redux/hooks/useAppSelector"; -import { getStudiesTree, getStudyFilters } from "../../../redux/selectors"; -import useAppDispatch from "../../../redux/hooks/useAppDispatch"; -import { updateStudyFilters } from "../../../redux/ducks/studies"; -import TreeItemEnhanced from "../../common/TreeItemEnhanced"; -import { SimpleTreeView } from "@mui/x-tree-view/SimpleTreeView"; -import { getParentPaths } from "../../../utils/pathUtils"; -import * as R from "ramda"; - -function StudyTree() { - const folder = useAppSelector((state) => getStudyFilters(state).folder, R.T); - const studiesTree = useAppSelector(getStudiesTree); - const dispatch = useAppDispatch(); - - //////////////////////////////////////////////////////////////// - // Event Handlers - //////////////////////////////////////////////////////////////// - - const handleTreeItemClick = (itemId: string) => { - dispatch(updateStudyFilters({ folder: itemId })); - }; - - //////////////////////////////////////////////////////////////// - // JSX - //////////////////////////////////////////////////////////////// - - const buildTree = (children: StudyTreeNode[], parentId?: string) => { - return children.map((child) => { - const id = parentId ? `${parentId}/${child.name}` : child.name; - - return ( - handleTreeItemClick(id)} - > - {buildTree(child.children, id)} - - ); - }); - }; - - return ( - - {buildTree([studiesTree])} - - ); -} - -export default StudyTree; diff --git a/webapp/src/components/App/Studies/StudyTree/__test__/fixtures.ts b/webapp/src/components/App/Studies/StudyTree/__test__/fixtures.ts new file mode 100644 index 0000000000..a6a4dbc3ad --- /dev/null +++ b/webapp/src/components/App/Studies/StudyTree/__test__/fixtures.ts @@ -0,0 +1,157 @@ +/** + * Copyright (c) 2024, RTE (https://www.rte-france.com) + * + * See AUTHORS.txt + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + * + * SPDX-License-Identifier: MPL-2.0 + * + * This file is part of the Antares project. + */ + +export const FIXTURES = { + basicTree: { + name: "Basic tree with single level", + studyTree: { + name: "Root", + path: "/", + children: [ + { name: "a", path: "/a", children: [] }, + { name: "b", path: "/b", children: [] }, + ], + }, + folders: [ + { + name: "folder1", + path: "folder1", + workspace: "a", + parentPath: "/a", + }, + ], + expected: { + name: "Root", + path: "/", + children: [ + { + name: "a", + path: "/a", + children: [{ name: "folder1", path: "/a/folder1", children: [] }], + }, + { name: "b", path: "/b", children: [] }, + ], + }, + }, + nestedTree: { + name: "Nested tree structure", + studyTree: { + name: "Root", + path: "/", + children: [ + { + name: "a", + path: "/a", + children: [{ name: "suba", path: "/a/suba", children: [] }], + }, + ], + }, + folders: [ + { + name: "folder1", + path: "suba/folder1", + workspace: "a", + parentPath: "/a/suba", + }, + ], + expected: { + name: "Root", + path: "/", + children: [ + { + name: "a", + path: "/a", + children: [ + { + name: "suba", + path: "/a/suba", + children: [ + { name: "folder1", path: "/a/suba/folder1", children: [] }, + ], + }, + ], + }, + ], + }, + }, + duplicateCase: { + name: "Tree with potential duplicates", + studyTree: { + name: "Root", + path: "/", + children: [ + { + name: "a", + path: "/a", + children: [{ name: "folder1", path: "/a/folder1", children: [] }], + }, + ], + }, + folders: [ + { + name: "folder1", + path: "/folder1", + workspace: "a", + parentPath: "/a", + }, + ], + expected: { + name: "Root", + path: "/", + children: [ + { + name: "a", + path: "/a", + children: [{ name: "folder1", path: "/a/folder1", children: [] }], + }, + ], + }, + }, + multipleFolders: { + name: "Multiple folders merge", + studyTree: { + name: "Root", + path: "/", + children: [{ name: "a", path: "/a", children: [] }], + }, + folders: [ + { + name: "folder1", + path: "/folder1", + workspace: "a", + parentPath: "/a", + }, + { + name: "folder2", + path: "/folder2", + workspace: "a", + parentPath: "/a", + }, + ], + expected: { + name: "Root", + path: "/", + children: [ + { + name: "a", + path: "/a", + children: [ + { name: "folder1", path: "/a/folder1", children: [] }, + { name: "folder2", path: "/a/folder2", children: [] }, + ], + }, + ], + }, + }, +}; diff --git a/webapp/src/components/App/Studies/StudyTree/__test__/utils.test.tsx b/webapp/src/components/App/Studies/StudyTree/__test__/utils.test.tsx new file mode 100644 index 0000000000..87a5419c54 --- /dev/null +++ b/webapp/src/components/App/Studies/StudyTree/__test__/utils.test.tsx @@ -0,0 +1,113 @@ +/** + * Copyright (c) 2024, RTE (https://www.rte-france.com) + * + * See AUTHORS.txt + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + * + * SPDX-License-Identifier: MPL-2.0 + * + * This file is part of the Antares project. + */ + +import { insertFoldersIfNotExist, insertWorkspacesIfNotExist } from "../utils"; +import { NonStudyFolderDTO, StudyTreeNode } from "../../utils"; +import { FIXTURES } from "./fixtures"; + +describe("StudyTree Utils", () => { + describe("mergeStudyTreeAndFolders", () => { + test.each(Object.values(FIXTURES))( + "$name", + ({ studyTree, folders, expected }) => { + const result = insertFoldersIfNotExist(studyTree, folders); + expect(result).toEqual(expected); + }, + ); + + test("should handle empty study tree", () => { + const emptyTree: StudyTreeNode = { + name: "Root", + path: "/", + children: [], + }; + const result = insertFoldersIfNotExist(emptyTree, []); + expect(result).toEqual(emptyTree); + }); + + test("should handle empty folders array", () => { + const tree: StudyTreeNode = { + name: "Root", + path: "/", + children: [{ name: "a", path: "/a", children: [] }], + }; + const result = insertFoldersIfNotExist(tree, []); + expect(result).toEqual(tree); + }); + + test("should handle invalid parent paths", () => { + const tree: StudyTreeNode = { + name: "Root", + path: "/", + children: [{ name: "a", path: "/a", children: [] }], + }; + const invalidFolder: NonStudyFolderDTO = { + name: "invalid", + path: "/invalid", + workspace: "nonexistent", + parentPath: "/nonexistent", + }; + 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: string[] = ["a", "workspace1", "workspace2"]; + const result = insertWorkspacesIfNotExist(tree, workspaces); + expect(result).toEqual(expected); + }); + }); +}); diff --git a/webapp/src/components/App/Studies/StudyTree/index.tsx b/webapp/src/components/App/Studies/StudyTree/index.tsx new file mode 100644 index 0000000000..510d70134e --- /dev/null +++ b/webapp/src/components/App/Studies/StudyTree/index.tsx @@ -0,0 +1,154 @@ +/** + * Copyright (c) 2024, RTE (https://www.rte-france.com) + * + * See AUTHORS.txt + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + * + * SPDX-License-Identifier: MPL-2.0 + * + * This file is part of the Antares project. + */ + +import { StudyTreeNode } from ".././utils"; +import useAppSelector from "../../../../redux/hooks/useAppSelector"; +import { getStudiesTree, getStudyFilters } from "../../../../redux/selectors"; +import useAppDispatch from "../../../../redux/hooks/useAppDispatch"; +import { updateStudyFilters } from "../../../../redux/ducks/studies"; +import TreeItemEnhanced from "../../../common/TreeItemEnhanced"; +import { SimpleTreeView } from "@mui/x-tree-view/SimpleTreeView"; +import { getParentPaths } from "../../../../utils/pathUtils"; +import * as R from "ramda"; +import { useState } from "react"; +import useEnqueueErrorSnackbar from "@/hooks/useEnqueueErrorSnackbar"; +import useUpdateEffectOnce from "@/hooks/useUpdateEffectOnce"; +import { fetchAndInsertSubfolders, fetchAndInsertWorkspaces } from "./utils"; +import { useTranslation } from "react-i18next"; +import { toError } from "@/utils/fnUtils"; + +function StudyTree() { + const initialStudiesTree = useAppSelector(getStudiesTree); + const [studiesTree, setStudiesTree] = useState(initialStudiesTree); + 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(() => { + updateTree("root", initialStudiesTree); + }, [initialStudiesTree]); + + /** + * This function is called at the initialization of the component and 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. + * + * However this process can take a long time, and the user shouldn't wait for hours before he can see a study he knows is already uploaded. + * + * Instead of relying on the scan process to update the tree, we'll allow the user to walk into the tree and run a scan process only when he needs to. + * + * 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 studyTreeNode - The node of the item clicked + */ + async function updateTree(itemId: string, studyTreeNode: StudyTreeNode) { + let treeAfterWorkspacesUpdate = studiesTree; + let chidrenPaths = studyTreeNode.children.map( + (child) => `root${child.path}`, + ); + // 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") { + try { + treeAfterWorkspacesUpdate = await fetchAndInsertWorkspaces(studiesTree); + chidrenPaths = treeAfterWorkspacesUpdate.children.map( + (child) => `root${child.path}`, + ); + } catch (error) { + enqueueErrorSnackbar( + "studies.tree.error.failToFetchWorkspace", + toError(error), + ); + } + } else { + // If the user clicks on a folder, we add the path of the clicked folder to the list of paths to fetch. + // as well as the path of the children of the clicked folder. + // If we don't fetch the subfolders of the children then we won't know if they're themselves folders, which we need + // to know to display the little arrow next to the subfolder. + // On the other hand, if we fetch only the subfolders of the children, then we won't fetch their "siblings" folder + // if one of them is added. + chidrenPaths = [studyTreeNode.path].concat(chidrenPaths); + } + + const [treeAfterChildrenUpdate, failedPath] = + await fetchAndInsertSubfolders(chidrenPaths, treeAfterWorkspacesUpdate); + if (failedPath.length > 0) { + enqueueErrorSnackbar( + t("studies.tree.error.failToFetchFolder", { + path: failedPath.join(" "), + interpolation: { escapeValue: false }, + }), + t("studies.tree.error.detailsInConsole"), + ); + } + setStudiesTree(treeAfterChildrenUpdate); + } + + //////////////////////////////////////////////////////////////// + // Event Handlers + //////////////////////////////////////////////////////////////// + + const handleTreeItemClick = async ( + itemId: string, + studyTreeNode: StudyTreeNode, + ) => { + dispatch(updateStudyFilters({ folder: itemId })); + updateTree(itemId, studyTreeNode); + }; + + //////////////////////////////////////////////////////////////// + // JSX + //////////////////////////////////////////////////////////////// + + const buildTree = (children: StudyTreeNode[], parentId?: string) => { + return children.map((child) => { + const id = parentId ? `${parentId}/${child.name}` : child.name; + + return ( + handleTreeItemClick(id, child)} + > + {buildTree(child.children, id)} + + ); + }); + }; + + return ( + + {buildTree([studiesTree])} + + ); +} + +export default StudyTree; diff --git a/webapp/src/components/App/Studies/StudyTree/utils.ts b/webapp/src/components/App/Studies/StudyTree/utils.ts new file mode 100644 index 0000000000..14c4a5b008 --- /dev/null +++ b/webapp/src/components/App/Studies/StudyTree/utils.ts @@ -0,0 +1,208 @@ +/** + * Copyright (c) 2024, RTE (https://www.rte-france.com) + * + * See AUTHORS.txt + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + * + * SPDX-License-Identifier: MPL-2.0 + * + * This file is part of the Antares project. + */ + +import { NonStudyFolderDTO, StudyTreeNode } from "../utils"; +import * as api from "../../../../services/api/study"; + +/** + * Add a folder that was returned by the explorer into the study tree view. + * + * This function doesn't mutate the tree, it returns a new tree with the folder inserted. + * + * If the folder is already in the tree, the tree returnred will be equal to the tree given to the function. + * + * @param studiesTree study tree to insert the folder into + * @param folder folder to inert into the tree + * @returns study tree with the folder inserted if it wasn't already there. + * New branch is created if it contain the folder otherwise the branch is left unchanged. + */ +function insertFolderIfNotExist( + studiesTree: StudyTreeNode, + folder: NonStudyFolderDTO, +): StudyTreeNode { + // Early return if folder doesn't belong in this branch + if (!folder.parentPath.startsWith(studiesTree.path)) { + return studiesTree; + } + + // direct child case + if (folder.parentPath == studiesTree.path) { + const folderExists = studiesTree.children.some( + (child) => child.name === folder.name, + ); + if (folderExists) { + return studiesTree; + } + // parent path is the same, but no folder with the same name at this level + return { + ...studiesTree, + children: [ + ...studiesTree.children, + { + path: `${folder.parentPath}/${folder.name}`, + name: folder.name, + children: [], + }, + ], + }; + } + + // not a direct child, but does belong to this branch so recursively walk though the tree + return { + ...studiesTree, + children: studiesTree.children.map((child) => + insertFolderIfNotExist(child, folder), + ), + }; +} + +/** + * Insert several folders in 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 folders inserted + * + * The folders are inserted in the order they are given. + * + * @param studiesTree study tree to insert the folder into + * @param folders folders to inert into the tree + * @param studiesTree study tree to insert the folder into + * @param folder folder to inert into the tree + * @returns study tree with the folder inserted if it wasn't already there. + * New branch is created if it contain the folder otherwise the branch is left unchanged. + */ +export function insertFoldersIfNotExist( + studiesTree: StudyTreeNode, + folders: NonStudyFolderDTO[], +): StudyTreeNode { + return folders.reduce( + (tree, folder) => insertFolderIfNotExist(tree, folder), + studiesTree, + ); +} + +/** + * Call the explorer api to fetch the subfolders under the given path. + * + * @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 { + if (path === "root") { + // Under root there're workspaces not subfolders + return []; + } + // less than 2 parts means we're at the root level + const pathParts = path.split("/"); + if (pathParts.length < 2) { + return []; + } + // path parts should be ["root", workspace, "folder1", ...] + const workspace = pathParts[1]; + const subPath = pathParts.slice(2).join("/"); + return api.getFolders(workspace, subPath); +} + +/** + * Fetch and insert the subfolders under the given paths into the study tree. + * + * This function is used to fill the study tree when the user clicks on a folder. + * + * Subfolders 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 subfolders inserted + * + * @param paths list of paths to fetch the subfolders for + * @param studiesTree study tree to insert the subfolders into + * @returns a tuple with study tree with the subfolders inserted if they weren't already there and path for which + * the fetch failed. + */ +export async function fetchAndInsertSubfolders( + paths: string[], + studiesTree: StudyTreeNode, +): Promise<[StudyTreeNode, string[]]> { + 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, []], + ); +} + +/** + * 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, +) { + 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) => { + return 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); +} diff --git a/webapp/src/components/App/Studies/utils.ts b/webapp/src/components/App/Studies/utils.ts index 3f2ff61564..8117450702 100644 --- a/webapp/src/components/App/Studies/utils.ts +++ b/webapp/src/components/App/Studies/utils.ts @@ -20,6 +20,13 @@ export interface StudyTreeNode { children: StudyTreeNode[]; } +export interface NonStudyFolderDTO { + name: string; + path: string; + workspace: string; + parentPath: string; +} + /** * Builds a tree structure from a list of study metadata. * diff --git a/webapp/src/redux/ducks/studies.ts b/webapp/src/redux/ducks/studies.ts index 3b3e8b04d2..9b4603a23f 100644 --- a/webapp/src/redux/ducks/studies.ts +++ b/webapp/src/redux/ducks/studies.ts @@ -94,7 +94,7 @@ const initialState = studiesAdapter.getInitialState({ filters: { inputValue: "", folder: "root", - strictFolder: false, + strictFolder: true, managed: false, archived: false, variant: false, diff --git a/webapp/src/services/api/study.ts b/webapp/src/services/api/study.ts index 53d4a67925..349134d790 100644 --- a/webapp/src/services/api/study.ts +++ b/webapp/src/services/api/study.ts @@ -34,6 +34,11 @@ import { getConfig } from "../config"; import { convertStudyDtoToMetadata } from "../utils"; import { FileDownloadTask } from "./downloads"; import { StudyMapDistrict } from "../../redux/ducks/studyMaps"; +import { NonStudyFolderDTO } from "@/components/App/Studies/utils"; + +interface Workspace { + name: string; +} const getStudiesRaw = async (): Promise> => { const res = await client.get(`/v1/studies`); @@ -48,6 +53,30 @@ export const getStudies = async (): Promise => { }); }; +export const getWorkspaces = async (): Promise => { + const res = await client.get( + `/v1/private/explorer/_list_workspaces`, + ); + return res.data.map((folder) => folder.name); +}; + +/** + * Call the explorer API to get the list of folders in a workspace + * + * @param workspace - workspace name + * @param folderPath - path starting from the workspace root (not including the workspace name) + * @returns list of folders that are not studies, under the given path + */ +export const getFolders = async ( + workspace: string, + folderPath: string, +): Promise => { + const res = await client.get( + `/v1/private/explorer/${workspace}/_list_dir?path=${encodeURIComponent(folderPath)}`, + ); + return res.data; +}; + export const getStudyVersions = async (): Promise => { const res = await client.get("/v1/studies/_versions"); return res.data; @@ -434,8 +463,13 @@ export const updateStudyMetadata = async ( return res.data; }; -export const scanFolder = async (folderPath: string): Promise => { - await client.post(`/v1/watcher/_scan?path=${encodeURIComponent(folderPath)}`); +export const scanFolder = async ( + folderPath: string, + recursive = false, +): Promise => { + await client.post( + `/v1/watcher/_scan?path=${encodeURIComponent(folderPath)}&recursive=${recursive}`, + ); }; export const getStudyLayers = async (uuid: string): Promise => {