diff --git a/src/components/menus/content-contextual-menu.tsx b/src/components/menus/content-contextual-menu.tsx index c250bcd3..dff22647 100644 --- a/src/components/menus/content-contextual-menu.tsx +++ b/src/components/menus/content-contextual-menu.tsx @@ -26,10 +26,10 @@ import { ElementAttributes, ElementType, FilterCreationDialog, + PARAM_DEVELOPER_MODE, + PARAM_LANGUAGE, TreeViewFinderNodeProps, useSnackMessage, - PARAM_LANGUAGE, - PARAM_DEVELOPER_MODE, } from '@gridsuite/commons-ui'; import RenameDialog from '../dialogs/rename-dialog'; import DeleteDialog from '../dialogs/delete-dialog'; diff --git a/src/components/toolbars/content-toolbar.tsx b/src/components/toolbars/content-toolbar.tsx index 5fa0e8c6..ed022ee8 100644 --- a/src/components/toolbars/content-toolbar.tsx +++ b/src/components/toolbars/content-toolbar.tsx @@ -10,12 +10,12 @@ import { useSelector } from 'react-redux'; import { useIntl } from 'react-intl'; import { Delete as DeleteIcon, + DownloadForOffline, DriveFileMove as DriveFileMoveIcon, FileDownload, TableView as TableViewIcon, - DownloadForOffline, } from '@mui/icons-material'; -import { ElementAttributes, ElementType, useSnackMessage, PARAM_DEVELOPER_MODE } from '@gridsuite/commons-ui'; +import { ElementAttributes, ElementType, PARAM_DEVELOPER_MODE, useSnackMessage } from '@gridsuite/commons-ui'; import { deleteElements, moveElementsToDirectory, PermissionType } from '../../utils/rest-api'; import DeleteDialog from '../dialogs/delete-dialog'; import CommonToolbar, { CommonToolbarProps } from './common-toolbar'; @@ -45,7 +45,6 @@ export default function ContentToolbar(props: Readonly) { const [openDialog, setOpenDialog] = useState(constants.DialogsId.NONE); const [directoryWritable, setDirectoryWritable] = useState(false); const [enableDeveloperMode] = useParameterState(PARAM_DEVELOPER_MODE); - useEffect(() => { if (selectedDirectory !== null) { checkPermissionOnDirectory(selectedDirectory, PermissionType.WRITE).then((b) => { diff --git a/src/components/tree-views-container.tsx b/src/components/tree-views-container.tsx index 59a1b853..c9e545d6 100644 --- a/src/components/tree-views-container.tsx +++ b/src/components/tree-views-container.tsx @@ -34,6 +34,7 @@ import * as constants from '../utils/UIconstants'; import DirectoryTreeContextualMenu from './menus/directory-tree-contextual-menu'; import { AppState, IDirectory, ITreeData, UploadingElement } from '../redux/types'; import { buildPathToFromMap, updatedTree } from './treeview-utils'; +import { useExportNotification } from '../hooks/use-export-notification'; const initialMousePosition = { mouseX: null, @@ -115,6 +116,8 @@ export default function TreeViewsContainer() { const treeDataRef = useRef(); treeDataRef.current = treeData; + useExportNotification(); + const handleOpenDirectoryMenu = useCallback( (event: ReactMouseEvent) => { setOpenDirectoryMenu(true); diff --git a/src/components/utils/downloadUtils.ts b/src/components/utils/downloadUtils.ts index 2909c9c3..1475da50 100644 --- a/src/components/utils/downloadUtils.ts +++ b/src/components/utils/downloadUtils.ts @@ -15,13 +15,14 @@ import { downloadSpreadsheetConfigCollection, fetchConvertedCase, } from '../../utils/rest-api'; +import { buildExportIdentifier, setExportSubscription } from '../../utils/case-export-utils'; interface DownloadData { blob: Blob; filename: string; } -const triggerDownload = ({ blob, filename }: DownloadData): void => { +export const triggerDownload = ({ blob, filename }: DownloadData): void => { const href = window.URL.createObjectURL(blob); const link = document.createElement('a'); link.href = href; @@ -106,7 +107,7 @@ export function useDownloadUtils() { fileName?: string ): Promise => { try { - const result = await fetchConvertedCase( + const exportUuid = await fetchConvertedCase( caseElement.elementUuid, fileName || caseElement.elementName, // if no fileName is provided or empty, the case name will be used format, @@ -114,24 +115,22 @@ export function useDownloadUtils() { abortController2 ); - let downloadFileName = - result.headers.get('Content-Disposition')?.split('filename=')[1] ?? - fileName ?? - caseElement.elementName; - // We remove quotes - downloadFileName = downloadFileName.substring(1, downloadFileName.length - 1); - - const blob = await result.blob(); - - triggerDownload({ blob, filename: downloadFileName }); + const identifier = buildExportIdentifier({ + caseUuid: caseElement.elementUuid, + exportUuid, + }); + setExportSubscription(identifier); + snackInfo({ + messageTxt: intl.formatMessage({ id: 'export.message.started' }), + }); } catch (error: any) { if (error.name === 'AbortError') { throw error; } - handleDownloadError(caseElement, error); + handleDownloadError(caseElement, error.message || 'Export failed'); } }, - [handleDownloadError] + [handleDownloadError, intl, snackInfo] ); const buildPartialDownloadMessage = useCallback( diff --git a/src/hooks/use-export-download.ts b/src/hooks/use-export-download.ts new file mode 100644 index 00000000..644f0345 --- /dev/null +++ b/src/hooks/use-export-download.ts @@ -0,0 +1,44 @@ +/** + * Copyright (c) 2025, 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 { useSnackMessage } from '@gridsuite/commons-ui'; +import { useCallback } from 'react'; +import { UUID } from 'node:crypto'; +import { triggerDownload } from '../components/utils/downloadUtils'; +import { fetchExportNetworkFile } from '../utils/rest-api'; + +export function useExportDownload() { + const { snackError } = useSnackMessage(); + + const downloadExportFile = useCallback( + (exportUuid: UUID) => { + fetchExportNetworkFile(exportUuid) + .then(async (response) => { + const contentDisposition = response.headers.get('Content-Disposition'); + let filename = 'export.zip'; + if (contentDisposition?.includes('filename=')) { + const regex = /filename="?([^"]+)"?/; + const [, extractedFilename] = regex.exec(contentDisposition) ?? []; + if (extractedFilename) { + filename = extractedFilename; + } + } + + const blob = await response.blob(); + triggerDownload({ blob, filename }); + }) + .catch((error: Error) => { + snackError({ + messageTxt: error.message, + headerId: 'export.header.failed', + }); + }); + }, + [snackError] + ); + return { downloadExportFile }; +} diff --git a/src/hooks/use-export-notification.ts b/src/hooks/use-export-notification.ts new file mode 100644 index 00000000..281cae3e --- /dev/null +++ b/src/hooks/use-export-notification.ts @@ -0,0 +1,50 @@ +/** + * Copyright (c) 2025, 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 { useIntl } from 'react-intl'; +import { NotificationsUrlKeys, useNotificationsListener, useSnackMessage } from '@gridsuite/commons-ui'; +import { useSelector } from 'react-redux'; +import { useCallback } from 'react'; +import { AppState } from '../redux/types'; +import { buildExportIdentifier, isExportSubscribed, unsetExportSubscription } from '../utils/case-export-utils'; +import { useExportDownload } from './use-export-download'; + +export function useExportNotification() { + const intl = useIntl(); + const { snackError, snackInfo } = useSnackMessage(); + const { downloadExportFile } = useExportDownload(); + const userIdProfile = useSelector((state: AppState) => state.user?.profile.sub); + const handleExportNotification = useCallback( + (event: MessageEvent) => { + const eventData = JSON.parse(event.data); + if (eventData?.headers?.notificationType === 'caseExportSucceeded') { + const { caseUuid, userId, exportUuid, error } = eventData.headers; + const exportIdentifierNotif = buildExportIdentifier({ + caseUuid, + exportUuid, + }); + const isSubscribed = isExportSubscribed(exportIdentifierNotif); + if (isSubscribed && userIdProfile === userId) { + unsetExportSubscription(exportIdentifierNotif); + + if (error) { + snackError({ + messageTxt: error, + }); + } else { + downloadExportFile(exportUuid); + snackInfo({ + messageTxt: intl.formatMessage({ id: 'export.message.succeeded' }), + }); + } + } + } + }, + [userIdProfile, snackError, downloadExportFile, snackInfo, intl] + ); + + useNotificationsListener(NotificationsUrlKeys.DIRECTORY, { listenerCallbackMessage: handleExportNotification }); +} diff --git a/src/redux/export-network-state.ts b/src/redux/export-network-state.ts new file mode 100644 index 00000000..c5ed92e5 --- /dev/null +++ b/src/redux/export-network-state.ts @@ -0,0 +1,23 @@ +/** + * Copyright (c) 2025, 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 { APP_NAME } from '../utils/config-params'; + +const SESSION_STORAGE_EXPORT_STATE_KEY_PREFIX = `${APP_NAME.toUpperCase()}_EXPORT_STATE_`; + +export function getExportState() { + const objJson = sessionStorage.getItem(SESSION_STORAGE_EXPORT_STATE_KEY_PREFIX); + if (objJson) { + const array = JSON.parse(objJson) as string[]; + return new Set(array); + } + return undefined; +} + +export function saveExportState(newExportState: Set): void { + const array = Array.from(newExportState); + sessionStorage.setItem(SESSION_STORAGE_EXPORT_STATE_KEY_PREFIX, JSON.stringify(array)); +} diff --git a/src/translations/en.json b/src/translations/en.json index f0a0c5ed..900afa8d 100644 --- a/src/translations/en.json +++ b/src/translations/en.json @@ -201,5 +201,8 @@ "visualization": "Visualization", "missingEquipmentsFromStudy": "Equipments missing from study", "equipmentTypesByFilters": "Contingencies per equipment types in voltage levels & substations", - "reload": "Reload" + "reload": "Reload", + "export.message.started": "Export file started", + "export.message.succeeded": "Export file succeeded", + "export.header.failed": "export file failed" } diff --git a/src/translations/fr.json b/src/translations/fr.json index f7be8a70..ba753eee 100644 --- a/src/translations/fr.json +++ b/src/translations/fr.json @@ -200,5 +200,8 @@ "visualization": "Visualisation", "missingEquipmentsFromStudy": "Ouvrages absents de l'étude", "equipmentTypesByFilters": "Aléas par types d'ouvrage dans les postes et sites", - "reload": "Rafraîchir" + "reload": "Rafraîchir", + "export.message.started": "Vous serez notifié lorsque l'export sera prêt.", + "export.message.succeeded": "Téléchargement en cours", + "export.header.failed": "Échec du téléchargement du fichier" } diff --git a/src/utils/case-export-utils.ts b/src/utils/case-export-utils.ts new file mode 100644 index 00000000..98aa017f --- /dev/null +++ b/src/utils/case-export-utils.ts @@ -0,0 +1,31 @@ +/** + * Copyright (c) 2025, 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 { UUID } from 'node:crypto'; +import { getExportState, saveExportState } from '../redux/export-network-state'; + +export function buildExportIdentifier({ caseUuid, exportUuid }: { caseUuid: UUID; exportUuid: String }): string { + return `${caseUuid}|${exportUuid}`; +} + +export function setExportSubscription(identifier: string): void { + const currentState = getExportState() || new Set(); + currentState.add(identifier); + saveExportState(currentState); +} + +export function unsetExportSubscription(identifier: string): void { + const currentState = getExportState(); + if (currentState) { + currentState.delete(identifier); + saveExportState(currentState); + } +} + +export function isExportSubscribed(identifier: string): boolean { + const currentState = getExportState(); + return currentState ? currentState.has(identifier) : false; +} diff --git a/src/utils/rest-api.ts b/src/utils/rest-api.ts index e690aad1..48215c15 100644 --- a/src/utils/rest-api.ts +++ b/src/utils/rest-api.ts @@ -736,16 +736,21 @@ export const fetchConvertedCase = ( formatParameters: unknown, abortController: AbortController ) => - backendFetch( + backendFetchText( `${PREFIX_NETWORK_CONVERSION_SERVER_QUERIES}/v1/cases/${caseUuid}/convert/${format}?fileName=${fileName}`, { - method: 'post', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(formatParameters), signal: abortController.signal, } ); +export const fetchExportNetworkFile = (exportUuid: UUID) => + backendFetch(`${PREFIX_NETWORK_CONVERSION_SERVER_QUERIES}/v1/download-file/${exportUuid}`, { + method: 'get', + headers: { 'Content-Type': 'application/json' }, + }); + export const downloadCase = (caseUuid: string) => backendFetch(`${PREFIX_CASE_QUERIES}/v1/cases/${caseUuid}`, { method: 'get',