diff --git a/src/components/dialogs/import-study-dialog.tsx b/src/components/dialogs/import-study-dialog.tsx new file mode 100644 index 000000000..ea3a2129d --- /dev/null +++ b/src/components/dialogs/import-study-dialog.tsx @@ -0,0 +1,190 @@ +/** + * Copyright (c) 2026, RTE (http://www.rte-france.com) + * 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/. + */ + +import { ChangeEvent, useCallback } from 'react'; +import { FormattedMessage, useIntl } from 'react-intl'; +import { useDispatch, useSelector } from 'react-redux'; +import { + CustomMuiDialog, + DescriptionField, + ElementType, + ErrorInput, + extractErrorMessageDescriptor, + FieldConstants, + FieldErrorAlert, + isObjectEmpty, + keyGenerator, + MAX_CHAR_DESCRIPTION, + NAME_EMPTY, + useSnackMessage, +} from '@gridsuite/commons-ui'; +import { Button, Grid, Input, Stack } from '@mui/material'; +import { FieldValues, useController, useForm } from 'react-hook-form'; +import { yupResolver } from '@hookform/resolvers/yup'; +import * as yup from 'yup'; +import { AppState, UploadingElement } from '../../redux/types'; +import { importStudy } from '../../utils/rest-api'; +import { addUploadingElement, removeUploadingElement } from '../../redux/actions'; +import PrefilledNameInput from './commons/prefilled-name-input'; + +interface ImportStudyDialogProps { + open: boolean; + onClose: () => void; +} + +interface ImportStudyFormData { + [FieldConstants.NAME]: string; + [FieldConstants.DESCRIPTION]: string; + studyFiles?: FileList; +} + +export default function ImportStudyDialog({ open, onClose }: Readonly) { + const intl = useIntl(); + const { snackError } = useSnackMessage(); + const dispatch = useDispatch(); + const selectedDirectory = useSelector((state: AppState) => state.selectedDirectory); + const userId = useSelector((state: AppState) => state.user?.profile.sub); + + const schema: yup.ObjectSchema = yup.object().shape({ + [FieldConstants.NAME]: yup.string().trim().required(NAME_EMPTY), + [FieldConstants.DESCRIPTION]: yup.string().max(MAX_CHAR_DESCRIPTION), + studyFiles: yup + .mixed() + .test('required', intl.formatMessage({ id: 'uploadStudyErrorMsg' }), (value) => { + return value !== undefined && value !== null && value.length > 0; + }) + .test('fileType', intl.formatMessage({ id: 'uploadStudyErrorMsg' }), (value) => { + if (!value || value.length === 0) return false; + const file = value[0] as File; + return file.name.endsWith('.zip'); + }), + }) as yup.ObjectSchema; + + const importStudyFormMethods = useForm({ + mode: 'onChange', + resolver: yupResolver(schema), + defaultValues: { + [FieldConstants.NAME]: '', + [FieldConstants.DESCRIPTION]: '', + }, + }); + const { + field: { ref, value: studyFiles, onChange: onStudyFilesChange }, + } = useController({ + name: 'studyFiles', + control: importStudyFormMethods.control, + }); + + const studyFileName = (studyFiles as FileList | undefined)?.[0]?.name; + + const { + formState: { errors, isValid }, + setError, + setValue, + } = importStudyFormMethods; + + const onFileChange = (event: ChangeEvent) => { + const files = event.target.files as FileList; + if (files && files.length > 0) { + onStudyFilesChange(event.target.files); + setValue(FieldConstants.NAME, files[0].name.replace(/\.zip$/i, ''), { shouldValidate: true }); + } + }; + + const handleImportStudy = useCallback( + async (data: FieldValues) => { + if (!selectedDirectory?.elementUuid) { + snackError({ headerId: 'studyImportError' }); + return; + } + const studyName = data[FieldConstants.NAME]; + const uploadingStudy: UploadingElement = { + id: keyGenerator()(), + elementName: studyName, + directory: selectedDirectory.elementUuid, + type: ElementType.STUDY, + owner: userId, + lastModifiedBy: userId, + uploading: true, + }; + + importStudy( + studyName, + data[FieldConstants.DESCRIPTION], + data.studyFiles?.[0] as File, + selectedDirectory.elementUuid + ) + .then(() => onClose()) + .catch((error) => { + dispatch(removeUploadingElement(uploadingStudy)); + const { descriptor, values } = extractErrorMessageDescriptor(error, 'studyImportError'); + setError(`root.${FieldConstants.API_CALL}`, { + message: intl.formatMessage(descriptor, values).toString(), + }); + }); + + // the uploadingStudy ghost element will be removed when directory + // content updated by fetch + dispatch(addUploadingElement(uploadingStudy)); + }, + [dispatch, intl, onClose, selectedDirectory?.elementUuid, setError, snackError, userId] + ); + const isFormValid = isObjectEmpty(errors) && isValid; + return ( + + + + + + + + + + + + + +

{studyFileName ?? intl.formatMessage({ id: 'uploadMessage' })}

+
+
+
+ +
+ ); +} diff --git a/src/components/menus/directory-tree-contextual-menu.tsx b/src/components/menus/directory-tree-contextual-menu.tsx index 1976c387b..2024d4401 100644 --- a/src/components/menus/directory-tree-contextual-menu.tsx +++ b/src/components/menus/directory-tree-contextual-menu.tsx @@ -56,6 +56,7 @@ import { checkPermissionOnDirectory } from './menus-utils'; import DirectoryPropertiesDialog from '../dialogs/directory-properties/directory-properties-dialog'; import { FilterType } from '../../utils/elementType'; import FilterBasedContingencyListDialog from '../dialogs/contingency-list/filter-based/contingency-list-filter-based-dialog'; +import ImportStudyDialog from '../dialogs/import-study-dialog'; export interface DirectoryTreeContextualMenuProps extends Omit { directory: ElementAttributes | null; @@ -220,6 +221,13 @@ export default function DirectoryTreeContextualMenu(props: Readonly handleOpenDialog(DialogsId.IMPORT_STUDY_FROM_EXPORTED_STUDY), + icon: , + }); + } menuItems.push( { messageDescriptorId: 'createNewStudy', @@ -352,6 +360,8 @@ export default function DirectoryTreeContextualMenu(props: Readonly; + case DialogsId.IMPORT_STUDY_FROM_EXPORTED_STUDY: + return ; case DialogsId.ADD_NEW_EXPLICIT_NAMING_CONTINGENCY_LIST: return ( diff --git a/src/translations/en.json b/src/translations/en.json index a44ddad6a..d1ca48a32 100644 --- a/src/translations/en.json +++ b/src/translations/en.json @@ -209,5 +209,8 @@ "PageNotFound": "Page not found", "export.button": "Export study", "exportStudy": "Export study succeeded", - "exportStudyFailed": "Export study failed" + "exportStudyFailed": "Export study failed", + "importStudy": "Import a study", + "uploadStudyErrorMsg": "Please upload study archive (.zip)", + "studyImportError": "Error while importing study" } diff --git a/src/translations/fr.json b/src/translations/fr.json index 49a08f308..0e9b038d4 100644 --- a/src/translations/fr.json +++ b/src/translations/fr.json @@ -208,5 +208,8 @@ "PageNotFound": "Page introuvable", "export.button": "Exporter l'étude", "exportStudy": "L'export de l'étude a réussi", - "exportStudyFailed": "L'export de l'étude a échoué" + "exportStudyFailed": "L'export de l'étude a échoué", + "importStudy": "Importer une étude", + "uploadStudyErrorMsg": "Veuillez télécharger l'étude (.zip)", + "studyImportError": "Erreur lors de l'importation de l'étude" } diff --git a/src/utils/UIconstants.ts b/src/utils/UIconstants.ts index 6fa45f733..f4be409a4 100644 --- a/src/utils/UIconstants.ts +++ b/src/utils/UIconstants.ts @@ -18,6 +18,7 @@ export const DialogsId = { MOVE_DIRECTORY: 'moveDirectory', EXPORT: 'export', ADD_NEW_STUDY_FROM_CASE: 'create_study_from_case', + IMPORT_STUDY_FROM_EXPORTED_STUDY: 'import_study_from_exported_study', ADD_NEW_STUDY: 'create_study', CONVERT_TO_EXPLICIT_NAMING_FILTER: 'convert_to_explicit_naming_filter', CREATE_SPREADSHEET_COLLECTION: 'create_spreadsheet_collection', diff --git a/src/utils/rest-api.ts b/src/utils/rest-api.ts index d06725585..d7445cc85 100644 --- a/src/utils/rest-api.ts +++ b/src/utils/rest-api.ts @@ -834,3 +834,19 @@ export function exportStudy(studyUuid: UUID, studyName: string) { method: 'get', }); } + +export function importStudy(studyName: string, description: string, archiveFile: File, parentDirectoryUuid: UUID) { + console.info('Importing study ...'); + const urlSearchParams = new URLSearchParams(); + urlSearchParams.append('studyName', studyName); + urlSearchParams.append('description', description); + urlSearchParams.append('parentDirectoryUuid', parentDirectoryUuid); + + const url = `${PREFIX_EXPLORE_SERVER_QUERIES}/v1/explore/studies/import?${urlSearchParams.toString()}`; + const formData = new FormData(); + formData.append('archiveFile', archiveFile); + return backendFetch(url, { + method: 'post', + body: formData, + }); +}