From 3385369df766b1211ced39710586f5deaaabc8d6 Mon Sep 17 00:00:00 2001 From: Anis Date: Fri, 17 Jan 2025 14:05:04 +0100 Subject: [PATCH 1/9] feat(ui-api, studies): optimize studies listing (#2288) Co-authored-by: Anis SMAIL Co-authored-by: maugde <167874615+maugde@users.noreply.github.com> Co-authored-by: MartinBelthle Co-authored-by: Sylvain Leclerc --- antarest/study/model.py | 22 +- antarest/study/storage/explorer_service.py | 39 ++- antarest/study/storage/utils.py | 12 + antarest/study/web/explorer_blueprint.py | 6 +- .../explorer_blueprint/test_explorer.py | 10 +- .../storage/business/test_explorer_service.py | 19 +- 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 | 46 ++- .../src/components/App/Studies/StudyTree.tsx | 77 ----- .../App/Studies/StudyTree/StudyTreeNode.tsx | 62 ++++ .../Studies/StudyTree/__test__/fixtures.ts | 322 ++++++++++++++++++ .../Studies/StudyTree/__test__/utils.test.ts | 125 +++++++ .../App/Studies/StudyTree/index.tsx | 154 +++++++++ .../components/App/Studies/StudyTree/types.ts | 34 ++ .../components/App/Studies/StudyTree/utils.ts | 281 +++++++++++++++ webapp/src/components/App/Studies/utils.ts | 64 ---- webapp/src/redux/ducks/studies.ts | 2 +- webapp/src/redux/selectors.ts | 6 +- webapp/src/services/api/study.ts | 32 +- 21 files changed, 1135 insertions(+), 191 deletions(-) delete mode 100644 webapp/src/components/App/Studies/StudyTree.tsx create mode 100644 webapp/src/components/App/Studies/StudyTree/StudyTreeNode.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.ts create mode 100644 webapp/src/components/App/Studies/StudyTree/index.tsx create mode 100644 webapp/src/components/App/Studies/StudyTree/types.ts create mode 100644 webapp/src/components/App/Studies/StudyTree/utils.ts delete mode 100644 webapp/src/components/App/Studies/utils.ts diff --git a/antarest/study/model.py b/antarest/study/model.py index cc8792ee99..d2ff8b97b9 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, ConfigDict, Field, PlainSerializer, computed_field, field_validator from sqlalchemy import ( # type: ignore Boolean, Column, @@ -334,7 +334,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 @@ -343,6 +343,24 @@ class NonStudyFolder(AntaresBaseModel): path: Path workspace: str name: str + has_children: bool = Field( + alias="hasChildren", + ) # true when has at least one non-study-folder children + + model_config = ConfigDict(populate_by_name=True) + + @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 30ff6ffcb6..d312f84552 100644 --- a/antarest/study/storage/explorer_service.py +++ b/antarest/study/storage/explorer_service.py @@ -14,12 +14,12 @@ 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, - is_study_folder, - should_ignore_folder_for_scan, + has_non_study_folder, + is_non_study_folder, ) logger = logging.getLogger(__name__) @@ -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. """ @@ -41,18 +41,27 @@ def list_dir( directory_path = get_folder_from_workspace(workspace, workspace_directory_path) directories = [] try: + # this block is skipped in case of permission error children = list(directory_path.iterdir()) - except PermissionError: - children = [] # we don't want to try to read folders we can't access - for child in children: - if ( - child.is_dir() - and not is_study_folder(child) - and not should_ignore_folder_for_scan(child, workspace.filter_in, workspace.filter_out) - ): - # 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)) + for child in children: + # if we can't access one child we skip it + try: + if is_non_study_folder(child, workspace.filter_in, workspace.filter_out): + # we don't want to expose the full absolute path on the server + child_rel_path = child.relative_to(workspace.path) + has_children = has_non_study_folder(child, workspace.filter_in, workspace.filter_out) + directories.append( + NonStudyFolderDTO( + path=child_rel_path, + workspace=workspace_name, + name=child.name, + has_children=has_children, + ) + ) + except PermissionError as e: + logger.warning(f"Permission error while accessing {child} or one of its children: {e}") + except PermissionError as e: + logger.warning(f"Permission error while listing {directory_path}: {e}") return directories def list_workspaces( diff --git a/antarest/study/storage/utils.py b/antarest/study/storage/utils.py index 639f9a6495..e8336cc1d1 100644 --- a/antarest/study/storage/utils.py +++ b/antarest/study/storage/utils.py @@ -495,3 +495,15 @@ def should_ignore_folder_for_scan(path: Path, filter_in: t.List[str], filter_out and any(re.search(regex, path.name) for regex in filter_in) and not any(re.search(regex, path.name) for regex in filter_out) ) + + +def has_non_study_folder(path: Path, filter_in: t.List[str], filter_out: t.List[str]) -> bool: + return any(is_non_study_folder(sub_path, filter_in, filter_out) for sub_path in path.iterdir()) + + +def is_non_study_folder(path: Path, filter_in: t.List[str], filter_out: t.List[str]) -> bool: + if is_study_folder(path): + return False + if should_ignore_folder_for_scan(path, filter_in, filter_out): + return False + return True diff --git a/antarest/study/web/explorer_blueprint.py b/antarest/study/web/explorer_blueprint.py index 2c8065fbd2..5b714aff91 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 7463602cff..70f554cbf8 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,13 +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( - path=Path("folder/trash"), - workspace="ext", - name="trash", - ) + NonStudyFolderDTO(path=Path("folder/trash"), workspace="ext", name="trash", hasChildren=False) ] assert directories_res == directorires_expected diff --git a/tests/storage/business/test_explorer_service.py b/tests/storage/business/test_explorer_service.py index fb150aad1c..60f5e20f1d 100644 --- a/tests/storage/business/test_explorer_service.py +++ b/tests/storage/business/test_explorer_service.py @@ -16,7 +16,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 @@ -87,7 +87,7 @@ def test_list_dir_empty_string(config_scenario_a: Config): # We don't want to see the .git folder or the $RECYCLE.BIN as they were ignored in the workspace config assert len(result) == 1 - assert result[0] == NonStudyFolder(path=Path("folder"), workspace="diese", name="folder") + assert result[0] == NonStudyFolderDTO(path=Path("folder"), workspace="diese", name="folder", has_children=True) @pytest.mark.unit_test @@ -97,9 +97,18 @@ def test_list_dir_several_subfolders(config_scenario_a: Config): assert len(result) == 3 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", has_children=False) + in result + ) + assert ( + NonStudyFolderDTO(path=(folder_path / "subfolder2"), workspace="diese", name="subfolder2", has_children=False) + in result + ) + assert ( + NonStudyFolderDTO(path=(folder_path / "subfolder3"), workspace="diese", name="subfolder3", has_children=False) + in result + ) @pytest.mark.unit_test diff --git a/webapp/public/locales/en/main.json b/webapp/public/locales/en/main.json index 7c6a13307a..0368e65fcc 100644 --- a/webapp/public/locales/en/main.json +++ b/webapp/public/locales/en/main.json @@ -643,7 +643,9 @@ "studies.studylaunched": "{{studyname}} launched!", "studies.copySuffix": "Copy", "studies.filters.strictfolder": "Show only direct folder children", + "studies.filters.showChildrens": "Show all children", "studies.scanFolder": "Scan folder", + "studies.recursiveScan": "Recursive scan", "studies.moveStudy": "Move", "studies.movefolderplaceholder": "Path separated by '/'", "studies.importcopy": "Copy to database", @@ -675,6 +677,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.fetchFolderLoading": "Loading...", "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 395ed91e7e..ad6ba562bf 100644 --- a/webapp/public/locales/fr/main.json +++ b/webapp/public/locales/fr/main.json @@ -643,7 +643,9 @@ "studies.studylaunched": "{{studyname}} lancé(s) !", "studies.copySuffix": "Copie", "studies.filters.strictfolder": "Afficher uniquement les descendants directs", + "studies.filters.showChildrens": "Voir les sous-dossiers", "studies.scanFolder": "Scanner le dossier", + "studies.recursiveScan": "Scan récursif", "studies.moveStudy": "Déplacer", "studies.movefolderplaceholder": "Chemin séparé par des '/'", "studies.importcopy": "Copier en base", @@ -674,7 +676,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.fetchFolderLoading": "Chargement...", "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..89326dfff9 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; @@ -87,7 +89,8 @@ function StudiesList(props: StudiesListProps) { const sortLabelId = useRef(uuidv4()).current; const [selectedStudies, setSelectedStudies] = useState([]); const [selectionMode, setSelectionMode] = useState(false); - const [confirmFolderScan, setConfirmFolderScan] = 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/StudyTreeNode.tsx b/webapp/src/components/App/Studies/StudyTree/StudyTreeNode.tsx new file mode 100644 index 0000000000..9b2d6cdff6 --- /dev/null +++ b/webapp/src/components/App/Studies/StudyTree/StudyTreeNode.tsx @@ -0,0 +1,62 @@ +/** + * 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 { memo } from "react"; +import { StudyTreeNodeProps } from "./types"; +import TreeItemEnhanced from "@/components/common/TreeItemEnhanced"; +import { t } from "i18next"; + +export default memo(function StudyTreeNode({ + studyTreeNode, + parentId, + onNodeClick, +}: StudyTreeNodeProps) { + const isLoadingFolder = + studyTreeNode.hasChildren && studyTreeNode.children.length === 0; + const id = parentId + ? `${parentId}/${studyTreeNode.name}` + : studyTreeNode.name; + + if (isLoadingFolder) { + return ( + onNodeClick(id, studyTreeNode)} + > + + + ); + } + + return ( + onNodeClick(id, studyTreeNode)} + > + {studyTreeNode.children.map((child) => ( + + ))} + + ); +}); 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..1fe3237182 --- /dev/null +++ b/webapp/src/components/App/Studies/StudyTree/__test__/fixtures.ts @@ -0,0 +1,322 @@ +/** + * 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 { StudyMetadata, StudyType } from "@/common/types"; + +function createStudyMetadata(folder: string, workspace: string): StudyMetadata { + return { + id: "test-study-id", + name: "Test Study", + creationDate: "2024-01-01", + modificationDate: "2024-01-02", + owner: { id: 1, name: "Owner 1" }, + type: StudyType.RAW, + version: "v1", + workspace, + managed: false, + archived: false, + groups: [], + folder, + publicMode: "NONE", + }; +} + +export const FIXTURES = { + basicTree: { + name: "Basic tree with single level", + 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: [] }, + ], + }, + }, + hasChildren: { + name: "Case where a folder is already in the tree, maybe because he has studies, but now the api return that the folder also contains non study folder", + studyTree: { + name: "Root", + path: "/", + children: [ + { + name: "a", + path: "/a", + children: [{ name: "folder1", path: "/a/folder1", children: [] }], + }, + { name: "b", path: "/b", children: [] }, + ], + }, + folders: [ + { + name: "folder1", + path: "folder1", + workspace: "a", + parentPath: "/a", + hasChildren: true, + }, + ], + expected: { + name: "Root", + path: "/", + children: [ + { + name: "a", + path: "/a", + children: [ + { + name: "folder1", + path: "/a/folder1", + children: [], + hasChildren: true, + }, + ], + }, + { 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", + }, + { + name: "folder3", + path: "/folder3", + workspace: "a", + parentPath: "/a", + hasChildren: true, + }, + ], + expected: { + name: "Root", + path: "/", + children: [ + { + name: "a", + path: "/a", + children: [ + { name: "folder1", path: "/a/folder1", children: [] }, + { name: "folder2", path: "/a/folder2", children: [] }, + { + name: "folder3", + path: "/a/folder3", + children: [], + hasChildren: true, + }, + ], + }, + ], + }, + }, +}; + +export const FIXTURES_BUILD_STUDY_TREE = { + simpleCase: { + name: "Basic case", + studies: [createStudyMetadata("studies/team1/myFolder", "workspace")], + expected: { + name: "root", + path: "", + children: [ + { + name: "workspace", + path: "/workspace", + children: [ + { + name: "studies", + path: "/workspace/studies", + children: [ + { + name: "team1", + path: "/workspace/studies/team1", + children: [], + }, + ], + }, + ], + }, + ], + }, + }, + multiplieStudies: { + name: "Multiple studies case", + studies: [ + createStudyMetadata("studies/team1/study", "workspace"), + createStudyMetadata("studies/team2/study", "workspace"), + createStudyMetadata("studies/team3/study", "workspace"), + createStudyMetadata("archives/team4/study", "workspace2"), + ], + expected: { + name: "root", + path: "", + children: [ + { + name: "workspace", + path: "/workspace", + children: [ + { + name: "studies", + path: "/workspace/studies", + children: [ + { + name: "team1", + path: "/workspace/studies/team1", + children: [], + }, + { + name: "team2", + path: "/workspace/studies/team2", + children: [], + }, + { + name: "team3", + path: "/workspace/studies/team3", + children: [], + }, + ], + }, + ], + }, + { + name: "workspace2", + path: "/workspace2", + children: [ + { + name: "archives", + path: "/workspace2/archives", + children: [ + { + name: "team4", + path: "/workspace2/archives/team4", + children: [], + }, + ], + }, + ], + }, + ], + }, + }, +}; diff --git a/webapp/src/components/App/Studies/StudyTree/__test__/utils.test.ts b/webapp/src/components/App/Studies/StudyTree/__test__/utils.test.ts new file mode 100644 index 0000000000..0578313bb1 --- /dev/null +++ b/webapp/src/components/App/Studies/StudyTree/__test__/utils.test.ts @@ -0,0 +1,125 @@ +/** + * 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 { FIXTURES, FIXTURES_BUILD_STUDY_TREE } from "./fixtures"; +import { + buildStudyTree, + insertFoldersIfNotExist, + insertWorkspacesIfNotExist, +} from "../utils"; +import { NonStudyFolderDTO, StudyTreeNode } from "../types"; + +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 = ["a", "workspace1", "workspace2"]; + const result = insertWorkspacesIfNotExist(tree, workspaces); + expect(result).toEqual(expected); + }); + + test.each(Object.values(FIXTURES_BUILD_STUDY_TREE))( + "$name", + ({ studies, expected }) => { + const result = buildStudyTree(studies); + expect(result).toEqual(expected); + }, + ); + }); +}); 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..659b05d906 --- /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 "./types"; +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 { 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"; +import StudyTreeNodeComponent from "./StudyTreeNode"; + +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(() => { + // be carefull to pass initialStudiesTree and not studiesTree at rootNode parameter + // otherwise we'll lose the default workspace + updateTree("root", initialStudiesTree, 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 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")) { + // 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; + } + // Bug fix : this function used to take only the itemId and the selectedNode, and we used to initialize treeAfterWorkspacesUpdate + // with the studiesTree closure, referencing directly the state, like this : treeAfterWorkspacesUpdate = studiesTree; + // The thing is at the first render studiesTree was empty. + // This made updateTree override studiesTree with an empty tree during the first call. This caused a bug where we didn't see the default + // workspace in the UI, as it was overridden by an empty tree at start and then the get workspaces api never returns the default workspace. + // Now we don't use the closure anymore, we pass the root tree as a parameter. Thus at the first call of updateTree, we pass initialStudiesTree. + // You may think why we don't just capture initialStudiesTree then, it's because in the following call we need to pass studiesTree. + let treeAfterWorkspacesUpdate = rootNode; + + let pathsToFetch: string[] = []; + // If the user clicks on the root folder, we fetch the workspaces and insert them. + // Then we fetch the direct subfolders of the workspaces. + if (itemId === "root") { + try { + treeAfterWorkspacesUpdate = await fetchAndInsertWorkspaces(rootNode); + } catch (error) { + enqueueErrorSnackbar( + t("studies.tree.error.failToFetchWorkspace"), + toError(error), + ); + } + pathsToFetch = treeAfterWorkspacesUpdate.children + .filter((t) => t.name !== "default") // We don't fetch the default workspace subfolders, api don't allow it + .map((child) => `root${child.path}`); + } else { + // If the user clicks on a folder, we add the path of the clicked folder to the list of paths to fetch. + pathsToFetch = [`root${selectedNode.path}`]; + } + + const [treeAfterSubfoldersUpdate, failedPath] = + await fetchAndInsertSubfolders(pathsToFetch, treeAfterWorkspacesUpdate); + if (failedPath.length > 0) { + enqueueErrorSnackbar( + t("studies.tree.error.failToFetchFolder", { + path: failedPath.join(" "), + interpolation: { escapeValue: false }, + }), + "", + ); + } + setStudiesTree(treeAfterSubfoldersUpdate); + } + + //////////////////////////////////////////////////////////////// + // Event Handlers + //////////////////////////////////////////////////////////////// + + const handleTreeItemClick = async ( + itemId: string, + studyTreeNode: StudyTreeNode, + ) => { + dispatch(updateStudyFilters({ folder: itemId })); + updateTree(itemId, studiesTree, studyTreeNode); + }; + + //////////////////////////////////////////////////////////////// + // JSX + //////////////////////////////////////////////////////////////// + + return ( + + + + ); +} + +export default StudyTree; diff --git a/webapp/src/components/App/Studies/StudyTree/types.ts b/webapp/src/components/App/Studies/StudyTree/types.ts new file mode 100644 index 0000000000..5a67d65627 --- /dev/null +++ b/webapp/src/components/App/Studies/StudyTree/types.ts @@ -0,0 +1,34 @@ +/** + * 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 interface StudyTreeNode { + name: string; + path: string; + children: StudyTreeNode[]; + hasChildren?: boolean; +} + +export interface NonStudyFolderDTO { + name: string; + path: string; + workspace: string; + parentPath: string; + hasChildren?: boolean; +} + +export interface StudyTreeNodeProps { + studyTreeNode: StudyTreeNode; + parentId: string; + onNodeClick: (id: string, node: StudyTreeNode) => void; +} 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..4d1132d79b --- /dev/null +++ b/webapp/src/components/App/Studies/StudyTree/utils.ts @@ -0,0 +1,281 @@ +/** + * 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 * as api from "../../../../services/api/study"; +import { StudyMetadata } from "../../../../common/types"; +import { StudyTreeNode, NonStudyFolderDTO } from "./types"; + +/** + * Builds a tree structure from a list of study metadata. + * + * @param studies - Array of study metadata objects. + * @returns A tree structure representing the studies. + */ +export function buildStudyTree(studies: StudyMetadata[]) { + const tree: StudyTreeNode = { + name: "root", + children: [], + path: "", + }; + + for (const study of studies) { + const path = + typeof study.folder === "string" + ? [study.workspace, ...study.folder.split("/").filter(Boolean)] + : [study.workspace]; + + let current = tree; + + for (let i = 0; i < path.length; i++) { + // Skip the last folder, as it represents the study itself + if (i === path.length - 1) { + break; + } + + const folderName = path[i]; + let child = current.children.find((child) => child.name === folderName); + + if (!child) { + child = { + name: folderName, + children: [], + path: current.path + ? `${current.path}/${folderName}` + : `/${folderName}`, + }; + + current.children.push(child); + } + + current = child; + } + } + + return tree; +} + +/** + * 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 { + const currentNodePath = `${studiesTree.path}`; + // Early return if folder doesn't belong in this branch + if (!folder.parentPath.startsWith(currentNodePath)) { + return studiesTree; + } + + // direct child case + if (folder.parentPath == currentNodePath) { + const folderExists = studiesTree.children.find( + (child) => child.name === folder.name, + ); + if (folderExists) { + return { + ...studiesTree, + children: [ + ...studiesTree.children.filter((child) => child.name !== folder.name), + { + ...folderExists, + hasChildren: folder.hasChildren, + }, + ], + }; + } + // 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: [], + hasChildren: folder.hasChildren, + }, + ], + }; + } + + // 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) => { + return 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") { + console.error("this function should not be called with path 'root'", path); + // Under root there're workspaces not subfolders + return []; + } + if (!path.startsWith("root/")) { + console.error("path here should start with root/ ", path); + return []; + } + // less than 2 parts means we're at the root level + const pathParts = path.split("/"); + if (pathParts.length < 2) { + console.error( + "this function should not be called with a path that has less than two com", + path, + ); + 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, +): StudyTreeNode { + const emptyNode = { + name: workspace, + path: `/${workspace}`, + children: [], + }; + if (stydyTree.children.some((child) => child.name === workspace)) { + return stydyTree; + } + return { + ...stydyTree, + children: [...stydyTree.children, emptyNode], + }; +} + +/** + * Insert several workspaces into the study tree if they don't exist already in the tree. + * + * This function doesn't mutate the tree, it returns a new tree with the workspaces inserted. + * + * The workspaces are inserted in the order they are given. + * + * @param workspaces workspaces to insert into the tree + * @param stydyTree study tree to insert the workspaces into + * @returns study tree with the empty workspaces inserted if they weren't already there. + */ +export function insertWorkspacesIfNotExist( + stydyTree: StudyTreeNode, + workspaces: string[], +): StudyTreeNode { + return workspaces.reduce( + (acc, workspace) => insertWorkspaceIfNotExist(acc, workspace), + stydyTree, + ); +} + +/** + * Fetch and insert the workspaces into the study tree. + * + * Workspaces are inserted only if they don't exist already in the tree. + * + * This function doesn't mutate the tree, it returns a new tree with the workspaces inserted. + * + * @param studyTree study tree to insert the workspaces into + * @returns study tree with the workspaces inserted if they weren't already there. + */ +export async function fetchAndInsertWorkspaces( + studyTree: StudyTreeNode, +): Promise { + const workspaces = await api.getWorkspaces(); + return insertWorkspacesIfNotExist(studyTree, workspaces); +} diff --git a/webapp/src/components/App/Studies/utils.ts b/webapp/src/components/App/Studies/utils.ts deleted file mode 100644 index 3f2ff61564..0000000000 --- a/webapp/src/components/App/Studies/utils.ts +++ /dev/null @@ -1,64 +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 { StudyMetadata } from "../../../common/types"; - -export interface StudyTreeNode { - name: string; - path: string; - children: StudyTreeNode[]; -} - -/** - * Builds a tree structure from a list of study metadata. - * - * @param studies - Array of study metadata objects. - * @returns A tree structure representing the studies. - */ -export function buildStudyTree(studies: StudyMetadata[]) { - const tree: StudyTreeNode = { name: "root", children: [], path: "" }; - - for (const study of studies) { - const path = - typeof study.folder === "string" - ? [study.workspace, ...study.folder.split("/").filter(Boolean)] - : [study.workspace]; - - let current = tree; - - for (let i = 0; i < path.length; i++) { - // Skip the last folder, as it represents the study itself - if (i === path.length - 1) { - break; - } - - const folderName = path[i]; - let child = current.children.find((child) => child.name === folderName); - - if (!child) { - child = { - name: folderName, - children: [], - path: current.path ? `${current.path}/${folderName}` : folderName, - }; - - current.children.push(child); - } - - current = child; - } - } - - return tree; -} 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/redux/selectors.ts b/webapp/src/redux/selectors.ts index 5fb8947726..99b6da20a0 100644 --- a/webapp/src/redux/selectors.ts +++ b/webapp/src/redux/selectors.ts @@ -23,7 +23,6 @@ import { StudyMetadata, UserDetailsDTO, } from "../common/types"; -import { buildStudyTree } from "../components/App/Studies/utils"; import { filterStudies, sortStudies } from "../utils/studiesUtils"; import { convertVersions, isGroupAdmin, isUserAdmin } from "../services/utils"; import { AppState } from "./ducks"; @@ -43,6 +42,7 @@ import { StudyMapsState, } from "./ducks/studyMaps"; import { makeLinkId } from "./utils"; +import { buildStudyTree } from "../components/App/Studies/StudyTree/utils"; // TODO resultEqualityCheck @@ -131,7 +131,9 @@ export const getStudyIdsFilteredAndSorted = createSelector( (studies) => studies.map((study) => study.id), ); -export const getStudiesTree = createSelector(getStudies, buildStudyTree); +export const getStudiesTree = createSelector(getStudies, (studies) => + buildStudyTree(studies), +); export const getStudyVersions = ( state: AppState, diff --git a/webapp/src/services/api/study.ts b/webapp/src/services/api/study.ts index 53d4a67925..6636b9d4b6 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/StudyTree/types"; + +interface Workspace { + name: string; +} const getStudiesRaw = async (): Promise> => { const res = await client.get(`/v1/studies`); @@ -48,6 +53,27 @@ export const getStudies = async (): Promise => { }); }; +export const getWorkspaces = async () => { + 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) => { + 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 +460,10 @@ 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) => { + await client.post( + `/v1/watcher/_scan?path=${encodeURIComponent(folderPath)}&recursive=${recursive}`, + ); }; export const getStudyLayers = async (uuid: string): Promise => { From 684b964f229c176dbd6f10dd679f7965c49575b8 Mon Sep 17 00:00:00 2001 From: Samir Kamal <1954121+skamril@users.noreply.github.com> Date: Thu, 9 Jan 2025 18:01:44 +0100 Subject: [PATCH 2/9] feat(ui-hooks): create useFormCloseProtection --- webapp/src/components/common/Form/index.tsx | 32 ++---------------- .../common/Matrix/hooks/useMatrix/index.ts | 11 +++---- webapp/src/hooks/useCloseFormSecurity.ts | 33 +++++++++++++++++++ 3 files changed, 41 insertions(+), 35 deletions(-) create mode 100644 webapp/src/hooks/useCloseFormSecurity.ts diff --git a/webapp/src/components/common/Form/index.tsx b/webapp/src/components/common/Form/index.tsx index 56b3e2d572..00b0f61d3a 100644 --- a/webapp/src/components/common/Form/index.tsx +++ b/webapp/src/components/common/Form/index.tsx @@ -13,7 +13,7 @@ */ /* eslint-disable @typescript-eslint/no-explicit-any */ -import { FormEvent, useEffect, useMemo, useRef, useState } from "react"; +import { FormEvent, useEffect, useMemo, useRef } from "react"; import { DeepPartial, FieldPath, @@ -54,12 +54,12 @@ import { toAutoSubmitConfig, } from "./utils"; import useDebouncedState from "../../../hooks/useDebouncedState"; -import usePrompt from "../../../hooks/usePrompt"; import { SubmitHandlerPlus, UseFormReturnPlus } from "./types"; import FormContext from "./FormContext"; import useFormApiPlus from "./useFormApiPlus"; import useFormUndoRedo from "./useFormUndoRedo"; import { mergeSxProp } from "../../../utils/muiUtils"; +import useFormCloseProtection from "@/hooks/useCloseFormSecurity"; export interface AutoSubmitConfig { enable: boolean; @@ -132,7 +132,6 @@ function Form( const { t } = useTranslation(); const autoSubmitConfig = toAutoSubmitConfig(autoSubmit); - const [isInProgress, setIsInProgress] = useState(false); const [showAutoSubmitLoader, setShowAutoSubmitLoader] = useDebouncedState( false, 750, @@ -246,32 +245,12 @@ function Form( [isSubmitSuccessful], ); - // Prevent browser close if a submit is pending - useEffect(() => { - const listener = (event: BeforeUnloadEvent) => { - if (isInProgress) { - // eslint-disable-next-line no-param-reassign - event.returnValue = t("form.submit.inProgress"); - } else if (isDirty) { - // eslint-disable-next-line no-param-reassign - event.returnValue = t("form.changeNotSaved"); - } - }; - - window.addEventListener("beforeunload", listener); - - return () => { - window.removeEventListener("beforeunload", listener); - }; - }, [t, isInProgress, isDirty]); + useFormCloseProtection({ isSubmitting, isDirty }); useUpdateEffect(() => onStateChange?.(formState), [formState]); useEffect(() => setRef(apiRef, formApiPlus)); - usePrompt(t("form.submit.inProgress"), isInProgress); - usePrompt(t("form.changeNotSaved"), isDirty && !isInProgress); - //////////////////////////////////////////////////////////////// // Submit //////////////////////////////////////////////////////////////// @@ -322,9 +301,6 @@ function Form( ? err.response?.data.description : err?.toString(), }); - }) - .finally(() => { - setIsInProgress(false); }); }, onInvalid); @@ -334,8 +310,6 @@ function Form( const submitDebounced = useDebounce(submit, autoSubmitConfig.wait); const requestSubmit = () => { - setIsInProgress(true); - if (autoSubmitConfig.enable) { submitDebounced(); } else { diff --git a/webapp/src/components/common/Matrix/hooks/useMatrix/index.ts b/webapp/src/components/common/Matrix/hooks/useMatrix/index.ts index e89f99be12..0726b42d4c 100644 --- a/webapp/src/components/common/Matrix/hooks/useMatrix/index.ts +++ b/webapp/src/components/common/Matrix/hooks/useMatrix/index.ts @@ -41,9 +41,9 @@ import useUndo from "use-undo"; import { GridCellKind } from "@glideapps/glide-data-grid"; import { uploadFile } from "../../../../../services/api/studies/raw"; import { fetchMatrixFn } from "../../../../App/Singlestudy/explore/Modelization/Areas/Hydro/utils"; -import usePrompt from "../../../../../hooks/usePrompt"; import { Aggregate, Column, Operation } from "../../shared/constants"; import { aggregatesTheme } from "../../styles"; +import useFormCloseProtection from "@/hooks/useCloseFormSecurity"; interface DataState { data: MatrixDataDTO["data"]; @@ -83,11 +83,10 @@ export function useMatrix( [aggregatesConfig], ); - // Display warning prompts to prevent unintended navigation - // 1. When the matrix is currently being submitted - usePrompt(t("form.submit.inProgress"), isSubmitting); - // 2. When there are unsaved changes in the matrix - usePrompt(t("form.changeNotSaved"), currentState.pendingUpdates.length > 0); + useFormCloseProtection({ + isSubmitting, + isDirty: currentState.pendingUpdates.length > 0, + }); const fetchMatrix = async (loadingState = true) => { // !NOTE This is a temporary solution to ensure the matrix is up to date diff --git a/webapp/src/hooks/useCloseFormSecurity.ts b/webapp/src/hooks/useCloseFormSecurity.ts new file mode 100644 index 0000000000..b20fdee6f6 --- /dev/null +++ b/webapp/src/hooks/useCloseFormSecurity.ts @@ -0,0 +1,33 @@ +/** + * 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 usePrompt from "./usePrompt"; +import { useTranslation } from "react-i18next"; + +export interface UseFormCloseProtectionParams { + isSubmitting: boolean; + isDirty: boolean; +} + +function useFormCloseProtection({ + isSubmitting, + isDirty, +}: UseFormCloseProtectionParams) { + const { t } = useTranslation(); + + usePrompt(t("form.submit.inProgress"), isSubmitting); + usePrompt(t("form.changeNotSaved"), isDirty && !isSubmitting); +} + +export default useFormCloseProtection; From ba87cffd2b44eb59715800036145474588a2853b Mon Sep 17 00:00:00 2001 From: Samir Kamal <1954121+skamril@users.noreply.github.com> Date: Fri, 10 Jan 2025 09:22:00 +0100 Subject: [PATCH 3/9] feat(ui-common): rename EmptyView and add extraActions prop --- .../Singlestudy/Commands/Edition/index.tsx | 2 +- .../dialogs/ScenarioBuilderDialog/Table.tsx | 2 +- .../Singlestudy/explore/Debug/Data/Folder.tsx | 2 +- .../Singlestudy/explore/Debug/Data/Text.tsx | 2 +- .../explore/Debug/Data/Unsupported.tsx | 2 +- .../explore/Modelization/Areas/index.tsx | 2 +- .../Modelization/BindingConstraints/index.tsx | 2 +- .../explore/Modelization/Links/index.tsx | 2 +- .../explore/Results/ResultDetails/index.tsx | 2 +- .../explore/TableModeList/index.tsx | 2 +- .../explore/Xpansion/Candidates/index.tsx | 2 +- webapp/src/components/common/Matrix/index.tsx | 2 +- webapp/src/components/common/TableMode.tsx | 2 +- .../components/MatrixContent.tsx | 2 +- .../common/dialogs/DigestDialog.tsx | 2 +- .../page/{SimpleContent.tsx => EmptyView.tsx} | 22 +++++++++++++++++-- .../common/utils/UsePromiseCond.tsx | 2 +- 17 files changed, 36 insertions(+), 18 deletions(-) rename webapp/src/components/common/page/{SimpleContent.tsx => EmptyView.tsx} (71%) diff --git a/webapp/src/components/App/Singlestudy/Commands/Edition/index.tsx b/webapp/src/components/App/Singlestudy/Commands/Edition/index.tsx index f26d44693a..69524ae7d8 100644 --- a/webapp/src/components/App/Singlestudy/Commands/Edition/index.tsx +++ b/webapp/src/components/App/Singlestudy/Commands/Edition/index.tsx @@ -57,7 +57,7 @@ import { } from "../../../../../services/webSocket/ws"; import ConfirmationDialog from "../../../../common/dialogs/ConfirmationDialog"; import CheckBoxFE from "../../../../common/fieldEditors/CheckBoxFE"; -import EmptyView from "../../../../common/page/SimpleContent"; +import EmptyView from "../../../../common/page/EmptyView"; import { TaskStatus } from "../../../../../services/api/tasks/constants"; import type { TaskEventPayload, diff --git a/webapp/src/components/App/Singlestudy/explore/Configuration/General/dialogs/ScenarioBuilderDialog/Table.tsx b/webapp/src/components/App/Singlestudy/explore/Configuration/General/dialogs/ScenarioBuilderDialog/Table.tsx index 9b0648912a..5670fccb2d 100644 --- a/webapp/src/components/App/Singlestudy/explore/Configuration/General/dialogs/ScenarioBuilderDialog/Table.tsx +++ b/webapp/src/components/App/Singlestudy/explore/Configuration/General/dialogs/ScenarioBuilderDialog/Table.tsx @@ -21,7 +21,7 @@ import { updateScenarioBuilderConfig, } from "./utils"; import { SubmitHandlerPlus } from "../../../../../../../common/Form/types"; -import EmptyView from "../../../../../../../common/page/SimpleContent"; +import EmptyView from "../../../../../../../common/page/EmptyView"; import useEnqueueErrorSnackbar from "../../../../../../../../hooks/useEnqueueErrorSnackbar"; import { toError } from "../../../../../../../../utils/fnUtils"; import { useOutletContext } from "react-router"; diff --git a/webapp/src/components/App/Singlestudy/explore/Debug/Data/Folder.tsx b/webapp/src/components/App/Singlestudy/explore/Debug/Data/Folder.tsx index 88c1aaea1e..dfc763783a 100644 --- a/webapp/src/components/App/Singlestudy/explore/Debug/Data/Folder.tsx +++ b/webapp/src/components/App/Singlestudy/explore/Debug/Data/Folder.tsx @@ -36,7 +36,7 @@ import { canEditFile, } from "../utils"; import { Fragment, useState } from "react"; -import EmptyView from "../../../../../common/page/SimpleContent"; +import EmptyView from "../../../../../common/page/EmptyView"; import { useTranslation } from "react-i18next"; import { Filename, Menubar } from "./styles"; import UploadFileButton from "../../../../../common/buttons/UploadFileButton"; diff --git a/webapp/src/components/App/Singlestudy/explore/Debug/Data/Text.tsx b/webapp/src/components/App/Singlestudy/explore/Debug/Data/Text.tsx index 1ee8be74c0..b77dd93c1f 100644 --- a/webapp/src/components/App/Singlestudy/explore/Debug/Data/Text.tsx +++ b/webapp/src/components/App/Singlestudy/explore/Debug/Data/Text.tsx @@ -31,7 +31,7 @@ import DownloadButton from "../../../../../common/buttons/DownloadButton"; import { downloadFile } from "../../../../../../utils/fileUtils"; import { Filename, Flex, Menubar } from "./styles"; import UploadFileButton from "../../../../../common/buttons/UploadFileButton"; -import EmptyView from "@/components/common/page/SimpleContent"; +import EmptyView from "@/components/common/page/EmptyView"; import GridOffIcon from "@mui/icons-material/GridOff"; import { getRawFile } from "@/services/api/studies/raw"; diff --git a/webapp/src/components/App/Singlestudy/explore/Debug/Data/Unsupported.tsx b/webapp/src/components/App/Singlestudy/explore/Debug/Data/Unsupported.tsx index 81307a4edf..f96f598a77 100644 --- a/webapp/src/components/App/Singlestudy/explore/Debug/Data/Unsupported.tsx +++ b/webapp/src/components/App/Singlestudy/explore/Debug/Data/Unsupported.tsx @@ -13,7 +13,7 @@ */ import { useTranslation } from "react-i18next"; -import EmptyView from "../../../../../common/page/SimpleContent"; +import EmptyView from "../../../../../common/page/EmptyView"; import BlockIcon from "@mui/icons-material/Block"; import { Filename, Flex, Menubar } from "./styles"; import type { DataCompProps } from "../utils"; diff --git a/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/index.tsx b/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/index.tsx index f5df3c43e0..199a543c27 100644 --- a/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/index.tsx +++ b/webapp/src/components/App/Singlestudy/explore/Modelization/Areas/index.tsx @@ -14,7 +14,7 @@ import { useOutletContext } from "react-router"; import { StudyMetadata } from "../../../../../../common/types"; -import EmptyView from "../../../../../common/page/SimpleContent"; +import EmptyView from "../../../../../common/page/EmptyView"; import AreaPropsView from "./AreaPropsView"; import AreasTab from "./AreasTab"; import useStudySynthesis from "../../../../../../redux/hooks/useStudySynthesis"; diff --git a/webapp/src/components/App/Singlestudy/explore/Modelization/BindingConstraints/index.tsx b/webapp/src/components/App/Singlestudy/explore/Modelization/BindingConstraints/index.tsx index c1e04de1e0..fa48b6aa15 100644 --- a/webapp/src/components/App/Singlestudy/explore/Modelization/BindingConstraints/index.tsx +++ b/webapp/src/components/App/Singlestudy/explore/Modelization/BindingConstraints/index.tsx @@ -14,7 +14,7 @@ import { useOutletContext } from "react-router"; import { StudyMetadata } from "../../../../../../common/types"; -import EmptyView from "../../../../../common/page/SimpleContent"; +import EmptyView from "../../../../../common/page/EmptyView"; import BindingConstPropsView from "./BindingConstPropsView"; import { getBindingConst, diff --git a/webapp/src/components/App/Singlestudy/explore/Modelization/Links/index.tsx b/webapp/src/components/App/Singlestudy/explore/Modelization/Links/index.tsx index 3749bb784b..29ab7632ba 100644 --- a/webapp/src/components/App/Singlestudy/explore/Modelization/Links/index.tsx +++ b/webapp/src/components/App/Singlestudy/explore/Modelization/Links/index.tsx @@ -14,7 +14,7 @@ import { useOutletContext } from "react-router"; import { StudyMetadata } from "../../../../../../common/types"; -import EmptyView from "../../../../../common/page/SimpleContent"; +import EmptyView from "../../../../../common/page/EmptyView"; import LinkPropsView from "./LinkPropsView"; import { getCurrentLink } from "../../../../../../redux/selectors"; import useAppDispatch from "../../../../../../redux/hooks/useAppDispatch"; diff --git a/webapp/src/components/App/Singlestudy/explore/Results/ResultDetails/index.tsx b/webapp/src/components/App/Singlestudy/explore/Results/ResultDetails/index.tsx index 1814bd876d..8df3507b76 100644 --- a/webapp/src/components/App/Singlestudy/explore/Results/ResultDetails/index.tsx +++ b/webapp/src/components/App/Singlestudy/explore/Results/ResultDetails/index.tsx @@ -63,7 +63,7 @@ import { Column } from "@/components/common/Matrix/shared/constants.ts"; import SplitView from "../../../../../common/SplitView/index.tsx"; import ResultFilters from "./ResultFilters.tsx"; import { toError } from "../../../../../../utils/fnUtils.ts"; -import EmptyView from "../../../../../common/page/SimpleContent.tsx"; +import EmptyView from "../../../../../common/page/EmptyView.tsx"; import { getStudyMatrixIndex } from "../../../../../../services/api/matrix.ts"; import { MatrixGridSynthesis } from "@/components/common/Matrix/components/MatrixGridSynthesis"; import { ResultMatrixDTO } from "@/components/common/Matrix/shared/types.ts"; diff --git a/webapp/src/components/App/Singlestudy/explore/TableModeList/index.tsx b/webapp/src/components/App/Singlestudy/explore/TableModeList/index.tsx index bf4655465a..1f381e17bc 100644 --- a/webapp/src/components/App/Singlestudy/explore/TableModeList/index.tsx +++ b/webapp/src/components/App/Singlestudy/explore/TableModeList/index.tsx @@ -32,7 +32,7 @@ import ConfirmationDialog from "../../../../common/dialogs/ConfirmationDialog"; import TableMode from "../../../../common/TableMode"; import SplitView from "../../../../common/SplitView"; import ViewWrapper from "../../../../common/page/ViewWrapper"; -import EmptyView from "@/components/common/page/SimpleContent"; +import EmptyView from "@/components/common/page/EmptyView"; function TableModeList() { const { t } = useTranslation(); diff --git a/webapp/src/components/App/Singlestudy/explore/Xpansion/Candidates/index.tsx b/webapp/src/components/App/Singlestudy/explore/Xpansion/Candidates/index.tsx index fafe0236ed..298dc08e01 100644 --- a/webapp/src/components/App/Singlestudy/explore/Xpansion/Candidates/index.tsx +++ b/webapp/src/components/App/Singlestudy/explore/Xpansion/Candidates/index.tsx @@ -41,7 +41,7 @@ import CreateCandidateDialog from "./CreateCandidateDialog"; import CandidateForm from "./CandidateForm"; import usePromiseWithSnackbarError from "../../../../../../hooks/usePromiseWithSnackbarError"; import DataViewerDialog from "../../../../../common/dialogs/DataViewerDialog"; -import EmptyView from "../../../../../common/page/SimpleContent"; +import EmptyView from "../../../../../common/page/EmptyView"; import SplitView from "../../../../../common/SplitView"; import { getLinks } from "@/services/api/studies/links"; import { MatrixDataDTO } from "@/components/common/Matrix/shared/types"; diff --git a/webapp/src/components/common/Matrix/index.tsx b/webapp/src/components/common/Matrix/index.tsx index 4bb07202dd..e9898f6292 100644 --- a/webapp/src/components/common/Matrix/index.tsx +++ b/webapp/src/components/common/Matrix/index.tsx @@ -21,7 +21,7 @@ import { useOutletContext } from "react-router"; import { StudyMetadata } from "../../../common/types"; import { MatrixContainer, MatrixHeader, MatrixTitle } from "./styles"; import MatrixActions from "./components/MatrixActions"; -import EmptyView from "../page/SimpleContent"; +import EmptyView from "../page/EmptyView"; import { fetchMatrixFn } from "../../App/Singlestudy/explore/Modelization/Areas/Hydro/utils"; import { AggregateConfig } from "./shared/types"; import { GridOff } from "@mui/icons-material"; diff --git a/webapp/src/components/common/TableMode.tsx b/webapp/src/components/common/TableMode.tsx index 499827537a..1809a93377 100644 --- a/webapp/src/components/common/TableMode.tsx +++ b/webapp/src/components/common/TableMode.tsx @@ -28,7 +28,7 @@ import { SubmitHandlerPlus } from "./Form/types"; import TableForm from "./TableForm"; import UsePromiseCond from "./utils/UsePromiseCond"; import GridOffIcon from "@mui/icons-material/GridOff"; -import EmptyView from "./page/SimpleContent"; +import EmptyView from "./page/EmptyView"; import { useTranslation } from "react-i18next"; export interface TableModeProps { diff --git a/webapp/src/components/common/dialogs/DatabaseUploadDialog/components/MatrixContent.tsx b/webapp/src/components/common/dialogs/DatabaseUploadDialog/components/MatrixContent.tsx index 3856e72690..c1c17c27b0 100644 --- a/webapp/src/components/common/dialogs/DatabaseUploadDialog/components/MatrixContent.tsx +++ b/webapp/src/components/common/dialogs/DatabaseUploadDialog/components/MatrixContent.tsx @@ -21,7 +21,7 @@ import ButtonBack from "@/components/common/ButtonBack"; import { getMatrix } from "@/services/api/matrix"; import usePromiseWithSnackbarError from "@/hooks/usePromiseWithSnackbarError"; import { generateDataColumns } from "@/components/common/Matrix/shared/utils"; -import EmptyView from "@/components/common/page/SimpleContent"; +import EmptyView from "@/components/common/page/EmptyView"; import { GridOff } from "@mui/icons-material"; interface MatrixContentProps { diff --git a/webapp/src/components/common/dialogs/DigestDialog.tsx b/webapp/src/components/common/dialogs/DigestDialog.tsx index db169ecaf1..31bf1f0900 100644 --- a/webapp/src/components/common/dialogs/DigestDialog.tsx +++ b/webapp/src/components/common/dialogs/DigestDialog.tsx @@ -20,7 +20,7 @@ import { getStudyData } from "../../../services/api/study"; import usePromise from "../../../hooks/usePromise"; import { useTranslation } from "react-i18next"; import { AxiosError } from "axios"; -import EmptyView from "../page/SimpleContent"; +import EmptyView from "../page/EmptyView"; import SearchOffIcon from "@mui/icons-material/SearchOff"; import { generateDataColumns } from "@/components/common/Matrix/shared/utils"; import { MatrixGridSynthesis } from "@/components/common/Matrix/components/MatrixGridSynthesis"; diff --git a/webapp/src/components/common/page/SimpleContent.tsx b/webapp/src/components/common/page/EmptyView.tsx similarity index 71% rename from webapp/src/components/common/page/SimpleContent.tsx rename to webapp/src/components/common/page/EmptyView.tsx index cd4804272c..5b5610d51c 100644 --- a/webapp/src/components/common/page/SimpleContent.tsx +++ b/webapp/src/components/common/page/EmptyView.tsx @@ -20,10 +20,14 @@ import { SvgIconComponent } from "@mui/icons-material"; export interface EmptyViewProps { title?: string; icon?: SvgIconComponent; + extraActions?: React.ReactNode; } -function EmptyView(props: EmptyViewProps) { - const { title, icon: Icon = LiveHelpRoundedIcon } = props; +function EmptyView({ + title, + icon: Icon = LiveHelpRoundedIcon, + extraActions, +}: EmptyViewProps) { const { t } = useTranslation(); return ( @@ -35,8 +39,22 @@ function EmptyView(props: EmptyViewProps) { flexDirection: "column", alignItems: "center", justifyContent: "center", + position: "relative", }} > + {extraActions && ( + + {extraActions} + + )} {Icon && }
{title || t("common.noContent")}
diff --git a/webapp/src/components/common/utils/UsePromiseCond.tsx b/webapp/src/components/common/utils/UsePromiseCond.tsx index 3d9df26617..0087922521 100644 --- a/webapp/src/components/common/utils/UsePromiseCond.tsx +++ b/webapp/src/components/common/utils/UsePromiseCond.tsx @@ -14,7 +14,7 @@ import { PromiseStatus, UsePromiseResponse } from "../../../hooks/usePromise"; import SimpleLoader from "../loaders/SimpleLoader"; -import EmptyView from "../page/SimpleContent"; +import EmptyView from "../page/EmptyView"; export type Response = Pick< UsePromiseResponse, From 1c590c12f41ebb84107b327cacb91ed10ac0bb21 Mon Sep 17 00:00:00 2001 From: Samir Kamal <1954121+skamril@users.noreply.github.com> Date: Fri, 10 Jan 2025 09:34:28 +0100 Subject: [PATCH 4/9] fix(ui-common): disable undo/redo buttons when submitting in Form --- webapp/src/components/common/Form/index.tsx | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/webapp/src/components/common/Form/index.tsx b/webapp/src/components/common/Form/index.tsx index 00b0f61d3a..eeb4bb6058 100644 --- a/webapp/src/components/common/Form/index.tsx +++ b/webapp/src/components/common/Form/index.tsx @@ -413,14 +413,22 @@ function Form( <> - + - + From b164dd6af3491b837e879bf09bd602ae2db77d03 Mon Sep 17 00:00:00 2001 From: Samir Kamal <1954121+skamril@users.noreply.github.com> Date: Fri, 10 Jan 2025 09:35:07 +0100 Subject: [PATCH 5/9] feat(ui-common): create DataGrid component and use it --- webapp/index.html | 2 + webapp/src/components/common/DataGrid.tsx | 337 ++++++++++++++++++ .../components/MatrixGrid/MatrixGrid.test.tsx | 84 ----- .../Matrix/components/MatrixGrid/index.tsx | 100 ++---- .../components/MatrixGridSynthesis/index.tsx | 17 +- .../Matrix/hooks/useColumnMapping/index.ts | 2 +- .../Matrix/hooks/useGridCellContent/index.ts | 2 +- .../Matrix/hooks/useMatrixPortal/index.ts | 76 ---- .../useMatrixPortal/useMatrixPortal.test.tsx | 153 -------- 9 files changed, 373 insertions(+), 400 deletions(-) create mode 100644 webapp/src/components/common/DataGrid.tsx delete mode 100644 webapp/src/components/common/Matrix/hooks/useMatrixPortal/index.ts delete mode 100644 webapp/src/components/common/Matrix/hooks/useMatrixPortal/useMatrixPortal.test.tsx diff --git a/webapp/index.html b/webapp/index.html index b1084b2392..b7e6021a32 100644 --- a/webapp/index.html +++ b/webapp/index.html @@ -17,6 +17,8 @@
+ +
diff --git a/webapp/src/components/common/DataGrid.tsx b/webapp/src/components/common/DataGrid.tsx new file mode 100644 index 0000000000..caf1bcb4df --- /dev/null +++ b/webapp/src/components/common/DataGrid.tsx @@ -0,0 +1,337 @@ +/** + * 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 { + CompactSelection, + DataEditor, + GridCellKind, + type EditListItem, + type GridSelection, + type DataEditorProps, +} from "@glideapps/glide-data-grid"; +import "@glideapps/glide-data-grid/dist/index.css"; +import { useCallback, useEffect, useState } from "react"; +import { voidFn } from "@/utils/fnUtils"; +import { darkTheme } from "./Matrix/styles"; + +interface StringRowMarkerOptions { + kind: "string" | "clickable-string"; + getTitle?: (rowIndex: number) => string; +} + +type RowMarkers = + | NonNullable + | StringRowMarkerOptions["kind"] + | StringRowMarkerOptions; + +type RowMarkersOptions = Exclude; + +export interface DataGridProps + extends Omit< + DataEditorProps, + "rowMarkers" | "onGridSelectionChange" | "gridSelection" + > { + rowMarkers?: RowMarkers; + enableColumnResize?: boolean; +} + +function isStringRowMarkerOptions( + rowMarkerOptions: RowMarkersOptions, +): rowMarkerOptions is StringRowMarkerOptions { + return ( + rowMarkerOptions.kind === "string" || + rowMarkerOptions.kind === "clickable-string" + ); +} + +function DataGrid(props: DataGridProps) { + const { + rowMarkers = { kind: "none" }, + getCellContent, + columns: columnsFromProps, + onCellEdited, + onCellsEdited, + onColumnResize, + onColumnResizeStart, + onColumnResizeEnd, + enableColumnResize = true, + freezeColumns, + ...rest + } = props; + + const rowMarkersOptions: RowMarkersOptions = + typeof rowMarkers === "string" ? { kind: rowMarkers } : rowMarkers; + const isStringRowMarkers = isStringRowMarkerOptions(rowMarkersOptions); + const adjustedFreezeColumns = isStringRowMarkers + ? (freezeColumns || 0) + 1 + : freezeColumns; + + const [columns, setColumns] = useState(columnsFromProps); + const [selection, setSelection] = useState({ + columns: CompactSelection.empty(), + rows: CompactSelection.empty(), + }); + + // Add a column for the "string" row markers if needed + useEffect(() => { + setColumns( + isStringRowMarkers + ? [{ id: "", title: "" }, ...columnsFromProps] + : columnsFromProps, + ); + }, [columnsFromProps, isStringRowMarkers]); + + //////////////////////////////////////////////////////////////// + // Utils + //////////////////////////////////////////////////////////////// + + const ifElseStringRowMarkers = ( + colIndex: number, + onTrue: () => R1, + onFalse: (colIndex: number) => R2, + ) => { + let adjustedColIndex = colIndex; + + if (isStringRowMarkers) { + if (colIndex === 0) { + return onTrue(); + } + + adjustedColIndex = colIndex - 1; + } + + return onFalse(adjustedColIndex); + }; + + const ifNotStringRowMarkers = ( + colIndex: number, + fn: (colIndex: number) => void, + ) => { + return ifElseStringRowMarkers(colIndex, voidFn, fn); + }; + + //////////////////////////////////////////////////////////////// + // Content + //////////////////////////////////////////////////////////////// + + const getCellContentWrapper = useCallback( + (cell) => { + const [colIndex, rowIndex] = cell; + + return ifElseStringRowMarkers( + colIndex, + () => { + const title = + isStringRowMarkers && rowMarkersOptions.getTitle + ? rowMarkersOptions.getTitle(rowIndex) + : `Row ${rowIndex + 1}`; + + return { + kind: GridCellKind.Text, + data: title, + displayData: title, + allowOverlay: false, + readonly: true, + themeOverride: { + bgCell: darkTheme.bgHeader, + }, + }; + }, + (adjustedColIndex) => { + return getCellContent([adjustedColIndex, rowIndex]); + }, + ); + }, + [getCellContent, isStringRowMarkers], + ); + + //////////////////////////////////////////////////////////////// + // Edition + //////////////////////////////////////////////////////////////// + + const handleCellEdited: DataEditorProps["onCellEdited"] = ( + location, + value, + ) => { + const [colIndex, rowIndex] = location; + + ifNotStringRowMarkers(colIndex, (adjustedColIndex) => { + onCellEdited?.([adjustedColIndex, rowIndex], value); + }); + }; + + const handleCellsEdited: DataEditorProps["onCellsEdited"] = (items) => { + if (onCellsEdited) { + const adjustedItems = items + .map((item) => { + const { location } = item; + const [colIndex, rowIndex] = location; + + return ifElseStringRowMarkers( + colIndex, + () => null, + (adjustedColIndex): EditListItem => { + return { ...item, location: [adjustedColIndex, rowIndex] }; + }, + ); + }) + .filter(Boolean); + + return onCellsEdited(adjustedItems); + } + }; + + //////////////////////////////////////////////////////////////// + // Resize + //////////////////////////////////////////////////////////////// + + const handleColumnResize: DataEditorProps["onColumnResize"] = + onColumnResize || enableColumnResize + ? (column, newSize, colIndex, newSizeWithGrow) => { + if (enableColumnResize) { + setColumns( + columns.map((col, index) => + index === colIndex ? { ...col, width: newSize } : col, + ), + ); + } + + if (onColumnResize) { + ifNotStringRowMarkers(colIndex, (adjustedColIndex) => { + onColumnResize( + column, + newSize, + adjustedColIndex, + newSizeWithGrow, + ); + }); + } + } + : undefined; + + const handleColumnResizeStart: DataEditorProps["onColumnResizeStart"] = + onColumnResizeStart + ? (column, newSize, colIndex, newSizeWithGrow) => { + ifNotStringRowMarkers(colIndex, (adjustedColIndex) => { + onColumnResizeStart( + column, + newSize, + adjustedColIndex, + newSizeWithGrow, + ); + }); + } + : undefined; + + const handleColumnResizeEnd: DataEditorProps["onColumnResizeEnd"] = + onColumnResizeEnd + ? (column, newSize, colIndex, newSizeWithGrow) => { + ifNotStringRowMarkers(colIndex, (adjustedColIndex) => { + onColumnResizeEnd( + column, + newSize, + adjustedColIndex, + newSizeWithGrow, + ); + }); + } + : undefined; + + //////////////////////////////////////////////////////////////// + // Selection + //////////////////////////////////////////////////////////////// + + const handleGridSelectionChange = (newSelection: GridSelection) => { + { + if (isStringRowMarkers) { + if (newSelection.current) { + // Select the whole row when clicking on a row marker cell + if ( + rowMarkersOptions.kind === "clickable-string" && + newSelection.current.cell[0] === 0 + ) { + setSelection({ + ...newSelection, + current: undefined, + rows: CompactSelection.fromSingleSelection( + newSelection.current.cell[1], + ), + }); + + return; + } + + // Prevent selecting a row marker cell + if (newSelection.current.range.x === 0) { + return; + } + } + + // Prevent selecting the row marker column + if (newSelection.columns.hasIndex(0)) { + // TODO find a way to have the rows length to select all the rows like other row markers + // setSelection({ + // ...newSelection, + // columns: CompactSelection.empty(), + // rows: CompactSelection.fromSingleSelection([ + // 0, + // // rowsLength + // ]), + // }); + + setSelection({ + ...newSelection, + columns: newSelection.columns.remove(0), + }); + + return; + } + } + + setSelection(newSelection); + } + }; + + //////////////////////////////////////////////////////////////// + // JSX + //////////////////////////////////////////////////////////////// + + return ( + + ); +} + +export default DataGrid; diff --git a/webapp/src/components/common/Matrix/components/MatrixGrid/MatrixGrid.test.tsx b/webapp/src/components/common/Matrix/components/MatrixGrid/MatrixGrid.test.tsx index 7f3f44aa7b..b1169ef3df 100644 --- a/webapp/src/components/common/Matrix/components/MatrixGrid/MatrixGrid.test.tsx +++ b/webapp/src/components/common/Matrix/components/MatrixGrid/MatrixGrid.test.tsx @@ -13,10 +13,8 @@ */ import { render } from "@testing-library/react"; -import userEvent from "@testing-library/user-event"; import Box from "@mui/material/Box"; import MatrixGrid, { MatrixGridProps } from "."; -import SplitView from "../../../SplitView"; import type { EnhancedGridColumn } from "../../shared/types"; import { mockGetBoundingClientRect } from "../../../../../tests/mocks/mockGetBoundingClientRect"; import { mockHTMLCanvasElement } from "../../../../../tests/mocks/mockHTMLCanvasElement"; @@ -146,86 +144,4 @@ describe("MatrixGrid", () => { assertDimensions(matrix, 300, 400); }); }); - - describe("portal management", () => { - const renderSplitView = () => { - return render( - - - {[0, 1].map((index) => ( - - - - ))} - - , - ); - }; - - const getPortal = () => document.getElementById("portal"); - - test("should manage portal visibility on mount", () => { - renderMatrixGrid(); - expect(getPortal()).toBeInTheDocument(); - expect(getPortal()?.style.display).toBe("none"); - }); - - test("should toggle portal visibility on mouse events", async () => { - const user = userEvent.setup(); - const { container } = renderMatrixGrid(); - const matrix = container.querySelector(".matrix-container"); - expect(matrix).toBeInTheDocument(); - - await user.hover(matrix!); - expect(getPortal()?.style.display).toBe("block"); - - await user.unhover(matrix!); - expect(getPortal()?.style.display).toBe("none"); - }); - - test("should handle portal in split view", async () => { - const user = userEvent.setup(); - renderSplitView(); - const matrices = document.querySelectorAll(".matrix-container"); - - // Test portal behavior with multiple matrices - await user.hover(matrices[0]); - expect(getPortal()?.style.display).toBe("block"); - - await user.hover(matrices[1]); - expect(getPortal()?.style.display).toBe("block"); - - await user.unhover(matrices[1]); - expect(getPortal()?.style.display).toBe("none"); - }); - - test("should maintain portal state when switching between matrices", async () => { - const user = userEvent.setup(); - renderSplitView(); - const matrices = document.querySelectorAll(".matrix-container"); - - for (const matrix of [matrices[0], matrices[1], matrices[0]]) { - await user.hover(matrix); - expect(getPortal()?.style.display).toBe("block"); - } - - await user.unhover(matrices[0]); - expect(getPortal()?.style.display).toBe("none"); - }); - - test("should handle unmounting correctly", () => { - const { unmount } = renderSplitView(); - expect(getPortal()).toBeInTheDocument(); - - unmount(); - expect(getPortal()).toBeInTheDocument(); - expect(getPortal()?.style.display).toBe("none"); - }); - }); }); diff --git a/webapp/src/components/common/Matrix/components/MatrixGrid/index.tsx b/webapp/src/components/common/Matrix/components/MatrixGrid/index.tsx index 688b647cc1..c7956c92ac 100644 --- a/webapp/src/components/common/Matrix/components/MatrixGrid/index.tsx +++ b/webapp/src/components/common/Matrix/components/MatrixGrid/index.tsx @@ -12,31 +12,27 @@ * This file is part of the Antares project. */ -import "@glideapps/glide-data-grid/dist/index.css"; -import DataEditor, { - CompactSelection, - EditableGridCell, - EditListItem, +import { GridCellKind, - GridColumn, - GridSelection, - Item, + type EditableGridCell, + type EditListItem, + type Item, } from "@glideapps/glide-data-grid"; import { useGridCellContent } from "../../hooks/useGridCellContent"; -import { useMemo, useState } from "react"; +import { useMemo } from "react"; import { type EnhancedGridColumn, type GridUpdate, type MatrixAggregates, } from "../../shared/types"; import { useColumnMapping } from "../../hooks/useColumnMapping"; -import { useMatrixPortal } from "../../hooks/useMatrixPortal"; import { darkTheme, readOnlyDarkTheme } from "../../styles"; +import DataGrid from "@/components/common/DataGrid"; export interface MatrixGridProps { data: number[][]; rows: number; - columns: EnhancedGridColumn[]; + columns: readonly EnhancedGridColumn[]; dateTime?: string[]; aggregates?: Partial; rowHeaders?: string[]; @@ -51,7 +47,7 @@ export interface MatrixGridProps { function MatrixGrid({ data, rows, - columns: initialColumns, + columns, dateTime, aggregates, rowHeaders, @@ -62,23 +58,8 @@ function MatrixGrid({ readOnly, showPercent, }: MatrixGridProps) { - const [columns, setColumns] = useState(initialColumns); - const [selection, setSelection] = useState({ - columns: CompactSelection.empty(), - rows: CompactSelection.empty(), - }); - const { gridToData } = useColumnMapping(columns); - // Due to a current limitation of Glide Data Grid, only one id="portal" is active on the DOM - // This is an issue on splited matrices, the second matrix does not have an id="portal" - // Causing the overlay editor to not behave correctly on click - // This hook manage portal creation and cleanup for matrices in split views - // TODO: add a prop to detect matrices in split views and enable this conditionnaly - // !Workaround: a proper solution should be replacing this in the future - const { containerRef, handleMouseEnter, handleMouseLeave } = - useMatrixPortal(); - const theme = useMemo(() => { if (readOnly) { return { @@ -105,19 +86,6 @@ function MatrixGrid({ // Event Handlers //////////////////////////////////////////////////////////////// - const handleColumnResize = ( - column: GridColumn, - newSize: number, - colIndex: number, - newSizeWithGrow: number, - ) => { - const newColumns = columns.map((col, index) => - index === colIndex ? { ...col, width: newSize } : col, - ); - - setColumns(newColumns); - }; - const handleCellEdited = (coordinates: Item, value: EditableGridCell) => { if (value.kind !== GridCellKind.Number) { // Invalid numeric value @@ -172,41 +140,23 @@ function MatrixGrid({ //////////////////////////////////////////////////////////////// return ( - <> -
- -
- + ); } diff --git a/webapp/src/components/common/Matrix/components/MatrixGridSynthesis/index.tsx b/webapp/src/components/common/Matrix/components/MatrixGridSynthesis/index.tsx index ad56f83b49..f7f099ddd4 100644 --- a/webapp/src/components/common/Matrix/components/MatrixGridSynthesis/index.tsx +++ b/webapp/src/components/common/Matrix/components/MatrixGridSynthesis/index.tsx @@ -12,16 +12,17 @@ * This file is part of the Antares project. */ -import DataEditor, { +import { GridCellKind, - GridColumn, - Item, - NumberCell, - TextCell, + type GridColumn, + type Item, + type NumberCell, + type TextCell, } from "@glideapps/glide-data-grid"; import { useMemo } from "react"; import { darkTheme, readOnlyDarkTheme } from "../../styles"; import { formatGridNumber } from "../../shared/utils"; +import DataGrid from "@/components/common/DataGrid"; type CellValue = number | string; @@ -81,17 +82,13 @@ export function MatrixGridSynthesis({ return (
-
diff --git a/webapp/src/components/common/Matrix/hooks/useColumnMapping/index.ts b/webapp/src/components/common/Matrix/hooks/useColumnMapping/index.ts index b9ceb95ebf..6b16f75c3f 100644 --- a/webapp/src/components/common/Matrix/hooks/useColumnMapping/index.ts +++ b/webapp/src/components/common/Matrix/hooks/useColumnMapping/index.ts @@ -46,7 +46,7 @@ import { Column } from "../../shared/constants"; * - dataToGrid: (dataCoord: Item) => Item * Converts data coordinates to grid coordinates. */ -export function useColumnMapping(columns: EnhancedGridColumn[]) { +export function useColumnMapping(columns: readonly EnhancedGridColumn[]) { return useMemo(() => { const dataColumnIndices = columns.reduce((acc, col, index) => { if (col.type === Column.Number) { diff --git a/webapp/src/components/common/Matrix/hooks/useGridCellContent/index.ts b/webapp/src/components/common/Matrix/hooks/useGridCellContent/index.ts index 7b12319381..3052c93ad1 100644 --- a/webapp/src/components/common/Matrix/hooks/useGridCellContent/index.ts +++ b/webapp/src/components/common/Matrix/hooks/useGridCellContent/index.ts @@ -108,7 +108,7 @@ const cellContentGenerators: Record = { */ export function useGridCellContent( data: number[][], - columns: EnhancedGridColumn[], + columns: readonly EnhancedGridColumn[], gridToData: (cell: Item) => Item | null, dateTime?: string[], aggregates?: Partial, diff --git a/webapp/src/components/common/Matrix/hooks/useMatrixPortal/index.ts b/webapp/src/components/common/Matrix/hooks/useMatrixPortal/index.ts deleted file mode 100644 index c6174d39d2..0000000000 --- a/webapp/src/components/common/Matrix/hooks/useMatrixPortal/index.ts +++ /dev/null @@ -1,76 +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 { useEffect, useRef, useState } from "react"; - -/** - * Custom hook to manage portal creation and cleanup for matrices in split views. - * This hook is specifically designed to work around a limitation where multiple - * Glide Data Grid instances compete for a single portal with id="portal". - * - * @returns Hook handlers and ref to be applied to the matrix container - * - * @example - * ```tsx - * function MatrixGrid() { - * const { containerRef, handleMouseEnter, handleMouseLeave } = useMatrixPortal(); - * - * return ( - *
- * - *
- * ); - * } - * ``` - */ -export function useMatrixPortal() { - const [isActive, setIsActive] = useState(false); - const containerRef = useRef(null); - - useEffect(() => { - let portal = document.getElementById("portal"); - - if (!portal) { - portal = document.createElement("div"); - portal.id = "portal"; - portal.style.position = "fixed"; - portal.style.left = "0"; - portal.style.top = "0"; - portal.style.zIndex = "9999"; - portal.style.display = "none"; - document.body.appendChild(portal); - } - - // Update visibility based on active state - if (containerRef.current && isActive) { - portal.style.display = "block"; - } else { - portal.style.display = "none"; - } - }, [isActive]); - - const handleMouseEnter = () => { - setIsActive(true); - }; - - const handleMouseLeave = () => { - setIsActive(false); - }; - - return { - containerRef, - handleMouseEnter, - handleMouseLeave, - }; -} diff --git a/webapp/src/components/common/Matrix/hooks/useMatrixPortal/useMatrixPortal.test.tsx b/webapp/src/components/common/Matrix/hooks/useMatrixPortal/useMatrixPortal.test.tsx deleted file mode 100644 index 8b3bdfda32..0000000000 --- a/webapp/src/components/common/Matrix/hooks/useMatrixPortal/useMatrixPortal.test.tsx +++ /dev/null @@ -1,153 +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 { cleanup, render, screen } from "@testing-library/react"; -import userEvent from "@testing-library/user-event"; -import { useMatrixPortal } from "../useMatrixPortal"; - -function NonRefComponent() { - return
Test Container
; -} - -function TestComponent() { - const { containerRef, handleMouseEnter, handleMouseLeave } = - useMatrixPortal(); - return ( -
- Test Container -
- ); -} - -describe("useMatrixPortal", () => { - beforeEach(() => { - cleanup(); - const existingPortal = document.getElementById("portal"); - if (existingPortal) { - existingPortal.remove(); - } - }); - - test("should create hidden portal initially", () => { - render(); - const portal = document.getElementById("portal"); - expect(portal).toBeInTheDocument(); - expect(portal?.style.display).toBe("none"); - }); - - test("should show portal with correct styles when mouse enters", async () => { - const user = userEvent.setup(); - render(); - - const container = screen.getByTestId("ref-container"); - await user.hover(container); - - const portal = document.getElementById("portal"); - expect(portal).toBeInTheDocument(); - expect(portal?.style.display).toBe("block"); - expect(portal?.style.position).toBe("fixed"); - expect(portal?.style.left).toBe("0px"); - expect(portal?.style.top).toBe("0px"); - expect(portal?.style.zIndex).toBe("9999"); - }); - - test("should hide portal when mouse leaves", async () => { - const user = userEvent.setup(); - render(); - - const container = screen.getByTestId("ref-container"); - - // Show portal - await user.hover(container); - expect(document.getElementById("portal")?.style.display).toBe("block"); - - // Hide portal - await user.unhover(container); - expect(document.getElementById("portal")?.style.display).toBe("none"); - }); - - test("should keep portal hidden if containerRef is null", async () => { - const user = userEvent.setup(); - - // First render test component to create portal - render(); - const portal = document.getElementById("portal"); - expect(portal?.style.display).toBe("none"); - - cleanup(); // Clean up the test component - - // Then render component without ref - render(); - const container = screen.getByTestId("non-ref-container"); - await user.hover(container); - - // Portal should stay hidden - expect(portal?.style.display).toBe("none"); - }); - - test("should handle multiple mouse enter/leave cycles", async () => { - const user = userEvent.setup(); - render(); - - const container = screen.getByTestId("ref-container"); - const portal = document.getElementById("portal"); - - // First cycle - await user.hover(container); - expect(portal?.style.display).toBe("block"); - - await user.unhover(container); - expect(portal?.style.display).toBe("none"); - - // Second cycle - await user.hover(container); - expect(portal?.style.display).toBe("block"); - - await user.unhover(container); - expect(portal?.style.display).toBe("none"); - }); - - test("should handle rapid mouse events", async () => { - const user = userEvent.setup(); - render(); - - const container = screen.getByTestId("ref-container"); - const portal = document.getElementById("portal"); - - // Rapid sequence - await user.hover(container); - await user.unhover(container); - await user.hover(container); - - expect(portal?.style.display).toBe("block"); - }); - - test("should maintain portal existence across multiple component instances", () => { - // Render first instance - const { unmount } = render(); - expect(document.getElementById("portal")).toBeInTheDocument(); - - // Unmount first instance - unmount(); - - // Render second instance - render(); - expect(document.getElementById("portal")).toBeInTheDocument(); - }); -}); From 26398acf321cc6edcb0227aa6117968f3eb883e4 Mon Sep 17 00:00:00 2001 From: Samir Kamal <1954121+skamril@users.noreply.github.com> Date: Fri, 10 Jan 2025 09:36:48 +0100 Subject: [PATCH 6/9] feat(ui-common): create DataGridForm component --- webapp/src/components/common/DataGridForm.tsx | 320 ++++++++++++++++++ 1 file changed, 320 insertions(+) create mode 100644 webapp/src/components/common/DataGridForm.tsx diff --git a/webapp/src/components/common/DataGridForm.tsx b/webapp/src/components/common/DataGridForm.tsx new file mode 100644 index 0000000000..1ae6c8064c --- /dev/null +++ b/webapp/src/components/common/DataGridForm.tsx @@ -0,0 +1,320 @@ +/** + * 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 type { IdType } from "@/common/types"; +import { + GridCellKind, + type Item, + type DataEditorProps, + type GridColumn, + type FillHandleDirection, +} from "@glideapps/glide-data-grid"; +import type { DeepPartial } from "react-hook-form"; +import { FormEvent, useCallback, useState } from "react"; +import DataGrid from "./DataGrid"; +import { + Box, + Divider, + IconButton, + SxProps, + Theme, + Tooltip, +} from "@mui/material"; +import useUndo from "use-undo"; +import UndoIcon from "@mui/icons-material/Undo"; +import RedoIcon from "@mui/icons-material/Redo"; +import SaveIcon from "@mui/icons-material/Save"; +import { useTranslation } from "react-i18next"; +import { LoadingButton } from "@mui/lab"; +import { mergeSxProp } from "@/utils/muiUtils"; +import * as R from "ramda"; +import { SubmitHandlerPlus } from "./Form/types"; +import useEnqueueErrorSnackbar from "@/hooks/useEnqueueErrorSnackbar"; +import useFormCloseProtection from "@/hooks/useCloseFormSecurity"; + +type GridFieldValuesByRow = Record< + IdType, + Record +>; + +export interface DataGridFormProps< + TFieldValues extends GridFieldValuesByRow = GridFieldValuesByRow, + SubmitReturnValue = unknown, +> { + defaultData: TFieldValues; + columns: ReadonlyArray; + allowedFillDirections?: FillHandleDirection; + onSubmit?: ( + data: SubmitHandlerPlus, + event?: React.BaseSyntheticEvent, + ) => void | Promise; + onSubmitSuccessful?: ( + data: SubmitHandlerPlus, + submitResult: SubmitReturnValue, + ) => void; + sx?: SxProps; + extraActions?: React.ReactNode; +} + +function DataGridForm({ + defaultData, + columns, + allowedFillDirections = "vertical", + onSubmit, + onSubmitSuccessful, + sx, + extraActions, +}: DataGridFormProps) { + const { t } = useTranslation(); + const enqueueErrorSnackbar = useEnqueueErrorSnackbar(); + const [isSubmitting, setIsSubmitting] = useState(false); + const [savedData, setSavedData] = useState(defaultData); + const [{ present: data }, { set, undo, redo, canUndo, canRedo }] = + useUndo(defaultData); + + const isSubmitAllowed = savedData !== data; + + useFormCloseProtection({ + isSubmitting, + isDirty: isSubmitAllowed, + }); + + //////////////////////////////////////////////////////////////// + // Utils + //////////////////////////////////////////////////////////////// + + const getRowAndColumnNames = (location: Item) => { + const [colIndex, rowIndex] = location; + const rowNames = Object.keys(data); + const columnIds = columns.map((column) => column.id); + + return [rowNames[rowIndex], columnIds[colIndex]]; + }; + + const getDirtyValues = () => { + return Object.keys(data).reduce((acc, rowName) => { + const rowData = data[rowName]; + const savedRowData = savedData[rowName]; + + const dirtyColumns = Object.keys(rowData).filter( + (columnName) => rowData[columnName] !== savedRowData[columnName], + ); + + if (dirtyColumns.length > 0) { + return { + ...acc, + [rowName]: dirtyColumns.reduce( + (acc, columnName) => ({ + ...acc, + [columnName]: rowData[columnName], + }), + {}, + ), + }; + } + + return acc; + }, {} as DeepPartial); + }; + + //////////////////////////////////////////////////////////////// + // Callbacks + //////////////////////////////////////////////////////////////// + + const getCellContent = useCallback( + (location) => { + const [rowName, columnName] = getRowAndColumnNames(location); + const dataRow = data[rowName]; + const cellData = dataRow?.[columnName]; + + if (typeof cellData == "string") { + return { + kind: GridCellKind.Text, + data: cellData, + displayData: cellData, + allowOverlay: true, + }; + } + + if (typeof cellData == "number") { + return { + kind: GridCellKind.Number, + data: cellData, + displayData: cellData.toString(), + allowOverlay: true, + contentAlign: "right", + thousandSeparator: " ", + }; + } + + if (typeof cellData == "boolean") { + return { + kind: GridCellKind.Boolean, + data: cellData, + allowOverlay: false, + }; + } + + return { + kind: GridCellKind.Text, + data: "", + displayData: "", + allowOverlay: true, + readonly: true, + }; + }, + [data, columns], + ); + + //////////////////////////////////////////////////////////////// + // Event Handlers + //////////////////////////////////////////////////////////////// + + const handleCellsEdited: DataEditorProps["onCellsEdited"] = (items) => { + set( + items.reduce((acc, { location, value }) => { + const [rowName, columnName] = getRowAndColumnNames(location); + const newValue = value.data; + + if (R.isNotNil(newValue)) { + return { + ...acc, + [rowName]: { + ...acc[rowName], + [columnName]: newValue, + }, + }; + } + + return acc; + }, data), + ); + + return true; + }; + + const handleFormSubmit = (event: FormEvent) => { + event.preventDefault(); + + if (!onSubmit) { + return; + } + + setIsSubmitting(true); + + const dataArg = { + values: data, + dirtyValues: getDirtyValues(), + }; + + Promise.resolve(onSubmit(dataArg, event)) + .then((submitRes) => { + setSavedData(data); + onSubmitSuccessful?.(dataArg, submitRes); + }) + .catch((err) => { + enqueueErrorSnackbar(t("form.submit.error"), err); + }) + .finally(() => { + setIsSubmitting(false); + }); + }; + + //////////////////////////////////////////////////////////////// + // JSX + //////////////////////////////////////////////////////////////// + + return ( + + Object.keys(data)[index], + }} + fillHandle + allowedFillDirections={allowedFillDirections} + getCellsForSelection + /> + + + + } + > + {t("global.save")} + + + + + + + + + + + + + + + + + {extraActions && ( + + {extraActions} + + )} + + + + ); +} + +export default DataGridForm; From 63bfe448d0593e7bc3e3d587fae1555d279199f9 Mon Sep 17 00:00:00 2001 From: Samir Kamal <1954121+skamril@users.noreply.github.com> Date: Fri, 10 Jan 2025 09:44:42 +0100 Subject: [PATCH 7/9] feat(ui-tablemode): replace Handsontable and remove context menu --- .../explore/TableModeList/index.tsx | 74 +++++++++++-------- .../explore/common/ListElement.tsx | 58 --------------- webapp/src/components/common/Form/types.ts | 1 - webapp/src/components/common/TableMode.tsx | 71 +++++++++++------- 4 files changed, 89 insertions(+), 115 deletions(-) diff --git a/webapp/src/components/App/Singlestudy/explore/TableModeList/index.tsx b/webapp/src/components/App/Singlestudy/explore/TableModeList/index.tsx index 1f381e17bc..c281d9c774 100644 --- a/webapp/src/components/App/Singlestudy/explore/TableModeList/index.tsx +++ b/webapp/src/components/App/Singlestudy/explore/TableModeList/index.tsx @@ -13,10 +13,11 @@ */ import { useEffect, useState } from "react"; -import { MenuItem } from "@mui/material"; +import { Button } from "@mui/material"; import { useOutletContext } from "react-router"; import { useUpdateEffect } from "react-use"; import { useTranslation } from "react-i18next"; +import EditIcon from "@mui/icons-material/Edit"; import DeleteIcon from "@mui/icons-material/Delete"; import { v4 as uuidv4 } from "uuid"; import PropertiesView from "../../../../common/PropertiesView"; @@ -84,7 +85,25 @@ function TableModeList() { // Event Handlers //////////////////////////////////////////////////////////////// - const handleDeleteTemplate = () => { + const handleEditClick = () => { + if (selectedTemplate) { + setDialog({ + type: "edit", + templateId: selectedTemplate.id, + }); + } + }; + + const handleDeleteClick = () => { + if (selectedTemplate) { + setDialog({ + type: "delete", + templateId: selectedTemplate.id, + }); + } + }; + + const handleDelete = () => { setTemplates((templates) => templates.filter((tp) => tp.id !== dialog?.templateId), ); @@ -106,34 +125,6 @@ function TableModeList() { currentElement={selectedTemplate?.id} currentElementKeyToTest="id" setSelectedItem={({ id }) => setSelectedTemplateId(id)} - contextMenuContent={({ element, close }) => ( - <> - { - event.stopPropagation(); - setDialog({ - type: "edit", - templateId: element.id, - }); - close(); - }} - > - Edit - - { - event.stopPropagation(); - setDialog({ - type: "delete", - templateId: element.id, - }); - close(); - }} - > - Delete - - - )} /> } onAdd={() => setDialog({ type: "add", templateId: "" })} @@ -148,6 +139,27 @@ function TableModeList() { studyId={study.id} type={selectedTemplate.type} columns={selectedTemplate.columns} + extraActions={ + <> + + + + } /> )} @@ -173,7 +185,7 @@ function TableModeList() { diff --git a/webapp/src/components/App/Singlestudy/explore/common/ListElement.tsx b/webapp/src/components/App/Singlestudy/explore/common/ListElement.tsx index 69b9254d61..5e799e1928 100644 --- a/webapp/src/components/App/Singlestudy/explore/common/ListElement.tsx +++ b/webapp/src/components/App/Singlestudy/explore/common/ListElement.tsx @@ -17,14 +17,11 @@ import { ListItemButton, ListItemIcon, ListItemText, - Menu, - PopoverPosition, SxProps, Theme, Tooltip, } from "@mui/material"; import ArrowRightOutlinedIcon from "@mui/icons-material/ArrowRightOutlined"; -import { useState } from "react"; import { IdType } from "../../../../../common/types"; import { mergeSxProp } from "../../../../../utils/muiUtils"; @@ -33,10 +30,6 @@ interface Props { currentElement?: string; currentElementKeyToTest?: keyof T; setSelectedItem: (item: T, index: number) => void; - contextMenuContent?: (props: { - element: T; - close: VoidFunction; - }) => React.ReactElement; sx?: SxProps; } @@ -45,43 +38,8 @@ function ListElement({ currentElement, currentElementKeyToTest, setSelectedItem, - contextMenuContent: ContextMenuContent, sx, }: Props) { - const [contextMenuPosition, setContextMenuPosition] = - useState(null); - const [elementForContext, setElementForContext] = useState(); - - //////////////////////////////////////////////////////////////// - // Event Handlers - //////////////////////////////////////////////////////////////// - - const handleContextMenu = (element: T) => (event: React.MouseEvent) => { - event.preventDefault(); - - if (!ContextMenuContent) { - return; - } - - setElementForContext(element); - - setContextMenuPosition( - contextMenuPosition === null - ? { - left: event.clientX + 2, - top: event.clientY - 6, - } - : // Repeated context menu when it is already open closes it with Chrome 84 on Ubuntu - // Other native context menus might behave different. - // With this behavior we prevent contextmenu from the backdrop to re-locale existing context menus. - null, - ); - }; - - //////////////////////////////////////////////////////////////// - // JSX - //////////////////////////////////////////////////////////////// - return ( ({ justifyContent: "space-between", py: 0, }} - onContextMenu={handleContextMenu(element)} > ({ > - {ContextMenuContent && elementForContext && ( - setContextMenuPosition(null)} - anchorReference="anchorPosition" - anchorPosition={ - contextMenuPosition !== null ? contextMenuPosition : undefined - } - > - setContextMenuPosition(null)} - /> - - )} ))} diff --git a/webapp/src/components/common/Form/types.ts b/webapp/src/components/common/Form/types.ts index d7f27f94fb..a09272e8f1 100644 --- a/webapp/src/components/common/Form/types.ts +++ b/webapp/src/components/common/Form/types.ts @@ -26,7 +26,6 @@ import { } from "react-hook-form"; export interface SubmitHandlerPlus< - // TODO Make parameter required TFieldValues extends FieldValues = FieldValues, > { values: TFieldValues; diff --git a/webapp/src/components/common/TableMode.tsx b/webapp/src/components/common/TableMode.tsx index 1809a93377..1f5bdf2d8f 100644 --- a/webapp/src/components/common/TableMode.tsx +++ b/webapp/src/components/common/TableMode.tsx @@ -25,47 +25,64 @@ import { TableModeType, } from "../../services/api/studies/tableMode/types"; import { SubmitHandlerPlus } from "./Form/types"; -import TableForm from "./TableForm"; import UsePromiseCond from "./utils/UsePromiseCond"; import GridOffIcon from "@mui/icons-material/GridOff"; import EmptyView from "./page/EmptyView"; import { useTranslation } from "react-i18next"; +import DataGridForm, { type DataGridFormProps } from "./DataGridForm"; +import { startCase } from "lodash"; +import type { GridColumn } from "@glideapps/glide-data-grid"; export interface TableModeProps { studyId: StudyMetadata["id"]; type: T; columns: TableModeColumnsForType; + extraActions?: React.ReactNode; } -function TableMode(props: TableModeProps) { - const { studyId, type, columns } = props; - const [filteredColumns, setFilteredColumns] = useState(columns); +function TableMode({ + studyId, + type, + columns, + extraActions, +}: TableModeProps) { const { t } = useTranslation(); + const [gridColumns, setGridColumns] = useState< + DataGridFormProps["columns"] + >([]); + const columnsDep = columns.join(","); const res = usePromise( () => getTableMode({ studyId, tableType: type, columns }), - [studyId, type, columns.join(",")], + [studyId, type, columnsDep], ); // Filter columns based on the data received, because the API may return // fewer columns than requested depending on the study version - useEffect( - () => { - const dataKeys = Object.keys(res.data || {}); + useEffect(() => { + const data = res.data || {}; + const rowNames = Object.keys(data); - if (dataKeys.length === 0) { - setFilteredColumns([]); - return; - } + if (rowNames.length === 0) { + setGridColumns([]); + return; + } - const data = res.data!; - const dataRowKeys = Object.keys(data[dataKeys[0]]); + const columnNames = Object.keys(data[rowNames[0]]); - setFilteredColumns(columns.filter((col) => dataRowKeys.includes(col))); - }, - // eslint-disable-next-line react-hooks/exhaustive-deps - [res.data, columns.join(",")], - ); + setGridColumns( + columns + .filter((col) => columnNames.includes(col)) + .map((col) => { + const title = startCase(col); + return { + title, + id: col, + width: title.length * 10, + } satisfies GridColumn; + }), + ); + }, [res.data, columnsDep]); //////////////////////////////////////////////////////////////// // Event Handlers @@ -83,15 +100,19 @@ function TableMode(props: TableModeProps) { - filteredColumns.length > 0 ? ( - 0 ? ( + ) : ( - + ) } /> From 3f957770b1f29bf5ddcebd0818aa469ed965c02f Mon Sep 17 00:00:00 2001 From: Samir Kamal <1954121+skamril@users.noreply.github.com> Date: Mon, 13 Jan 2025 13:50:27 +0100 Subject: [PATCH 8/9] feat(ui-playlist): replace Handsontable --- webapp/public/locales/en/main.json | 7 +- webapp/public/locales/fr/main.json | 9 +- .../dialogs/ScenarioPlaylistDialog.tsx | 192 ++++++++++++++++++ .../dialogs/ScenarioPlaylistDialog/index.tsx | 156 -------------- .../dialogs/ScenarioPlaylistDialog/utils.ts | 43 ---- .../TimeSeriesManagement/index.tsx | 2 +- webapp/src/components/common/DataGridForm.tsx | 131 +++++++----- webapp/src/components/common/Form/index.tsx | 2 +- webapp/src/hooks/useConfirm.ts | 30 ++- .../api/studies/config/playlist/constants.ts | 15 ++ .../api/studies/config/playlist/index.ts | 36 ++++ .../api/studies/config/playlist/types.ts | 28 +++ 12 files changed, 390 insertions(+), 261 deletions(-) create mode 100644 webapp/src/components/App/Singlestudy/explore/Configuration/General/dialogs/ScenarioPlaylistDialog.tsx delete mode 100644 webapp/src/components/App/Singlestudy/explore/Configuration/General/dialogs/ScenarioPlaylistDialog/index.tsx delete mode 100644 webapp/src/components/App/Singlestudy/explore/Configuration/General/dialogs/ScenarioPlaylistDialog/utils.ts create mode 100644 webapp/src/services/api/studies/config/playlist/constants.ts create mode 100644 webapp/src/services/api/studies/config/playlist/index.ts create mode 100644 webapp/src/services/api/studies/config/playlist/types.ts diff --git a/webapp/public/locales/en/main.json b/webapp/public/locales/en/main.json index 0368e65fcc..2873e51239 100644 --- a/webapp/public/locales/en/main.json +++ b/webapp/public/locales/en/main.json @@ -80,6 +80,7 @@ "global.semicolon": "Semicolon", "global.language": "Language", "global.path": "Path", + "global.weight": "Weight", "global.time.hourly": "Hourly", "global.time.daily": "Daily", "global.time.weekly": "Weekly", @@ -136,8 +137,8 @@ "maintenance.error.messageInfoError": "Unable to retrieve message info", "maintenance.error.maintenanceError": "Unable to retrieve maintenance status", "form.submit.error": "Error while submitting", - "form.submit.inProgress": "The form is being submitted. Are you sure you want to leave the page?", - "form.changeNotSaved": "The form has not been saved. Are you sure you want to leave the page?", + "form.submit.inProgress": "The form is being submitted. Are you sure you want to close it?", + "form.changeNotSaved": "The form has not been saved. Are you sure you want to close it?", "form.asyncDefaultValues.error": "Failed to get values", "form.field.required": "Field required", "form.field.duplicate": "Value already exists", @@ -361,8 +362,6 @@ "study.configuration.general.mcScenarioPlaylist.action.disableAll": "Disable all", "study.configuration.general.mcScenarioPlaylist.action.reverse": "Reverse", "study.configuration.general.mcScenarioPlaylist.action.resetWeights": "Reset weights", - "study.configuration.general.mcScenarioPlaylist.status": "Status", - "study.configuration.general.mcScenarioPlaylist.weight": "Weight", "study.configuration.general.geographicTrimming": "Geographic trimming", "study.configuration.general.thematicTrimming": "Thematic trimming", "study.configuration.general.thematicTrimming.action.enableAll": "Enable all", diff --git a/webapp/public/locales/fr/main.json b/webapp/public/locales/fr/main.json index ad6ba562bf..0cf1689f75 100644 --- a/webapp/public/locales/fr/main.json +++ b/webapp/public/locales/fr/main.json @@ -80,6 +80,7 @@ "global.semicolon": "Point-virgule", "global.language": "Langue", "global.path": "Chemin", + "global.weight": "Poids", "global.time.hourly": "Horaire", "global.time.daily": "Journalier", "global.time.weekly": "Hebdomadaire", @@ -136,8 +137,8 @@ "maintenance.error.messageInfoError": "Impossible de récupérer le message d'info", "maintenance.error.maintenanceError": "Impossible de récupérer le status de maintenance de l'application", "form.submit.error": "Erreur lors de la soumission", - "form.submit.inProgress": "Le formulaire est en cours de soumission. Etes-vous sûr de vouloir quitter la page ?", - "form.changeNotSaved": "Le formulaire n'a pas été sauvegardé. Etes-vous sûr de vouloir quitter la page ?", + "form.submit.inProgress": "Le formulaire est en cours de soumission. Etes-vous sûr de vouloir le fermer ?", + "form.changeNotSaved": "Le formulaire n'a pas été sauvegardé. Etes-vous sûr de vouloir le fermer ?", "form.asyncDefaultValues.error": "Impossible d'obtenir les valeurs", "form.field.required": "Champ requis", "form.field.duplicate": "Cette valeur existe déjà", @@ -360,9 +361,7 @@ "study.configuration.general.mcScenarioPlaylist.action.enableAll": "Activer tous", "study.configuration.general.mcScenarioPlaylist.action.disableAll": "Désactiver tous", "study.configuration.general.mcScenarioPlaylist.action.reverse": "Inverser", - "study.configuration.general.mcScenarioPlaylist.action.resetWeights": "Reset weights", - "study.configuration.general.mcScenarioPlaylist.status": "Status", - "study.configuration.general.mcScenarioPlaylist.weight": "Weight", + "study.configuration.general.mcScenarioPlaylist.action.resetWeights": "Réinitialiser les poids", "study.configuration.general.geographicTrimming": "Geographic trimming", "study.configuration.general.thematicTrimming": "Thematic trimming", "study.configuration.general.thematicTrimming.action.enableAll": "Activer tout", diff --git a/webapp/src/components/App/Singlestudy/explore/Configuration/General/dialogs/ScenarioPlaylistDialog.tsx b/webapp/src/components/App/Singlestudy/explore/Configuration/General/dialogs/ScenarioPlaylistDialog.tsx new file mode 100644 index 0000000000..b0a237eb0a --- /dev/null +++ b/webapp/src/components/App/Singlestudy/explore/Configuration/General/dialogs/ScenarioPlaylistDialog.tsx @@ -0,0 +1,192 @@ +/** + * 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 { Button, ButtonGroup, Divider } from "@mui/material"; +import { useMemo, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; +import * as R from "ramda"; +import * as RA from "ramda-adjunct"; +import type { StudyMetadata } from "../../../../../../../common/types"; +import usePromise from "../../../../../../../hooks/usePromise"; +import BasicDialog from "../../../../../../common/dialogs/BasicDialog"; +import UsePromiseCond from "../../../../../../common/utils/UsePromiseCond"; +import type { SubmitHandlerPlus } from "../../../../../../common/Form/types"; +import DataGridForm, { + DataGridFormState, + type DataGridFormApi, +} from "@/components/common/DataGridForm"; +import ConfirmationDialog from "@/components/common/dialogs/ConfirmationDialog"; +import useConfirm from "@/hooks/useConfirm"; +import type { PlaylistData } from "@/services/api/studies/config/playlist/types"; +import { + getPlaylistData, + setPlaylistData, +} from "@/services/api/studies/config/playlist"; +import { DEFAULT_WEIGHT } from "@/services/api/studies/config/playlist/constants"; + +interface Props { + study: StudyMetadata; + open: boolean; + onClose: VoidFunction; +} + +function ScenarioPlaylistDialog(props: Props) { + const { study, open, onClose } = props; + const { t } = useTranslation(); + const dataGridApiRef = useRef>(null); + const [isSubmitting, setIsSubmitting] = useState(false); + const [isDirty, setIsDirty] = useState(false); + const closeAction = useConfirm(); + const res = usePromise( + () => getPlaylistData({ studyId: study.id }), + [study.id], + ); + + const columns = useMemo(() => { + return [ + { + id: "status" as const, + title: t("global.status"), + grow: 1, + }, + { + id: "weight" as const, + title: t("global.weight"), + grow: 1, + }, + ]; + }, [t]); + + //////////////////////////////////////////////////////////////// + // Event Handlers + //////////////////////////////////////////////////////////////// + + const handleUpdateStatus = (fn: RA.Pred) => () => { + if (dataGridApiRef.current) { + const { data, setData } = dataGridApiRef.current; + setData(R.map(R.evolve({ status: fn }), data)); + } + }; + + const handleResetWeights = () => { + if (dataGridApiRef.current) { + const { data, setData } = dataGridApiRef.current; + setData(R.map(R.assoc("weight", DEFAULT_WEIGHT), data)); + } + }; + + const handleSubmit = (data: SubmitHandlerPlus) => { + return setPlaylistData({ studyId: study.id, data: data.values }); + }; + + const handleClose = () => { + if (isSubmitting) { + return; + } + + if (isDirty) { + return closeAction.showConfirm().then((confirm) => { + if (confirm) { + onClose(); + } + }); + } + + onClose(); + }; + + const handleFormStateChange = (formState: DataGridFormState) => { + setIsSubmitting(formState.isSubmitting); + setIsDirty(formState.isDirty); + }; + + //////////////////////////////////////////////////////////////// + // JSX + //////////////////////////////////////////////////////////////// + + return ( + + {t("global.close")} + + } + PaperProps={{ sx: { height: 700 } }} + maxWidth="md" + fullWidth + > + ( + <> + + + + + + + + + `MC Year ${index + 1}`, + }} + onSubmit={handleSubmit} + onStateChange={handleFormStateChange} + apiRef={dataGridApiRef} + enableColumnResize={false} + /> + + {t("form.changeNotSaved")} + + + )} + /> + + ); +} + +export default ScenarioPlaylistDialog; diff --git a/webapp/src/components/App/Singlestudy/explore/Configuration/General/dialogs/ScenarioPlaylistDialog/index.tsx b/webapp/src/components/App/Singlestudy/explore/Configuration/General/dialogs/ScenarioPlaylistDialog/index.tsx deleted file mode 100644 index abb04d0a88..0000000000 --- a/webapp/src/components/App/Singlestudy/explore/Configuration/General/dialogs/ScenarioPlaylistDialog/index.tsx +++ /dev/null @@ -1,156 +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 { Box, Button, Divider } from "@mui/material"; -import { useRef } from "react"; -import { useTranslation } from "react-i18next"; -import * as R from "ramda"; -import * as RA from "ramda-adjunct"; -import Handsontable from "handsontable"; -import { StudyMetadata } from "../../../../../../../../common/types"; -import usePromise from "../../../../../../../../hooks/usePromise"; -import BasicDialog from "../../../../../../../common/dialogs/BasicDialog"; -import TableForm from "../../../../../../../common/TableForm"; -import UsePromiseCond from "../../../../../../../common/utils/UsePromiseCond"; -import { - DEFAULT_WEIGHT, - getPlaylist, - PlaylistData, - setPlaylist, -} from "./utils"; -import { SubmitHandlerPlus } from "../../../../../../../common/Form/types"; -import { - HandsontableProps, - HotTableClass, -} from "../../../../../../../common/Handsontable"; - -interface Props { - study: StudyMetadata; - open: boolean; - onClose: VoidFunction; -} - -function ScenarioPlaylistDialog(props: Props) { - const { study, open, onClose } = props; - const { t } = useTranslation(); - const tableRef = useRef({} as HotTableClass); - const res = usePromise(() => getPlaylist(study.id), [study.id]); - - //////////////////////////////////////////////////////////////// - // Event Handlers - //////////////////////////////////////////////////////////////// - - const handleUpdateStatus = (fn: RA.Pred) => () => { - const api = tableRef.current.hotInstance; - if (!api) { - return; - } - - const changes: Array<[number, string, boolean]> = api - .getDataAtProp("status") - .map((status, index) => [index, "status", fn(status)]); - - api.setDataAtRowProp(changes); - }; - - const handleResetWeights = () => { - const api = tableRef.current.hotInstance as Handsontable; - - api.setDataAtRowProp( - api.rowIndexMapper - .getIndexesSequence() - .map((rowIndex) => [rowIndex, "weight", DEFAULT_WEIGHT]), - ); - }; - - const handleSubmit = (data: SubmitHandlerPlus) => { - return setPlaylist(study.id, data.values); - }; - - const handleCellsRender: HandsontableProps["cells"] = function cells( - this, - row, - column, - prop, - ) { - if (prop === "weight") { - const status = this.instance.getDataAtRowProp(row, "status"); - return { readOnly: !status }; - } - return {}; - }; - - //////////////////////////////////////////////////////////////// - // JSX - //////////////////////////////////////////////////////////////// - - return ( - {t("global.close")}} - // TODO: add `maxHeight` and `fullHeight` in BasicDialog` - PaperProps={{ sx: { height: 500 } }} - maxWidth="sm" - fullWidth - > - ( - <> - - - - - - - - - `MC Year ${row.id}`, - tableRef, - stretchH: "all", - className: "htCenter", - cells: handleCellsRender, - }} - /> - - )} - /> - - ); -} - -export default ScenarioPlaylistDialog; diff --git a/webapp/src/components/App/Singlestudy/explore/Configuration/General/dialogs/ScenarioPlaylistDialog/utils.ts b/webapp/src/components/App/Singlestudy/explore/Configuration/General/dialogs/ScenarioPlaylistDialog/utils.ts deleted file mode 100644 index bd7c015df8..0000000000 --- a/webapp/src/components/App/Singlestudy/explore/Configuration/General/dialogs/ScenarioPlaylistDialog/utils.ts +++ /dev/null @@ -1,43 +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 { StudyMetadata } from "../../../../../../../../common/types"; -import client from "../../../../../../../../services/api/client"; - -interface PlaylistColumns extends Record { - status: boolean; - weight: number; -} - -export type PlaylistData = Record; - -export const DEFAULT_WEIGHT = 1; - -function makeRequestURL(studyId: StudyMetadata["id"]): string { - return `v1/studies/${studyId}/config/playlist/form`; -} - -export async function getPlaylist( - studyId: StudyMetadata["id"], -): Promise { - const res = await client.get(makeRequestURL(studyId)); - return res.data; -} - -export function setPlaylist( - studyId: StudyMetadata["id"], - data: PlaylistData, -): Promise { - return client.put(makeRequestURL(studyId), data); -} diff --git a/webapp/src/components/App/Singlestudy/explore/Configuration/TimeSeriesManagement/index.tsx b/webapp/src/components/App/Singlestudy/explore/Configuration/TimeSeriesManagement/index.tsx index c888c774ab..3c04d03707 100644 --- a/webapp/src/components/App/Singlestudy/explore/Configuration/TimeSeriesManagement/index.tsx +++ b/webapp/src/components/App/Singlestudy/explore/Configuration/TimeSeriesManagement/index.tsx @@ -31,7 +31,7 @@ function TimeSeriesManagement() { const { study } = useOutletContext<{ study: StudyMetadata }>(); const { t } = useTranslation(); const [launchTaskInProgress, setLaunchTaskInProgress] = useState(false); - const apiRef = useRef>(); + const apiRef = useRef>(null); const handleGenerateTs = usePromiseHandler({ fn: generateTimeSeries, diff --git a/webapp/src/components/common/DataGridForm.tsx b/webapp/src/components/common/DataGridForm.tsx index 1ae6c8064c..3e1cde6871 100644 --- a/webapp/src/components/common/DataGridForm.tsx +++ b/webapp/src/components/common/DataGridForm.tsx @@ -12,7 +12,6 @@ * This file is part of the Antares project. */ -import type { IdType } from "@/common/types"; import { GridCellKind, type Item, @@ -21,17 +20,18 @@ import { type FillHandleDirection, } from "@glideapps/glide-data-grid"; import type { DeepPartial } from "react-hook-form"; -import { FormEvent, useCallback, useState } from "react"; -import DataGrid from "./DataGrid"; +import { FormEvent, useCallback, useEffect, useMemo, useState } from "react"; +import DataGrid, { DataGridProps } from "./DataGrid"; import { Box, Divider, IconButton, + setRef, SxProps, Theme, Tooltip, } from "@mui/material"; -import useUndo from "use-undo"; +import useUndo, { type Actions } from "use-undo"; import UndoIcon from "@mui/icons-material/Undo"; import RedoIcon from "@mui/icons-material/Redo"; import SaveIcon from "@mui/icons-material/Save"; @@ -42,53 +42,88 @@ import * as R from "ramda"; import { SubmitHandlerPlus } from "./Form/types"; import useEnqueueErrorSnackbar from "@/hooks/useEnqueueErrorSnackbar"; import useFormCloseProtection from "@/hooks/useCloseFormSecurity"; +import { useUpdateEffect } from "react-use"; +import { toError } from "@/utils/fnUtils"; -type GridFieldValuesByRow = Record< - IdType, - Record ->; +type Data = Record>; + +export interface DataGridFormState { + isDirty: boolean; + isSubmitting: boolean; +} + +export interface DataGridFormApi { + data: TData; + setData: Actions["set"]; + formState: DataGridFormState; +} export interface DataGridFormProps< - TFieldValues extends GridFieldValuesByRow = GridFieldValuesByRow, + TData extends Data = Data, SubmitReturnValue = unknown, > { - defaultData: TFieldValues; - columns: ReadonlyArray; + defaultData: TData; + columns: ReadonlyArray; + rowMarkers?: DataGridProps["rowMarkers"]; allowedFillDirections?: FillHandleDirection; - onSubmit?: ( - data: SubmitHandlerPlus, + enableColumnResize?: boolean; + onSubmit: ( + data: SubmitHandlerPlus, event?: React.BaseSyntheticEvent, ) => void | Promise; onSubmitSuccessful?: ( - data: SubmitHandlerPlus, + data: SubmitHandlerPlus, submitResult: SubmitReturnValue, ) => void; + onStateChange?: (state: DataGridFormState) => void; sx?: SxProps; extraActions?: React.ReactNode; + apiRef?: React.Ref>; } -function DataGridForm({ +function DataGridForm({ defaultData, columns, allowedFillDirections = "vertical", + enableColumnResize, + rowMarkers, onSubmit, onSubmitSuccessful, + onStateChange, sx, extraActions, -}: DataGridFormProps) { + apiRef, +}: DataGridFormProps) { const { t } = useTranslation(); const enqueueErrorSnackbar = useEnqueueErrorSnackbar(); const [isSubmitting, setIsSubmitting] = useState(false); const [savedData, setSavedData] = useState(defaultData); - const [{ present: data }, { set, undo, redo, canUndo, canRedo }] = + const [{ present: data }, { set: setData, undo, redo, canUndo, canRedo }] = useUndo(defaultData); - const isSubmitAllowed = savedData !== data; + // Shallow comparison to check if the data has changed. + // So even if the content are the same, we consider it as dirty. + // Deep comparison fix the issue but with big data it can be slow. + const isDirty = savedData !== data; + + const rowNames = Object.keys(data); + + const formState = useMemo( + () => ({ + isDirty, + isSubmitting, + }), + [isDirty, isSubmitting], + ); + + useFormCloseProtection({ isSubmitting, isDirty }); - useFormCloseProtection({ - isSubmitting, - isDirty: isSubmitAllowed, - }); + useUpdateEffect(() => onStateChange?.(formState), [formState]); + + useEffect( + () => setRef(apiRef, { data, setData, formState }), + [apiRef, data, setData, formState], + ); //////////////////////////////////////////////////////////////// // Utils @@ -96,14 +131,13 @@ function DataGridForm({ const getRowAndColumnNames = (location: Item) => { const [colIndex, rowIndex] = location; - const rowNames = Object.keys(data); const columnIds = columns.map((column) => column.id); return [rowNames[rowIndex], columnIds[colIndex]]; }; const getDirtyValues = () => { - return Object.keys(data).reduce((acc, rowName) => { + return rowNames.reduce((acc, rowName) => { const rowData = data[rowName]; const savedRowData = savedData[rowName]; @@ -125,11 +159,11 @@ function DataGridForm({ } return acc; - }, {} as DeepPartial); + }, {} as DeepPartial); }; //////////////////////////////////////////////////////////////// - // Callbacks + // Content //////////////////////////////////////////////////////////////// const getCellContent = useCallback( @@ -182,7 +216,7 @@ function DataGridForm({ //////////////////////////////////////////////////////////////// const handleCellsEdited: DataEditorProps["onCellsEdited"] = (items) => { - set( + setData( items.reduce((acc, { location, value }) => { const [rowName, columnName] = getRowAndColumnNames(location); const newValue = value.data; @@ -204,13 +238,9 @@ function DataGridForm({ return true; }; - const handleFormSubmit = (event: FormEvent) => { + const handleSubmit = async (event: FormEvent) => { event.preventDefault(); - if (!onSubmit) { - return; - } - setIsSubmitting(true); const dataArg = { @@ -218,17 +248,15 @@ function DataGridForm({ dirtyValues: getDirtyValues(), }; - Promise.resolve(onSubmit(dataArg, event)) - .then((submitRes) => { - setSavedData(data); - onSubmitSuccessful?.(dataArg, submitRes); - }) - .catch((err) => { - enqueueErrorSnackbar(t("form.submit.error"), err); - }) - .finally(() => { - setIsSubmitting(false); - }); + try { + const submitRes = await onSubmit(dataArg, event); + setSavedData(data); + onSubmitSuccessful?.(dataArg, submitRes); + } catch (err) { + enqueueErrorSnackbar(t("form.submit.error"), toError(err)); + } finally { + setIsSubmitting(false); + } }; //////////////////////////////////////////////////////////////// @@ -247,19 +275,22 @@ function DataGridForm({ sx, )} component="form" - onSubmit={handleFormSubmit} + onSubmit={handleSubmit} > Object.keys(data)[index], - }} + rowMarkers={ + rowMarkers || { + kind: "clickable-string", + getTitle: (index) => rowNames[index], + } + } fillHandle allowedFillDirections={allowedFillDirections} + enableColumnResize={enableColumnResize} getCellsForSelection /> ({ ; - apiRef?: React.Ref | undefined>; + apiRef?: React.Ref>; } export function useFormContextPlus() { diff --git a/webapp/src/hooks/useConfirm.ts b/webapp/src/hooks/useConfirm.ts index 4df7890c7f..537674d220 100644 --- a/webapp/src/hooks/useConfirm.ts +++ b/webapp/src/hooks/useConfirm.ts @@ -22,7 +22,35 @@ function errorFunction() { /** * Hook that allows to wait for a confirmation from the user with a `Promise`. * It is intended to be used in conjunction with a confirm view (like `ConfirmationDialog`). - + * + * @example + * ```tsx + * const action = useConfirm(); + * + * return ( + * <> + * + * + * Are you sure? + * + * + * ); + * ``` + * * @returns An object with the following properties: * - `showConfirm`: A function that returns a `Promise` that resolves to `true` if the user confirms, * `false` if the user refuses, and `null` if the user cancel. diff --git a/webapp/src/services/api/studies/config/playlist/constants.ts b/webapp/src/services/api/studies/config/playlist/constants.ts new file mode 100644 index 0000000000..bec0c020cb --- /dev/null +++ b/webapp/src/services/api/studies/config/playlist/constants.ts @@ -0,0 +1,15 @@ +/** + * 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 DEFAULT_WEIGHT = 1; diff --git a/webapp/src/services/api/studies/config/playlist/index.ts b/webapp/src/services/api/studies/config/playlist/index.ts new file mode 100644 index 0000000000..40ee7b21c1 --- /dev/null +++ b/webapp/src/services/api/studies/config/playlist/index.ts @@ -0,0 +1,36 @@ +/** + * 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 type { StudyMetadata } from "@/common/types"; +import client from "@/services/api/client"; +import { format } from "@/utils/stringUtils"; +import type { PlaylistData, SetPlaylistDataParams } from "./types"; + +const URL = "/v1/studies/{studyId}/config/playlist/form"; + +export async function getPlaylistData(params: { + studyId: StudyMetadata["id"]; +}) { + const url = format(URL, { studyId: params.studyId }); + const { data } = await client.get(url); + return data; +} + +export async function setPlaylistData({ + studyId, + data, +}: SetPlaylistDataParams) { + const url = format(URL, { studyId }); + await client.put(url, data); +} diff --git a/webapp/src/services/api/studies/config/playlist/types.ts b/webapp/src/services/api/studies/config/playlist/types.ts new file mode 100644 index 0000000000..9eeae5441e --- /dev/null +++ b/webapp/src/services/api/studies/config/playlist/types.ts @@ -0,0 +1,28 @@ +/** + * 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 type { StudyMetadata } from "@/common/types"; + +// eslint-disable-next-line @typescript-eslint/consistent-type-definitions +export type Playlist = { + status: boolean; + weight: number; +}; + +export type PlaylistData = Record; + +export interface SetPlaylistDataParams { + studyId: StudyMetadata["id"]; + data: PlaylistData; +} From d14454ab3b5d91dc9136a89fea044485b8305b2e Mon Sep 17 00:00:00 2001 From: Theo Pascoli <48944759+TheoPascoli@users.noreply.github.com> Date: Fri, 17 Jan 2025 16:48:36 +0100 Subject: [PATCH 9/9] =?UTF-8?q?feat:=20add=20an=20endpoint=20to=20allow=20?= =?UTF-8?q?multiple=20deletion=20of=20binding=20constrain=E2=80=A6=20(#229?= =?UTF-8?q?8)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../business/binding_constraint_management.py | 40 +++++++ .../variantstudy/business/command_reverter.py | 11 ++ .../storage/variantstudy/command_factory.py | 4 + .../model/command/binding_constraint_utils.py | 35 ++++++ .../variantstudy/model/command/common.py | 1 + .../command/create_binding_constraint.py | 22 +--- .../command/remove_binding_constraint.py | 2 +- .../remove_multiple_binding_constraints.py | 111 ++++++++++++++++++ antarest/study/web/study_data_blueprint.py | 19 +++ .../test_binding_constraints.py | 36 ++++++ tests/variantstudy/test_command_factory.py | 10 ++ 11 files changed, 269 insertions(+), 22 deletions(-) create mode 100644 antarest/study/storage/variantstudy/model/command/binding_constraint_utils.py create mode 100644 antarest/study/storage/variantstudy/model/command/remove_multiple_binding_constraints.py diff --git a/antarest/study/business/binding_constraint_management.py b/antarest/study/business/binding_constraint_management.py index 1fb5d48a0d..fd3970b9b1 100644 --- a/antarest/study/business/binding_constraint_management.py +++ b/antarest/study/business/binding_constraint_management.py @@ -70,6 +70,9 @@ ) from antarest.study.storage.variantstudy.model.command.icommand import ICommand from antarest.study.storage.variantstudy.model.command.remove_binding_constraint import RemoveBindingConstraint +from antarest.study.storage.variantstudy.model.command.remove_multiple_binding_constraints import ( + RemoveMultipleBindingConstraints, +) from antarest.study.storage.variantstudy.model.command.replace_matrix import ReplaceMatrix from antarest.study.storage.variantstudy.model.command.update_binding_constraint import ( UpdateBindingConstraint, @@ -589,6 +592,18 @@ def terms_to_coeffs(terms: t.Sequence[ConstraintTerm]) -> t.Dict[str, t.List[flo coeffs[term.id].append(term.offset) return coeffs + def check_binding_constraints_exists(self, study: Study, bc_ids: t.List[str]) -> None: + storage_service = self.storage_service.get_storage(study) + file_study = storage_service.get_raw(study) + existing_constraints = file_study.tree.get(["input", "bindingconstraints", "bindingconstraints"]) + + existing_ids = {constraint["id"] for constraint in existing_constraints.values()} + + missing_bc_ids = [bc_id for bc_id in bc_ids if bc_id not in existing_ids] + + if missing_bc_ids: + raise BindingConstraintNotFound(f"Binding constraint(s) '{missing_bc_ids}' not found") + def get_binding_constraint(self, study: Study, bc_id: str) -> ConstraintOutput: """ Retrieves a binding constraint by its ID within a given study. @@ -1018,6 +1033,31 @@ def remove_binding_constraint(self, study: Study, binding_constraint_id: str) -> ) execute_or_add_commands(study, file_study, [command], self.storage_service) + def remove_multiple_binding_constraints(self, study: Study, binding_constraints_ids: t.List[str]) -> None: + """ + Removes multiple binding constraints from a study. + + Args: + study: The study from which to remove the constraint. + binding_constraints_ids: The IDs of the binding constraints to remove. + + Raises: + BindingConstraintNotFound: If at least one binding constraint within the specified list is not found. + """ + + self.check_binding_constraints_exists(study, binding_constraints_ids) + + command_context = self.storage_service.variant_study_service.command_factory.command_context + file_study = self.storage_service.get_storage(study).get_raw(study) + + command = RemoveMultipleBindingConstraints( + ids=binding_constraints_ids, + command_context=command_context, + study_version=file_study.config.version, + ) + + execute_or_add_commands(study, file_study, [command], self.storage_service) + def _update_constraint_with_terms( self, study: Study, bc: ConstraintOutput, terms: t.Mapping[str, ConstraintTerm] ) -> None: diff --git a/antarest/study/storage/variantstudy/business/command_reverter.py b/antarest/study/storage/variantstudy/business/command_reverter.py index d5971dd69e..4b74d2a0ba 100644 --- a/antarest/study/storage/variantstudy/business/command_reverter.py +++ b/antarest/study/storage/variantstudy/business/command_reverter.py @@ -38,6 +38,9 @@ from antarest.study.storage.variantstudy.model.command.remove_cluster import RemoveCluster from antarest.study.storage.variantstudy.model.command.remove_district import RemoveDistrict from antarest.study.storage.variantstudy.model.command.remove_link import RemoveLink +from antarest.study.storage.variantstudy.model.command.remove_multiple_binding_constraints import ( + RemoveMultipleBindingConstraints, +) from antarest.study.storage.variantstudy.model.command.remove_renewables_cluster import RemoveRenewablesCluster from antarest.study.storage.variantstudy.model.command.remove_st_storage import RemoveSTStorage from antarest.study.storage.variantstudy.model.command.remove_user_resource import RemoveUserResource @@ -163,6 +166,14 @@ def _revert_remove_binding_constraint( ) -> t.List[ICommand]: raise NotImplementedError("The revert function for RemoveBindingConstraint is not available") + @staticmethod + def _revert_remove_multiple_binding_constraints( + base_command: RemoveMultipleBindingConstraints, + history: t.List["ICommand"], + base: FileStudy, + ) -> t.List[ICommand]: + raise NotImplementedError("The revert function for RemoveMultipleBindingConstraints is not available") + @staticmethod def _revert_update_scenario_builder( base_command: UpdateScenarioBuilder, diff --git a/antarest/study/storage/variantstudy/command_factory.py b/antarest/study/storage/variantstudy/command_factory.py index 9638141643..f20f0ef58d 100644 --- a/antarest/study/storage/variantstudy/command_factory.py +++ b/antarest/study/storage/variantstudy/command_factory.py @@ -37,6 +37,9 @@ from antarest.study.storage.variantstudy.model.command.remove_cluster import RemoveCluster from antarest.study.storage.variantstudy.model.command.remove_district import RemoveDistrict from antarest.study.storage.variantstudy.model.command.remove_link import RemoveLink +from antarest.study.storage.variantstudy.model.command.remove_multiple_binding_constraints import ( + RemoveMultipleBindingConstraints, +) from antarest.study.storage.variantstudy.model.command.remove_renewables_cluster import RemoveRenewablesCluster from antarest.study.storage.variantstudy.model.command.remove_st_storage import RemoveSTStorage from antarest.study.storage.variantstudy.model.command.remove_user_resource import RemoveUserResource @@ -63,6 +66,7 @@ CommandName.CREATE_BINDING_CONSTRAINT.value: CreateBindingConstraint, CommandName.UPDATE_BINDING_CONSTRAINT.value: UpdateBindingConstraint, CommandName.REMOVE_BINDING_CONSTRAINT.value: RemoveBindingConstraint, + CommandName.REMOVE_MULTIPLE_BINDING_CONSTRAINTS.value: RemoveMultipleBindingConstraints, CommandName.CREATE_THERMAL_CLUSTER.value: CreateCluster, CommandName.REMOVE_THERMAL_CLUSTER.value: RemoveCluster, CommandName.CREATE_RENEWABLES_CLUSTER.value: CreateRenewablesCluster, diff --git a/antarest/study/storage/variantstudy/model/command/binding_constraint_utils.py b/antarest/study/storage/variantstudy/model/command/binding_constraint_utils.py new file mode 100644 index 0000000000..45d40d5bc5 --- /dev/null +++ b/antarest/study/storage/variantstudy/model/command/binding_constraint_utils.py @@ -0,0 +1,35 @@ +# Copyright (c) 2025, 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 typing as t + +from antarest.study.storage.rawstudy.model.filesystem.factory import FileStudy + + +def remove_bc_from_scenario_builder(study_data: FileStudy, removed_groups: t.Set[str]) -> None: + """ + Update the scenario builder by removing the rows that correspond to the BC groups to remove. + + NOTE: this update can be very long if the scenario builder configuration is large. + """ + if not removed_groups: + return + + rulesets = study_data.tree.get(["settings", "scenariobuilder"]) + + for ruleset in rulesets.values(): + for key in list(ruleset): + # The key is in the form "symbol,group,year" + symbol, *parts = key.split(",") + if symbol == "bc" and parts[0] in removed_groups: + del ruleset[key] + + study_data.tree.save(rulesets, ["settings", "scenariobuilder"]) diff --git a/antarest/study/storage/variantstudy/model/command/common.py b/antarest/study/storage/variantstudy/model/command/common.py index 744c87d22c..e91a0e2e58 100644 --- a/antarest/study/storage/variantstudy/model/command/common.py +++ b/antarest/study/storage/variantstudy/model/command/common.py @@ -39,6 +39,7 @@ class CommandName(Enum): CREATE_BINDING_CONSTRAINT = "create_binding_constraint" UPDATE_BINDING_CONSTRAINT = "update_binding_constraint" REMOVE_BINDING_CONSTRAINT = "remove_binding_constraint" + REMOVE_MULTIPLE_BINDING_CONSTRAINTS = "remove_multiple_binding_constraints" CREATE_THERMAL_CLUSTER = "create_cluster" REMOVE_THERMAL_CLUSTER = "remove_cluster" CREATE_RENEWABLES_CLUSTER = "create_renewables_cluster" diff --git a/antarest/study/storage/variantstudy/model/command/create_binding_constraint.py b/antarest/study/storage/variantstudy/model/command/create_binding_constraint.py index b76f092086..766934c8ee 100644 --- a/antarest/study/storage/variantstudy/model/command/create_binding_constraint.py +++ b/antarest/study/storage/variantstudy/model/command/create_binding_constraint.py @@ -38,6 +38,7 @@ from antarest.study.storage.variantstudy.business.utils_binding_constraint import ( parse_bindings_coeffs_and_save_into_config, ) +from antarest.study.storage.variantstudy.model.command.binding_constraint_utils import remove_bc_from_scenario_builder from antarest.study.storage.variantstudy.model.command.common import CommandName, CommandOutput from antarest.study.storage.variantstudy.model.command.icommand import MATCH_SIGNATURE_SEPARATOR, ICommand from antarest.study.storage.variantstudy.model.command_listener.command_listener import ICommandListener @@ -503,24 +504,3 @@ def match(self, other: "ICommand", equal: bool = False) -> bool: if not equal: return self.name == other.name return super().match(other, equal) - - -def remove_bc_from_scenario_builder(study_data: FileStudy, removed_groups: t.Set[str]) -> None: - """ - Update the scenario builder by removing the rows that correspond to the BC groups to remove. - - NOTE: this update can be very long if the scenario builder configuration is large. - """ - if not removed_groups: - return - - rulesets = study_data.tree.get(["settings", "scenariobuilder"]) - - for ruleset in rulesets.values(): - for key in list(ruleset): - # The key is in the form "symbol,group,year" - symbol, *parts = key.split(",") - if symbol == "bc" and parts[0] in removed_groups: - del ruleset[key] - - study_data.tree.save(rulesets, ["settings", "scenariobuilder"]) diff --git a/antarest/study/storage/variantstudy/model/command/remove_binding_constraint.py b/antarest/study/storage/variantstudy/model/command/remove_binding_constraint.py index b05b51efc4..482ffc5a1b 100644 --- a/antarest/study/storage/variantstudy/model/command/remove_binding_constraint.py +++ b/antarest/study/storage/variantstudy/model/command/remove_binding_constraint.py @@ -19,8 +19,8 @@ from antarest.study.storage.rawstudy.model.filesystem.config.binding_constraint import DEFAULT_GROUP from antarest.study.storage.rawstudy.model.filesystem.config.model import FileStudyTreeConfig from antarest.study.storage.rawstudy.model.filesystem.factory import FileStudy +from antarest.study.storage.variantstudy.model.command.binding_constraint_utils import remove_bc_from_scenario_builder from antarest.study.storage.variantstudy.model.command.common import CommandName, CommandOutput -from antarest.study.storage.variantstudy.model.command.create_binding_constraint import remove_bc_from_scenario_builder from antarest.study.storage.variantstudy.model.command.icommand import MATCH_SIGNATURE_SEPARATOR, ICommand from antarest.study.storage.variantstudy.model.command_listener.command_listener import ICommandListener from antarest.study.storage.variantstudy.model.model import CommandDTO diff --git a/antarest/study/storage/variantstudy/model/command/remove_multiple_binding_constraints.py b/antarest/study/storage/variantstudy/model/command/remove_multiple_binding_constraints.py new file mode 100644 index 0000000000..7747f4945f --- /dev/null +++ b/antarest/study/storage/variantstudy/model/command/remove_multiple_binding_constraints.py @@ -0,0 +1,111 @@ +# Copyright (c) 2025, 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 typing as t + +from typing_extensions import override + +from antarest.study.model import STUDY_VERSION_8_7 +from antarest.study.storage.rawstudy.model.filesystem.config.binding_constraint import DEFAULT_GROUP +from antarest.study.storage.rawstudy.model.filesystem.config.model import FileStudyTreeConfig +from antarest.study.storage.rawstudy.model.filesystem.factory import FileStudy +from antarest.study.storage.variantstudy.model.command.binding_constraint_utils import remove_bc_from_scenario_builder +from antarest.study.storage.variantstudy.model.command.common import CommandName, CommandOutput +from antarest.study.storage.variantstudy.model.command.icommand import MATCH_SIGNATURE_SEPARATOR, ICommand, OutputTuple +from antarest.study.storage.variantstudy.model.command_listener.command_listener import ICommandListener +from antarest.study.storage.variantstudy.model.model import CommandDTO + + +class RemoveMultipleBindingConstraints(ICommand): + """ + Command used to remove multiple binding constraints at once. + """ + + command_name: CommandName = CommandName.REMOVE_MULTIPLE_BINDING_CONSTRAINTS + version: int = 1 + + # Properties of the `REMOVE_MULTIPLE_BINDING_CONSTRAINTS` command: + ids: t.List[str] + + @override + def _apply_config(self, study_data: FileStudyTreeConfig) -> OutputTuple: + # If at least one bc is missing in the database, we raise an error + already_existing_ids = {binding.id for binding in study_data.bindings} + missing_bc_ids = [id_ for id_ in self.ids if id_ not in already_existing_ids] + if missing_bc_ids: + return CommandOutput(status=False, message=f"Binding constraint not found: '{missing_bc_ids}'"), {} + return CommandOutput(status=True), {} + + @override + def _apply(self, study_data: FileStudy, listener: t.Optional[ICommandListener] = None) -> CommandOutput: + command_output, _ = self._apply_config(study_data.config) + + if not command_output.status: + return command_output + + binding_constraints = study_data.tree.get(["input", "bindingconstraints", "bindingconstraints"]) + + old_groups = {bd.get("group", DEFAULT_GROUP).lower() for bd in binding_constraints.values()} + + deleted_binding_constraints = [] + + for key in list(binding_constraints.keys()): + if binding_constraints[key].get("id") in self.ids: + deleted_binding_constraints.append(binding_constraints.pop(key)) + + study_data.tree.save( + binding_constraints, + ["input", "bindingconstraints", "bindingconstraints"], + ) + + existing_files = study_data.tree.get(["input", "bindingconstraints"], depth=1) + for bc in deleted_binding_constraints: + if study_data.config.version < STUDY_VERSION_8_7: + study_data.tree.delete(["input", "bindingconstraints", bc.get("id")]) + else: + for term in ["lt", "gt", "eq"]: + matrix_id = f"{bc.get('id')}_{term}" + if matrix_id in existing_files: + study_data.tree.delete(["input", "bindingconstraints", matrix_id]) + + new_groups = {bd.get("group", DEFAULT_GROUP).lower() for bd in binding_constraints.values()} + removed_groups = old_groups - new_groups + remove_bc_from_scenario_builder(study_data, removed_groups) + + return command_output + + @override + def to_dto(self) -> CommandDTO: + return CommandDTO( + action=CommandName.REMOVE_MULTIPLE_BINDING_CONSTRAINTS.value, + args={ + "ids": self.ids, + }, + study_version=self.study_version, + ) + + @override + def match_signature(self) -> str: + return str(self.command_name.value + MATCH_SIGNATURE_SEPARATOR + ",".join(self.ids)) + + @override + def match(self, other: ICommand, equal: bool = False) -> bool: + if not isinstance(other, RemoveMultipleBindingConstraints): + return False + return self.ids == other.ids + + @override + def _create_diff(self, other: "ICommand") -> t.List["ICommand"]: + return [] + + @override + def get_inner_matrices(self) -> t.List[str]: + return [] diff --git a/antarest/study/web/study_data_blueprint.py b/antarest/study/web/study_data_blueprint.py index 9be21c1743..95659ecd7f 100644 --- a/antarest/study/web/study_data_blueprint.py +++ b/antarest/study/web/study_data_blueprint.py @@ -1378,6 +1378,25 @@ def delete_binding_constraint( study = study_service.check_study_access(uuid, StudyPermissionType.WRITE, params) return study_service.binding_constraint_manager.remove_binding_constraint(study, binding_constraint_id) + @bp.delete( + "/studies/{uuid}/bindingconstraints", + tags=[APITag.study_data], + summary="Delete multiple binding constraints", + response_model=None, + ) + def delete_multiple_binding_constraints( + uuid: str, binding_constraints_ids: t.List[str], current_user: JWTUser = Depends(auth.get_current_user) + ) -> None: + logger.info( + f"Deleting the binding constraints {binding_constraints_ids!r} for study {uuid}", + extra={"user": current_user.id}, + ) + params = RequestParameters(user=current_user) + study = study_service.check_study_access(uuid, StudyPermissionType.WRITE, params) + return study_service.binding_constraint_manager.remove_multiple_binding_constraints( + study, binding_constraints_ids + ) + @bp.post( "/studies/{uuid}/bindingconstraints/{binding_constraint_id}/term", tags=[APITag.study_data], diff --git a/tests/integration/study_data_blueprint/test_binding_constraints.py b/tests/integration/study_data_blueprint/test_binding_constraints.py index 408cef124d..84b27ded12 100644 --- a/tests/integration/study_data_blueprint/test_binding_constraints.py +++ b/tests/integration/study_data_blueprint/test_binding_constraints.py @@ -972,6 +972,26 @@ def test_for_version_870(self, client: TestClient, user_access_token: str, study binding_constraints_list = preparer.get_binding_constraints(study_id) assert len(binding_constraints_list) == 2 + # Delete multiple binding constraint + preparer.create_binding_constraint(study_id, name="bc1", group="grp1", **args) + preparer.create_binding_constraint(study_id, name="bc2", group="grp2", **args) + + binding_constraints_list = preparer.get_binding_constraints(study_id) + assert len(binding_constraints_list) == 4 + + res = client.request( + "DELETE", + f"/v1/studies/{study_id}/bindingconstraints", + json=["bc1", "bc2"], + ) + assert res.status_code == 200, res.json() + + # Asserts that the deletion worked + binding_constraints_list = preparer.get_binding_constraints(study_id) + assert len(binding_constraints_list) == 2 + actual_ids = [constraint["id"] for constraint in binding_constraints_list] + assert actual_ids == ["binding_constraint_1", "binding_constraint_3"] + # ============================= # CONSTRAINT DUPLICATION # ============================= @@ -1015,6 +1035,22 @@ def test_for_version_870(self, client: TestClient, user_access_token: str, study # ERRORS # ============================= + # Deletion multiple binding constraints, one does not exist. Make sure none is deleted + + binding_constraints_list = preparer.get_binding_constraints(study_id) + assert len(binding_constraints_list) == 3 + + res = client.request( + "DELETE", + f"/v1/studies/{study_id}/bindingconstraints", + json=["binding_constraint_1", "binding_constraint_2", "binding_constraint_3"], + ) + assert res.status_code == 404, res.json() + assert res.json()["description"] == "Binding constraint(s) '['binding_constraint_2']' not found" + + binding_constraints_list = preparer.get_binding_constraints(study_id) + assert len(binding_constraints_list) == 3 + # Creation with wrong matrix according to version for operator in ["less", "equal", "greater", "both"]: args["operator"] = operator diff --git a/tests/variantstudy/test_command_factory.py b/tests/variantstudy/test_command_factory.py index c4406b50da..a2c392785a 100644 --- a/tests/variantstudy/test_command_factory.py +++ b/tests/variantstudy/test_command_factory.py @@ -159,6 +159,16 @@ CommandDTO( action=CommandName.REMOVE_BINDING_CONSTRAINT.value, args=[{"id": "id"}], study_version=STUDY_VERSION_8_8 ), + CommandDTO( + action=CommandName.REMOVE_MULTIPLE_BINDING_CONSTRAINTS.value, + args={"ids": ["id"]}, + study_version=STUDY_VERSION_8_8, + ), + CommandDTO( + action=CommandName.REMOVE_MULTIPLE_BINDING_CONSTRAINTS.value, + args=[{"ids": ["id"]}], + study_version=STUDY_VERSION_8_8, + ), CommandDTO( action=CommandName.CREATE_THERMAL_CLUSTER.value, args={