Skip to content
170 changes: 170 additions & 0 deletions src/components/dialogs/import-study-dialog.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
/**
* 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 { useSelector } from 'react-redux';
import {
CustomMuiDialog,
DescriptionField,
ElementType,
ErrorInput,
extractErrorMessageDescriptor,
FieldConstants,
FieldErrorAlert,
isObjectEmpty,
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 } from '../../redux/types';
import { importStudy } from '../../utils/rest-api';
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<ImportStudyDialogProps>) {
const intl = useIntl();
const { snackError } = useSnackMessage();
const selectedDirectory = useSelector((state: AppState) => state.selectedDirectory);

const schema: yup.ObjectSchema<ImportStudyFormData> = yup.object().shape({
[FieldConstants.NAME]: yup.string().trim().required(NAME_EMPTY),
[FieldConstants.DESCRIPTION]: yup.string().max(MAX_CHAR_DESCRIPTION),
studyFiles: yup
.mixed<FileList>()
.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');
}),
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}) as yup.ObjectSchema<ImportStudyFormData>;

const importStudyFormMethods = useForm<ImportStudyFormData>({
mode: 'onChange',
resolver: yupResolver<ImportStudyFormData>(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<HTMLInputElement>) => {
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;
}
await importStudy(
data[FieldConstants.NAME],
data[FieldConstants.DESCRIPTION],
data.studyFiles?.[0] as File,
selectedDirectory.elementUuid
)
.then(() => onClose())
.catch((error) => {
const { descriptor, values } = extractErrorMessageDescriptor(error, 'studyImportError');
setError(`root.${FieldConstants.API_CALL}`, {
message: intl.formatMessage(descriptor, values).toString(),
});
});
},
[intl, onClose, selectedDirectory?.elementUuid, setError, snackError]
);
const isFormValid = isObjectEmpty(errors) && isValid;
return (
<CustomMuiDialog
titleId="importStudy"
formContext={{
...importStudyFormMethods,
validationSchema: schema,
removeOptional: true,
}}
open={open}
onClose={onClose}
onSave={handleImportStudy}
onCancel={onClose}
disabledSave={!isFormValid}
>
<Stack spacing={2} marginTop="auto">
<Grid>
<PrefilledNameInput
name={FieldConstants.NAME}
label="nameProperty"
elementType={ElementType.STUDY}
/>
</Grid>
<Grid
sx={{
opacity: 0.5,
pointerEvents: 'none',
}}
>
<DescriptionField />
</Grid>
Comment thread
coderabbitai[bot] marked this conversation as resolved.
<Grid container alignItems="center" spacing={1} pt={1}>
<Grid>
<Button variant="contained" color="primary" component="label">
<FormattedMessage id="uploadStudy" />
<Input
ref={ref}
type="file"
name="studyFiles"
inputProps={{ accept: '.zip' }}
onChange={onFileChange}
sx={{ display: 'none' }}
data-testid="ArchiveFileUpload"
/>
</Button>
</Grid>
<Grid sx={{ fontWeight: 'bold' }}>
<p>{studyFileName ?? intl.formatMessage({ id: 'uploadMessage' })}</p>
</Grid>
</Grid>
</Stack>
<ErrorInput name="studyFiles" InputField={FieldErrorAlert} />
</CustomMuiDialog>
);
}
57 changes: 53 additions & 4 deletions src/components/menus/content-contextual-menu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,12 +39,18 @@ import RenameDialog from '../dialogs/rename-dialog';
import DeleteDialog from '../dialogs/delete-dialog';
import CreateStudyDialog from '../dialogs/create-study-dialog/create-study-dialog';
import { DialogsId } from '../../utils/UIconstants';
import { deleteElements, duplicateElement, moveElementsToDirectory, renameElement } from '../../utils/rest-api';
import {
deleteElements,
duplicateElement,
exportStudy,
moveElementsToDirectory,
renameElement,
} from '../../utils/rest-api';
import { FilterType } from '../../utils/elementType';
import CommonContextualMenu, { CommonContextualMenuProps, MenuItemType } from './common-contextual-menu';
import { useDeferredFetch, useMultipleDeferredFetch } from '../../utils/custom-hooks';
import MoveDialog from '../dialogs/move-dialog';
import { useDownloadUtils } from '../utils/downloadUtils';
import { triggerDownload, useDownloadUtils } from '../utils/downloadUtils';
import ExportCaseDialog from '../dialogs/export-case-dialog';
import { setItemSelectionForCopy } from '../../redux/actions';
import { useParameterState } from '../dialogs/use-parameters-dialog';
Expand Down Expand Up @@ -407,6 +413,39 @@ export default function ContentContextualMenu(props: Readonly<ContentContextualM
);
}, [isSingleElement, selectedElements]);

const couldExportStudy = useCallback(() => {
return (
isDeveloperMode &&
isSingleElement &&
activeElement.elementUuid &&
activeElement.type === ElementType.STUDY &&
noCreationInProgress()
);
}, [isDeveloperMode, isSingleElement, activeElement.elementUuid, activeElement.type, noCreationInProgress]);

const handleExportStudy = useCallback(async () => {
try {
const response = await exportStudy(activeElement.elementUuid, activeElement.elementName);
let fileName = `${activeElement.elementName}.zip`;
const contentDisposition = response.headers.get('Content-Disposition');
if (contentDisposition?.includes('filename=')) {
const regex = /filename="?([^"]+)"?/;
const [, extractedFilename] = regex.exec(contentDisposition) ?? [];
if (extractedFilename) {
fileName = extractedFilename;
}
}
const blob = await response.blob();
triggerDownload({ blob, filename: fileName });
snackInfo({
messageTxt: `${intl.formatMessage({ id: 'exportStudy' })} ${activeElement.elementName}`,
});
handleCloseDialog();
} catch {
snackError({ headerId: 'exportStudyFailed' });
}
}, [activeElement, handleCloseDialog, intl, snackInfo, snackError]);

useEffect(() => {
let isCurrent = true;
if (selectedDirectory !== null) {
Expand Down Expand Up @@ -526,6 +565,14 @@ export default function ContentContextualMenu(props: Readonly<ContentContextualM
);
}

if (couldExportStudy()) {
menuItems.push({
messageDescriptorId: 'export.button',
callback: handleExportStudy,
icon: <FileDownload fontSize="small" data-testid="ExportStudyIcon" />,
});
}

if (couldDelete()) {
menuItems.push({
messageDescriptorId: 'delete',
Expand Down Expand Up @@ -591,8 +638,9 @@ export default function ContentContextualMenu(props: Readonly<ContentContextualM
couldCreateNewStudyFromCase,
couldDuplicate,
couldCopy,
couldDelete,
couldDisplaySharingLinks,
couldExportStudy,
couldDelete,
couldDownload,
isDeveloperMode,
couldExportCase,
Expand All @@ -603,10 +651,11 @@ export default function ContentContextualMenu(props: Readonly<ContentContextualM
duplicateItem,
allowsDuplicateOrCopy,
copyItem,
noCreationInProgress,
copyLinkItem,
handleExportStudy,
downloadElements,
handleCloseDialog,
noCreationInProgress,
]);

const renderDialog = () => {
Expand Down
10 changes: 10 additions & 0 deletions src/components/menus/directory-tree-contextual-menu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<CommonContextualMenuProps, 'onClose'> {
directory: ElementAttributes | null;
Expand Down Expand Up @@ -220,6 +221,13 @@ export default function DirectoryTreeContextualMenu(props: Readonly<DirectoryTre
}

if (directory && directoryWritable) {
if (isDeveloperMode) {
menuItems.push({
messageDescriptorId: 'importStudy',
callback: () => handleOpenDialog(DialogsId.IMPORT_STUDY_FROM_EXPORTED_STUDY),
icon: <AddIcon fontSize="small" data-testid="ImportStudyIcon" />,
});
}
menuItems.push(
{
messageDescriptorId: 'createNewStudy',
Expand Down Expand Up @@ -352,6 +360,8 @@ export default function DirectoryTreeContextualMenu(props: Readonly<DirectoryTre
switch (openDialog) {
case DialogsId.ADD_NEW_STUDY:
return <CreateStudyForm open onClose={handleCloseDialog} />;
case DialogsId.IMPORT_STUDY_FROM_EXPORTED_STUDY:
return <ImportStudyDialog open onClose={handleCloseDialog} />;
case DialogsId.ADD_NEW_EXPLICIT_NAMING_CONTINGENCY_LIST:
return (
<ExplicitNamingCreationDialog open titleId="createNewContingencyList" onClose={handleCloseDialog} />
Expand Down
8 changes: 7 additions & 1 deletion src/translations/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -208,5 +208,11 @@
"export.message.started": "Export {fileName} started",
"export.message.succeeded": "Export {fileName} succeeded",
"export.message.failed": "Export file failed {error}",
"PageNotFound": "Page not found"
"PageNotFound": "Page not found",
"export.button": "Export study",
"exportStudy": "Export study succeeded",
"exportStudyFailed": "Export study failed",
"importStudy": "Import a study",
"uploadStudyErrorMsg": "Please upload study archive (.zip)",
"studyImportError": "Error while importing study"
}
8 changes: 7 additions & 1 deletion src/translations/fr.json
Original file line number Diff line number Diff line change
Expand Up @@ -207,5 +207,11 @@
"export.message.started": "Export du {fileName} a commencé",
"export.message.succeeded": "Téléchargement du {fileName} a réussi",
"export.message.failed": "Échec du téléchargement du fichier {error}",
"PageNotFound": "Page introuvable"
"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é",
"importStudy": "Importer une étude",
"uploadStudyErrorMsg": "Veuillez télécharger l'étude (.zip)",
"studyImportError": "Erreur lors de l'importation de l'étude"
}
1 change: 1 addition & 0 deletions src/utils/UIconstants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
25 changes: 25 additions & 0 deletions src/utils/rest-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -826,3 +826,28 @@ export function updateProcessConfig<TProcessType extends ProcessType>(
body: JSON.stringify(processConfig),
});
}

export function exportStudy(studyUuid: UUID, studyName: string) {
console.info('Exporting study %s', studyUuid);
const url = `${PREFIX_STUDY_QUERIES}/v1/studies/${encodeURIComponent(studyUuid)}/export/${encodeURIComponent(studyName)}`;
console.debug(url);
return backendFetch(url, {
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,
});
}
Loading