Skip to content

Commit

Permalink
feat(api,ui-studies): update study move (#2239)
Browse files Browse the repository at this point in the history
ANT-2390
  • Loading branch information
skamril authored Dec 17, 2024
2 parents d51e88c + 6d454f4 commit 88a7a53
Show file tree
Hide file tree
Showing 14 changed files with 284 additions and 110 deletions.
6 changes: 5 additions & 1 deletion antarest/study/service.py
Original file line number Diff line number Diff line change
Expand Up @@ -1073,11 +1073,15 @@ def copy_task(notifier: ITaskNotifier) -> TaskResult:

return task_or_study_id

def move_study(self, study_id: str, new_folder: str, params: RequestParameters) -> None:
def move_study(self, study_id: str, folder_dest: str, params: RequestParameters) -> None:
study = self.get_study(study_id)
assert_permission(params.user, study, StudyPermissionType.WRITE)
if not is_managed(study):
raise NotAManagedStudyException(study_id)
if folder_dest:
new_folder = folder_dest.rstrip("/") + f"/{study.id}"
else:
new_folder = None
study.folder = new_folder
self.repository.save(study, update_modification_date=False)
self.event_bus.push(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -610,7 +610,7 @@ def create_variant_study(self, uuid: str, name: str, params: RequestParameters)
created_at=datetime.utcnow(),
updated_at=datetime.utcnow(),
version=study.version,
folder=(re.sub(f"/?{study.id}", "", study.folder) if study.folder is not None else None),
folder=(re.sub(study.id, new_id, study.folder) if study.folder is not None else None),
groups=study.groups, # Create inherit_group boolean
owner_id=params.user.impersonator if params.user else None,
snapshot=None,
Expand Down
51 changes: 51 additions & 0 deletions tests/integration/studies_blueprint/test_move.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
# 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.

from starlette.testclient import TestClient


class TestMove:
def test_move_endpoint(self, client: TestClient, internal_study_id: str, user_access_token: str) -> None:
client.headers = {"Authorization": f"Bearer {user_access_token}"}

res = client.post("/v1/studies?name=study_test")
assert res.status_code == 201
study_id = res.json()

# asserts move with a given folder adds the /study_id at the end of the path
res = client.put(f"/v1/studies/{study_id}/move", params={"folder_dest": "folder1"})
res.raise_for_status()
res = client.get(f"/v1/studies/{study_id}")
assert res.json()["folder"] == f"folder1/{study_id}"

# asserts move to a folder with //// removes the unwanted `/`
res = client.put(f"/v1/studies/{study_id}/move", params={"folder_dest": "folder2///////"})
res.raise_for_status()
res = client.get(f"/v1/studies/{study_id}")
assert res.json()["folder"] == f"folder2/{study_id}"

# asserts the created variant has the same parent folder
res = client.post(f"/v1/studies/{study_id}/variants?name=Variant1")
variant_id = res.json()
res = client.get(f"/v1/studies/{variant_id}")
assert res.json()["folder"] == f"folder2/{variant_id}"

# asserts move doesn't work on un-managed studies
res = client.put(f"/v1/studies/{internal_study_id}/move", params={"folder_dest": "folder1"})
assert res.status_code == 422
assert res.json()["exception"] == "NotAManagedStudyException"

# asserts users can put back a study at the root folder
res = client.put(f"/v1/studies/{study_id}/move", params={"folder_dest": ""})
res.raise_for_status()
res = client.get(f"/v1/studies/{study_id}")
assert res.json()["folder"] is None
3 changes: 2 additions & 1 deletion tests/integration/test_integration.py
Original file line number Diff line number Diff line change
Expand Up @@ -207,7 +207,8 @@ def test_main(client: TestClient, admin_access_token: str) -> None:
headers={"Authorization": f'Bearer {george_credentials["access_token"]}'},
)
assert len(res.json()) == 3
assert filter(lambda s: s["id"] == copied.json(), res.json().values()).__next__()["folder"] == "foo/bar"
moved_study = filter(lambda s: s["id"] == copied.json(), res.json().values()).__next__()
assert moved_study["folder"] == f"foo/bar/{moved_study['id']}"

# Study delete
client.delete(
Expand Down
8 changes: 5 additions & 3 deletions webapp/public/locales/en/main.json
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@
"global.status": "Status",
"global.semicolon": "Semicolon",
"global.language": "Language",
"global.path": "Path",
"global.time.hourly": "Hourly",
"global.time.daily": "Daily",
"global.time.weekly": "Weekly",
Expand Down Expand Up @@ -154,6 +155,9 @@
"form.field.requireUppercase": "Must contain at least one uppercase letter.",
"form.field.requireDigit": "Must contain at least one digit.",
"form.field.requireSpecialChars": "Must contain at least one special character.",
"form.field.path.startWithSlashNotAllowed": "Path must not start with a '/'",
"form.field.path.endWithSlashNotAllowed": "Path must not end with a '/'",
"form.field.path.invalid": "Invalid path",
"matrix.graphSelector": "Columns",
"matrix.message.importHint": "Click or drag and drop a matrix here",
"matrix.importNewMatrix": "Import a new matrix",
Expand Down Expand Up @@ -620,7 +624,6 @@
"studies.error.loadStudy": "Failed to load study",
"studies.error.runStudy": "Failed to run study",
"studies.error.scanFolder": "Failed to start folder scan",
"studies.error.moveStudy": "Failed to move study {{study}}",
"studies.error.saveData": "Failed to save data",
"studies.error.copyStudy": "Failed to copy study",
"studies.error.import": "Failed to import Study ({{uploadFile}})",
Expand All @@ -631,11 +634,10 @@
"studies.error.createStudy": "Failed to create Study {{studyname}}",
"studies.success.saveData": "Data saved with success",
"studies.success.scanFolder": "Folder scan started",
"studies.success.moveStudy": "Study {{study}} was successfully moved to {{folder}}",
"studies.success.moveStudy": "Study '{{study}}' was successfully moved to '{{path}}'",
"studies.success.createStudy": "Study {{studyname}} created successfully",
"studies.studylaunched": "{{studyname}} launched!",
"studies.copySuffix": "Copy",
"studies.folder": "Folder",
"studies.filters.strictfolder": "Show only direct folder children",
"studies.scanFolder": "Scan folder",
"studies.moveStudy": "Move",
Expand Down
8 changes: 5 additions & 3 deletions webapp/public/locales/fr/main.json
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@
"global.status": "Statut",
"global.semicolon": "Point-virgule",
"global.language": "Langue",
"global.path": "Chemin",
"global.time.hourly": "Horaire",
"global.time.daily": "Journalier",
"global.time.weekly": "Hebdomadaire",
Expand Down Expand Up @@ -154,6 +155,9 @@
"form.field.requireUppercase": "Doit contenir au moins une lettre majuscule.",
"form.field.requireDigit": "Doit contenir au moins un chiffre.",
"form.field.requireSpecialChars": "Doit contenir au moins un caractère spécial.",
"form.field.path.startWithSlashNotAllowed": "Le chemin ne doit pas commencer par un '/'",
"form.field.path.endWithSlashNotAllowed": "Le chemin ne doit pas finir par un '/'",
"form.field.path.invalid": "Chemin invalide",
"matrix.graphSelector": "Colonnes",
"matrix.message.importHint": "Cliquer ou glisser une matrice ici",
"matrix.importNewMatrix": "Import d'une nouvelle matrice",
Expand Down Expand Up @@ -620,7 +624,6 @@
"studies.error.loadStudy": "Échec du chargement de l'étude",
"studies.error.runStudy": "Échec du lancement de l'étude",
"studies.error.scanFolder": "Échec du lancement du scan",
"studies.error.moveStudy": "Échec du déplacement de l'étude {{study}}",
"studies.error.saveData": "Erreur lors de la sauvegarde des données",
"studies.error.copyStudy": "Erreur lors de la copie de l'étude",
"studies.error.import": "L'import de l'étude a échoué ({{uploadFile}})",
Expand All @@ -631,11 +634,10 @@
"studies.error.createStudy": "Erreur lors de la création de l'étude {{studyname}}",
"studies.success.saveData": "Donnée sauvegardée avec succès",
"studies.success.scanFolder": "L'analyse du dossier a commencé",
"studies.success.moveStudy": "L'étude {{study}} a été déplacée avec succès vers {{folder}}",
"studies.success.moveStudy": "L'étude \"{{study}}\" a été déplacée avec succès vers \"{{path}}\"",
"studies.success.createStudy": "L'étude {{studyname}} a été crée avec succès",
"studies.studylaunched": "{{studyname}} lancé(s) !",
"studies.copySuffix": "Copie",
"studies.folder": "Dossier",
"studies.filters.strictfolder": "Afficher uniquement les descendants directs",
"studies.scanFolder": "Scanner le dossier",
"studies.moveStudy": "Déplacer",
Expand Down
96 changes: 55 additions & 41 deletions webapp/src/components/App/Studies/MoveStudyDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,17 +13,40 @@
*/

import { DialogProps } from "@mui/material";
import TextField from "@mui/material/TextField";
import { useSnackbar } from "notistack";
import * as R from "ramda";
import { useTranslation } from "react-i18next";
import { usePromise } from "react-use";
import { StudyMetadata } from "../../../common/types";
import useEnqueueErrorSnackbar from "../../../hooks/useEnqueueErrorSnackbar";
import { moveStudy } from "../../../services/api/study";
import { isStringEmpty } from "../../../services/utils";
import FormDialog from "../../common/dialogs/FormDialog";
import { SubmitHandlerPlus } from "../../common/Form/types";
import StringFE from "@/components/common/fieldEditors/StringFE";
import * as R from "ramda";
import { validatePath } from "@/utils/validation/string";

function formalizePath(
path: string | undefined,
studyId?: StudyMetadata["id"],
) {
const trimmedPath = path?.trim();

if (!trimmedPath) {
return "";
}

const pathArray = trimmedPath.split("/").filter(Boolean);

if (studyId) {
const lastFolder = R.last(pathArray);

// The API automatically add the study ID to a not empty path when moving a study.
// So we need to remove it from the display path.
if (lastFolder === studyId) {
return pathArray.slice(0, -1).join("/");
}
}

return pathArray.join("/");
}

interface Props extends DialogProps {
study: StudyMetadata;
Expand All @@ -33,36 +56,35 @@ interface Props extends DialogProps {
function MoveStudyDialog(props: Props) {
const { study, open, onClose } = props;
const [t] = useTranslation();
const mounted = usePromise();
const { enqueueSnackbar } = useSnackbar();
const enqueueErrorSnackbar = useEnqueueErrorSnackbar();

const defaultValues = {
folder: R.join("/", R.dropLast(1, R.split("/", study.folder || ""))),
path: formalizePath(study.folder, study.id),
};

////////////////////////////////////////////////////////////////
// Event Handlers
////////////////////////////////////////////////////////////////

const handleSubmit = async (
const handleSubmit = (data: SubmitHandlerPlus<typeof defaultValues>) => {
const path = formalizePath(data.values.path);
return moveStudy(study.id, path);
};

const handleSubmitSuccessful = (
data: SubmitHandlerPlus<typeof defaultValues>,
) => {
const { folder } = data.values;
try {
await mounted(moveStudy(study.id, folder));
enqueueSnackbar(
t("studies.success.moveStudy", { study: study.name, folder }),
{
variant: "success",
},
);
onClose();
} catch (e) {
enqueueErrorSnackbar(
t("studies.error.moveStudy", { study: study.name }),
e as Error,
);
}
onClose();

enqueueSnackbar(
t("studies.success.moveStudy", {
study: study.name,
path: data.values.path || "/", // Empty path move the study to the root
}),
{
variant: "success",
},
);
};

////////////////////////////////////////////////////////////////
Expand All @@ -74,27 +96,19 @@ function MoveStudyDialog(props: Props) {
open={open}
config={{ defaultValues }}
onSubmit={handleSubmit}
onSubmitSuccessful={handleSubmitSuccessful}
onCancel={onClose}
>
{(formObj) => (
<TextField
{({ control }) => (
<StringFE
name="path"
control={control}
rules={{ validate: validatePath({ allowEmpty: true }) }}
label={t("global.path")}
placeholder={t("studies.movefolderplaceholder")}
sx={{ mx: 0 }}
autoFocus
label={t("studies.folder")}
error={!!formObj.formState.errors.folder}
helperText={formObj.formState.errors.folder?.message}
placeholder={t("studies.movefolderplaceholder") as string}
InputLabelProps={
// Allow to show placeholder when field is empty
formObj.formState.defaultValues?.folder ? { shrink: true } : {}
}
fullWidth
{...formObj.register("folder", {
required: t("form.field.required") as string,
validate: (value) => {
return !isStringEmpty(value);
},
})}
/>
)}
</FormDialog>
Expand Down
7 changes: 2 additions & 5 deletions webapp/src/components/App/Studies/StudiesList/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,10 +39,7 @@ import { FixedSizeGrid, GridOnScrollProps } from "react-window";
import { v4 as uuidv4 } from "uuid";
import { AxiosError } from "axios";
import { StudyMetadata } from "../../../../common/types";
import {
STUDIES_HEIGHT_HEADER,
STUDIES_LIST_HEADER_HEIGHT,
} from "../../../../theme";
import { STUDIES_LIST_HEADER_HEIGHT } from "../../../../theme";
import {
setStudyScrollPosition,
StudiesSortConf,
Expand Down Expand Up @@ -184,7 +181,7 @@ function StudiesList(props: StudiesListProps) {

return (
<Box
height={`calc(100vh - ${STUDIES_HEIGHT_HEADER}px)`}
height={1}
flex={1}
display="flex"
flexDirection="column"
Expand Down
17 changes: 12 additions & 5 deletions webapp/src/components/App/Studies/StudyTree.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,17 +40,17 @@ function StudyTree() {
////////////////////////////////////////////////////////////////

const buildTree = (children: StudyTreeNode[], parentId?: string) => {
return children.map((elm) => {
const id = parentId ? `${parentId}/${elm.name}` : elm.name;
return children.map((child) => {
const id = parentId ? `${parentId}/${child.name}` : child.name;

return (
<TreeItemEnhanced
key={id}
itemId={id}
label={elm.name}
label={child.name}
onClick={() => handleTreeItemClick(id)}
>
{buildTree(elm.children, id)}
{buildTree(child.children, id)}
</TreeItemEnhanced>
);
});
Expand All @@ -60,7 +60,14 @@ function StudyTree() {
<SimpleTreeView
defaultExpandedItems={[...getParentPaths(folder), folder]}
defaultSelectedItems={folder}
sx={{ flexGrow: 1, height: 0, width: 1, py: 1 }}
sx={{
flexGrow: 1,
height: 0,
overflowY: "auto",
overflowX: "hidden",
width: 1,
py: 1,
}}
>
{buildTree([studiesTree])}
</SimpleTreeView>
Expand Down
Loading

0 comments on commit 88a7a53

Please sign in to comment.