diff --git a/src/components/dialogs/export-network-dialog.tsx b/src/components/dialogs/export-network-dialog.tsx index 1da4d70c37..cee058b750 100644 --- a/src/components/dialogs/export-network-dialog.tsx +++ b/src/components/dialogs/export-network-dialog.tsx @@ -5,18 +5,17 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ import { + Alert, Collapse, Dialog, DialogTitle, - Stack, - Typography, - InputLabel, - Alert, FormControl, - Select, - MenuItem, - CircularProgress, IconButton, + InputLabel, + MenuItem, + Select, + Stack, + Typography, } from '@mui/material'; import ExpandLessIcon from '@mui/icons-material/ExpandLess'; import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; @@ -25,7 +24,7 @@ import DialogContent from '@mui/material/DialogContent'; import DialogActions from '@mui/material/DialogActions'; import Button from '@mui/material/Button'; import { useCallback, useEffect, useMemo, useState } from 'react'; -import { CancelButton, FlatParameters, fetchDirectoryElementPath, useSnackMessage } from '@gridsuite/commons-ui'; +import { CancelButton, fetchDirectoryElementPath, FlatParameters, useSnackMessage } from '@gridsuite/commons-ui'; import { ExportFormatProperties, getAvailableExportFormats } from '../../services/study'; import { getExportUrl } from '../../services/study/network'; import { isBlankOrEmpty } from 'components/utils/validation-functions'; @@ -51,7 +50,7 @@ const STRING_LIST = 'STRING_LIST'; interface ExportNetworkDialogProps { open: boolean; onClose: () => void; - onClick: (url: string) => void; + onClick: (url: string, selectedFormat: string, fileName: string) => void; studyUuid: UUID; nodeUuid: UUID; rootNetworkUuid: UUID; @@ -68,10 +67,9 @@ export function ExportNetworkDialog({ const intl = useIntl(); const [formatsWithParameters, setFormatsWithParameters] = useState>({}); const [selectedFormat, setSelectedFormat] = useState(''); - const [loading, setLoading] = useState(false); const [exportStudyErr, setExportStudyErr] = useState(''); const { snackError } = useSnackMessage(); - const [fileName, setFileName] = useState(); + const [fileName, setFileName] = useState(''); const [enableDeveloperMode] = useParameterState(PARAM_DEVELOPER_MODE); const [unfolded, setUnfolded] = useState(false); @@ -150,8 +148,7 @@ export function ExportNetworkDialog({ // we have already as parameters, the access tokens, so use '&' instead of '?' suffix = urlSearchParams.toString() ? '&' + urlSearchParams.toString() : ''; - setLoading(true); - onClick(downloadUrl + suffix); + onClick(downloadUrl + suffix, selectedFormat, fileName); } else { setExportStudyErr(intl.formatMessage({ id: 'exportStudyErrorMsg' })); } @@ -161,7 +158,6 @@ export function ExportNetworkDialog({ setCurrentParameters({}); setExportStudyErr(''); setSelectedFormat(''); - setLoading(false); onClose(); }; @@ -183,7 +179,7 @@ export function ExportNetworkDialog({ margin="dense" label={} id="fileName" - value={fileName} + value={fileName || ''} sx={{ width: '100%', marginBottom: 1 }} fullWidth variant="filled" @@ -234,17 +230,6 @@ export function ExportNetworkDialog({ /> {exportStudyErr !== '' && {exportStudyErr}} - {loading && ( -
- -
- )} diff --git a/src/components/network-modification-tree-pane.jsx b/src/components/network-modification-tree-pane.jsx index 41e7ca0f3e..007b146c1a 100644 --- a/src/components/network-modification-tree-pane.jsx +++ b/src/components/network-modification-tree-pane.jsx @@ -7,17 +7,17 @@ import { useCallback, useEffect, useRef, useState } from 'react'; import { + deletedOrRenamedNodes, + networkModificationHandleSubtree, networkModificationTreeNodeAdded, networkModificationTreeNodeMoved, networkModificationTreeNodesRemoved, networkModificationTreeNodesUpdated, removeNotificationByNode, - networkModificationHandleSubtree, - setNodeSelectionForCopy, - resetLogsFilter, reorderNetworkModificationTreeNodes, - deletedOrRenamedNodes, + resetLogsFilter, resetLogsPagination, + setNodeSelectionForCopy, } from '../redux/actions'; import { useDispatch, useSelector } from 'react-redux'; import PropTypes from 'prop-types'; @@ -29,20 +29,21 @@ import { BUILD_STATUS } from './network/constants'; import { copySubtree, copyTreeNode, + createNodeSequence, createTreeNode, cutSubtree, cutTreeNode, - stashSubtree, - stashTreeNode, fetchNetworkModificationSubtree, fetchNetworkModificationTreeNode, fetchStashedNodes, - createNodeSequence, + stashSubtree, + stashTreeNode, } from '../services/study/tree-subtree'; import { buildNode, getUniqueNodeName, unbuildNode } from '../services/study/index'; import { RestoreNodesDialog } from './dialogs/restore-node-dialog'; import { CopyType } from './network-modification.type'; import { NodeSequenceType, NotificationType, PENDING_MODIFICATION_NOTIFICATION_TYPES } from 'types/notification-types'; +import useExportSubscription from '../hooks/use-export-subscription.js'; const noNodeSelectionForCopy = { sourceStudyUuid: null, @@ -123,6 +124,12 @@ export const NetworkModificationTreePane = ({ studyUuid, currentRootNetworkUuid, const studyUpdatedForce = useSelector((state) => state.studyUpdated); + const { subscribeExport } = useExportSubscription({ + studyUuid: studyUuid, + nodeUuid: currentNode?.id, + rootNetworkUuid: currentRootNetworkUuid, + }); + const updateNodes = useCallback( (updatedNodesIds) => { Promise.all( @@ -461,7 +468,8 @@ export const NetworkModificationTreePane = ({ studyUuid, currentRootNetworkUuid, const [openExportDialog, setOpenExportDialog] = useState(false); - const handleClickExportNodeNetwork = (url) => { + const handleClickExportNodeNetwork = (url, selectedFormat, fileName) => { + subscribeExport(selectedFormat, fileName); window.open(url, DownloadIframe); setOpenExportDialog(false); }; diff --git a/src/components/study-container.jsx b/src/components/study-container.jsx index ea6a197e4c..8752265f83 100644 --- a/src/components/study-container.jsx +++ b/src/components/study-container.jsx @@ -39,6 +39,7 @@ import { fetchStudy, recreateStudyNetwork, reindexAllRootNetwork } from 'service import { HttpStatusCode } from 'utils/http-status-code'; import { NodeType } from './graph/tree-node.type'; import { + isExportNetworkNotification, isIndexationStatusNotification, isLoadflowResultNotification, isStateEstimationResultNotification, @@ -47,6 +48,7 @@ import { RootNetworkIndexationStatus, } from 'types/notification-types'; import { useDiagramGridLayout } from 'hooks/use-diagram-grid-layout'; +import useExportNotificationHandler from '../hooks/use-export-notification-handler.js'; function useStudy(studyUuidRequest) { const dispatch = useDispatch(); @@ -143,6 +145,8 @@ export function StudyContainer({ view, onChangeTab }) { const { snackError, snackWarning, snackInfo } = useSnackMessage(); + const { handleExportNotification } = useExportNotificationHandler(); + const displayErrorNotifications = useCallback( (eventData) => { const updateTypeHeader = eventData.headers.updateType; @@ -264,11 +268,15 @@ export function StudyContainer({ view, onChangeTab }) { sendAlert(eventData); return; // here, we do not want to update the redux state } + if (isExportNetworkNotification(eventData)) { + handleExportNotification(eventData); + return; + } displayErrorNotifications(eventData); dispatch(studyUpdated(eventData)); }, // Note: dispatch doesn't change - [dispatch, displayErrorNotifications, sendAlert] + [dispatch, displayErrorNotifications, handleExportNotification, sendAlert] ); useNotificationsListener(NotificationsUrlKeys.STUDY, { listenerCallbackMessage: handleStudyUpdate }); diff --git a/src/hooks/use-export-download.ts b/src/hooks/use-export-download.ts new file mode 100644 index 0000000000..246bf3195c --- /dev/null +++ b/src/hooks/use-export-download.ts @@ -0,0 +1,51 @@ +/** + * 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 'crypto'; +import { useSnackMessage } from '@gridsuite/commons-ui'; +import { useCallback } from 'react'; +import { fetchExportNetworkFile } from '../services/study'; +import { downloadZipFile } from '../services/utils'; +import { HttpStatusCode } from '../utils/http-status-code'; + +export function useExportDownload() { + const { snackWarning, snackError } = useSnackMessage(); + + const downloadExportNetworkFile = 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 match = regex.exec(contentDisposition); + if (match?.[1]) { + filename = match[1]; + } + } + + const blob = await response.blob(); + downloadZipFile(blob, filename); + }) + .catch((responseError: any) => { + const error = responseError as Error & { status: number }; + if (error.status === HttpStatusCode.NOT_FOUND) { + snackWarning({ + headerId: 'export.header.fileNotFound', + }); + } else { + snackError({ + messageTxt: error.message, + headerId: 'export.header.fileError', + }); + } + }); + }, + [snackWarning, snackError] + ); + return { downloadExportNetworkFile }; +} diff --git a/src/hooks/use-export-notification-handler.ts b/src/hooks/use-export-notification-handler.ts new file mode 100644 index 0000000000..452f65e115 --- /dev/null +++ b/src/hooks/use-export-notification-handler.ts @@ -0,0 +1,103 @@ +/** + * 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 'crypto'; +import { useIntl } from 'react-intl'; +import { useSnackMessage } from '@gridsuite/commons-ui'; +import { useCallback } from 'react'; +import { useExportDownload } from './use-export-download'; +import { useSelector } from 'react-redux'; +import { AppState } from '../redux/reducer'; +import { ExportNetworkEventData } from '../types/notification-types'; + +export function buildExportIdentifier({ + studyUuid, + nodeUuid, + rootNetworkUuid, + format, + fileName, +}: { + studyUuid: UUID; + nodeUuid: UUID; + rootNetworkUuid: UUID; + format: string; + fileName: string; +}) { + return `${studyUuid}|${rootNetworkUuid}|${nodeUuid}|${fileName}|${format}`; +} + +function getExportState(): Set | null { + const state = sessionStorage.getItem('export-subscriptions'); + return state ? new Set(JSON.parse(state)) : null; +} + +function saveExportState(state: Set): void { + sessionStorage.setItem('export-subscriptions', JSON.stringify([...state])); +} + +export function isExportSubscribed(identifier: string): boolean { + const exportState = getExportState(); + return exportState?.has(identifier) ?? false; +} + +export function unsetExportSubscription(identifier: string): void { + const exportState = getExportState(); + if (exportState) { + exportState.delete(identifier); + saveExportState(exportState); + } +} + +export default function useExportNotificationHandler() { + const intl = useIntl(); + const { snackWarning, snackInfo } = useSnackMessage(); + const { downloadExportNetworkFile } = useExportDownload(); + const userId = useSelector((state: AppState) => state.user?.profile.sub); + + const handleExportNotification = useCallback( + (eventData: ExportNetworkEventData) => { + const { + studyUuid, + node, + rootNetworkUuid, + format, + userId: useId, + exportUuid, + fileName, + error, + } = eventData.headers; + + const exportIdentifierNotif = buildExportIdentifier({ + studyUuid: studyUuid, + nodeUuid: node, + rootNetworkUuid: rootNetworkUuid, + format, + fileName, + }); + + const isSubscribed = isExportSubscribed(exportIdentifierNotif); + + if (isSubscribed && useId === userId) { + unsetExportSubscription(exportIdentifierNotif); + + if (error) { + snackWarning({ + messageTxt: error, + }); + } else { + downloadExportNetworkFile(exportUuid); + snackInfo({ + headerTxt: intl.formatMessage({ id: 'exportNetwork' }), + messageTxt: intl.formatMessage({ id: 'export.message.downloadStarted' }, { fileName }), + }); + } + } + }, + [userId, snackWarning, downloadExportNetworkFile, snackInfo, intl] + ); + + return { handleExportNotification }; +} diff --git a/src/hooks/use-export-subscription.ts b/src/hooks/use-export-subscription.ts new file mode 100644 index 0000000000..a67e7fbfa9 --- /dev/null +++ b/src/hooks/use-export-subscription.ts @@ -0,0 +1,74 @@ +/** + * 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 'crypto'; +import { useIntl } from 'react-intl'; +import { useSnackMessage } from '@gridsuite/commons-ui'; +import { useCallback } from 'react'; + +export function buildExportIdentifier({ + studyUuid, + nodeUuid, + rootNetworkUuid, + format, + fileName, +}: { + studyUuid: UUID; + nodeUuid: UUID; + rootNetworkUuid: UUID; + format: string; + fileName: string; +}) { + return `${studyUuid}|${rootNetworkUuid}|${nodeUuid}|${fileName}|${format}`; +} + +function getExportState(): Set | null { + const state = sessionStorage.getItem('export-subscriptions'); + return state ? new Set(JSON.parse(state)) : null; +} + +function saveExportState(state: Set): void { + sessionStorage.setItem('export-subscriptions', JSON.stringify([...state])); +} + +export function setExportSubscription(identifier: string): void { + const exportState = getExportState() ?? new Set(); + exportState.add(identifier); + saveExportState(exportState); +} + +export default function useExportSubscription({ + studyUuid, + nodeUuid, + rootNetworkUuid, +}: { + studyUuid: UUID; + nodeUuid: UUID; + rootNetworkUuid: UUID; +}) { + const intl = useIntl(); + const { snackInfo } = useSnackMessage(); + + const subscribeExport = useCallback( + (format: string, fileName: string) => { + const identifier = buildExportIdentifier({ + studyUuid, + nodeUuid, + rootNetworkUuid, + format, + fileName, + }); + setExportSubscription(identifier); + snackInfo({ + headerTxt: intl.formatMessage({ id: 'exportNetwork' }), + messageTxt: intl.formatMessage({ id: 'export.message.subscribed' }, { fileName }), + }); + }, + [studyUuid, nodeUuid, rootNetworkUuid, snackInfo, intl] + ); + + return { subscribeExport }; +} diff --git a/src/services/study/index.ts b/src/services/study/index.ts index dfa2dd4d20..cbc8179676 100644 --- a/src/services/study/index.ts +++ b/src/services/study/index.ts @@ -8,7 +8,7 @@ import { backendFetch, backendFetchJson, backendFetchText, getRequestParamFromList } from '../utils'; import { UUID } from 'crypto'; import { COMPUTING_AND_NETWORK_MODIFICATION_TYPE } from '../../utils/report/report.constant'; -import { EquipmentType, ExtendedEquipmentType, Parameter, ComputingType } from '@gridsuite/commons-ui'; +import { ComputingType, EquipmentType, ExtendedEquipmentType, Parameter } from '@gridsuite/commons-ui'; import { NetworkModificationCopyInfo } from 'components/graph/menus/network-modifications/network-modification-menu.type'; import type { Svg } from 'components/grid-layout/cards/diagrams/diagram.type'; @@ -254,6 +254,11 @@ export function getAvailableExportFormats(): Promise { console.info('get available component libraries for diagrams'); const getAvailableComponentLibrariesUrl = PREFIX_STUDY_QUERIES + '/v1/svg-component-libraries'; diff --git a/src/translations/en.json b/src/translations/en.json index 353284c33a..db5694d3c9 100644 --- a/src/translations/en.json +++ b/src/translations/en.json @@ -2,6 +2,10 @@ "debug.message.downloadFile": "Computation data will be downloaded upon its completion.", "debug.header.fileNotFound": "Computation data not found", "debug.header.fileError": "Computation data download failed", + "export.message.subscribed": "You will be notified when the export is ready.", + "export.message.downloadStarted": "Starting download...", + "export.header.fileNotFound": "export file not found", + "export.header.fileError": "export file download failed", "button.delete": "Delete", "button.continue": "Continue", diff --git a/src/translations/fr.json b/src/translations/fr.json index 86a8c03669..592ca482ff 100644 --- a/src/translations/fr.json +++ b/src/translations/fr.json @@ -2,6 +2,10 @@ "debug.message.downloadFile": "À la fin du calcul ses données seront téléchargées.", "debug.header.fileNotFound": "Données de calcul non trouvées", "debug.header.fileError": "Échec du téléchargement des données de calcul", + "export.message.subscribed": "Vous serez notifié lorsque l'export sera prêt.", + "export.message.downloadStarted": "Téléchargement en cours", + "export.header.fileNotFound": "Fichier non trouvé", + "export.header.fileError": "Échec du téléchargement du fichier", "button.delete": "Supprimer", "button.continue": "Continuer", diff --git a/src/types/notification-types.ts b/src/types/notification-types.ts index a46a9e664b..38a7fe3200 100644 --- a/src/types/notification-types.ts +++ b/src/types/notification-types.ts @@ -36,6 +36,7 @@ export enum NotificationType { SUBTREE_MOVED = 'subtreeMoved', SUBTREE_CREATED = 'subtreeCreated', NODES_COLUMN_POSITION_CHANGED = 'nodesColumnPositionsChanged', + NETWORK_EXPORT_SUCCEEDED = 'networkExportSucceeded', // Modifications MODIFICATIONS_CREATION_IN_PROGRESS = 'creatingInProgress', MODIFICATIONS_UPDATING_IN_PROGRESS = 'updatingInProgress', @@ -377,6 +378,7 @@ interface ComputationResultEventDataHeaders extends CommonStudyEventDataHeaders node: UUID; rootNetworkUuid: UUID; } + interface ComputationStatusEventDataHeaders extends CommonStudyEventDataHeaders { node: UUID; rootNetworkUuid: UUID; @@ -513,6 +515,17 @@ interface StateEstimationStatusEventDataHeaders extends ComputationStatusEventDa updateType: NotificationType.STATE_ESTIMATION_STATUS; } +interface ExportNetworkEventDataHeaders extends CommonStudyEventDataHeaders { + updateType: NotificationType.NETWORK_EXPORT_SUCCEEDED; + rootNetworkUuid: UUID; + node: UUID; + userId: string; + fileName: string; + exportUuid: UUID; + format: string; + error: string; +} + // Payloads export interface DeletedEquipment { equipmentId: string; @@ -853,6 +866,11 @@ export interface StateEstimationStatusEventData { payload: undefined; } +export interface ExportNetworkEventData { + headers: ExportNetworkEventDataHeaders; + payload: undefined; +} + export interface SpreadsheetParametersUpdatedEventData extends Omit { headers: SpreadsheetParametersUpdatedDataHeaders; /** @@ -950,6 +968,7 @@ export function isEventCrudFinishedNotification(notif: unknown): notif is EventC export function isNodeDeletedNotification(notif: unknown): notif is NodesDeletedEventData { return (notif as NodesDeletedEventData).headers?.updateType === NotificationType.NODES_DELETED; } + export function isNodeCreatedNotification(notif: unknown): notif is NodeCreatedEventData { return (notif as NodeCreatedEventData).headers?.updateType === NotificationType.NODE_CREATED; } @@ -957,10 +976,15 @@ export function isNodeCreatedNotification(notif: unknown): notif is NodeCreatedE export function isNodeEditedNotification(notif: unknown): notif is NodeEditedEventData { return (notif as NodeEditedEventData).headers?.updateType === NotificationType.NODE_EDITED; } + export function isNodSubTreeCreatedNotification(notif: unknown): notif is SubtreeCreatedEventData { return (notif as SubtreeCreatedEventData).headers?.updateType === NotificationType.SUBTREE_CREATED; } +export function isExportNetworkNotification(notif: unknown): notif is ExportNetworkEventData { + return (notif as ExportNetworkEventData).headers?.updateType === NotificationType.NETWORK_EXPORT_SUCCEEDED; +} + export function isContainingNodesInformationNotification(notif: unknown): notif is | EventCrudFinishedEventData // contains 'nodes' header | EventDeletingInProgressEventData @@ -1139,13 +1163,15 @@ export type StudyUpdateEventData = | VoltageInitStatusEventData | StateEstimationResultEventData | StateEstimationFailedEventData - | StateEstimationStatusEventData; + | StateEstimationStatusEventData + | ExportNetworkEventData; export type StudyUpdateNotification = { eventData: StudyUpdateEventData; }; /******************* TO REMOVE LATER ****************/ + // Headers /** * @deprecated The type should not be used @@ -1163,6 +1189,7 @@ export interface StudyUpdatedEventDataHeader { userId?: string; computationType?: ComputingType; } + // EventData /** * @deprecated The type should not be used @@ -1173,4 +1200,5 @@ export interface StudyUpdatedEventData { /** @see NetworkImpactsInfos */ payload: string; } + /******************* TO REMOVE LATER ****************/