Skip to content
New issue

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

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

Already on GitHub? Sign in to your account

feat(api,ui-studies): update study move #2239

Merged
merged 5 commits into from
Dec 17, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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}"
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just a note for later: would probably be best to use PurePosixPath objects than just strings to represent our paths.

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
Loading