From 92b4092d563b8bbf42fe01d0320eac2cb303e7dd Mon Sep 17 00:00:00 2001 From: Rehili Ghazwa Date: Wed, 17 Sep 2025 18:26:50 +0200 Subject: [PATCH 1/3] asynch export draft version --- .../dialogs/export-network-dialog.tsx | 37 ++-- .../network-modification-tree-pane.jsx | 28 ++- src/hooks/use-export-subscription.ts | 174 ++++++++++++++++++ src/services/study/network.ts | 9 + src/translations/en.json | 1 + src/translations/fr.json | 1 + src/types/notification-types.ts | 7 + 7 files changed, 223 insertions(+), 34 deletions(-) create mode 100644 src/hooks/use-export-subscription.ts 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..2dc693ce05 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, handleExportNotification } = useExportSubscription({ + studyUuid: studyUuid, + nodeUuid: currentNode?.id, + rootNetworkUuid: currentRootNetworkUuid, + }); + const updateNodes = useCallback( (updatedNodesIds) => { Promise.all( @@ -311,6 +318,9 @@ export const NetworkModificationTreePane = ({ studyUuid, currentRootNetworkUuid, ) { resetNodeClipboard(); } + } else if (studyUpdatedForce.eventData.headers.updateType === NotificationType.NETWORK_EXPORT_SUCCEEDED) { + console.log('Export notification studyUpdatedForce', studyUpdatedForce); + handleExportNotification(studyUpdatedForce.eventData.headers); } } }, [ @@ -324,6 +334,7 @@ export const NetworkModificationTreePane = ({ studyUuid, currentRootNetworkUuid, currentRootNetworkUuid, isSubtreeImpacted, resetNodeClipboard, + handleExportNotification, ]); const handleCreateNode = useCallback( @@ -461,7 +472,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/hooks/use-export-subscription.ts b/src/hooks/use-export-subscription.ts new file mode 100644 index 0000000000..89ba28dce5 --- /dev/null +++ b/src/hooks/use-export-subscription.ts @@ -0,0 +1,174 @@ +/** + * 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 { useSelector } from 'react-redux'; +import { AppState } from 'redux/reducer'; +import { useSnackMessage } from '@gridsuite/commons-ui'; +import { useCallback } from 'react'; +import { downloadZipFile } from '../services/utils'; +import { HttpStatusCode } from '../utils/http-status-code'; +import { fetchExportNetworkFile } from '../services/study/network'; + +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 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 useExportSubscription({ + studyUuid, + nodeUuid, + rootNetworkUuid, +}: { + studyUuid: UUID; + nodeUuid: UUID; + rootNetworkUuid: UUID; +}) { + const intl = useIntl(); + const { snackWarning, snackInfo, snackError } = useSnackMessage(); + const userId = useSelector((state: AppState) => state.user?.profile.sub); + + const downloadExportNetworkFile = useCallback( + (exportUuid: UUID) => { + console.log('exportUuid', exportUuid); + fetchExportNetworkFile(studyUuid, nodeUuid, rootNetworkUuid, exportUuid) + .then(async (response: Response) => { + const contentDisposition = response.headers.get('Content-Disposition'); + let filename = 'export.zip'; + + if (contentDisposition) { + const filenameMatch = contentDisposition.match(/filename[^;=\n]*=((['"]).*?\2|[^;\n]*)/); + if (filenameMatch && filenameMatch[1]) { + filename = filenameMatch[1].replace(/['"]/g, ''); + } + } + + const blob = await response.blob(); + downloadZipFile(blob, filename); + }) + .catch((responseError: any) => { + const error = responseError as Error & { status: number }; + if (error.status === HttpStatusCode.NOT_FOUND) { + // not found + snackWarning({ + headerId: 'debug.header.fileNotFound', + }); + } else { + // or whatever error + snackError({ + messageTxt: error.message, + headerId: 'debug.header.fileError', + }); + } + }); + }, + [studyUuid, nodeUuid, rootNetworkUuid, snackWarning, snackError] + ); + + const handleExportNotification = useCallback( + (eventData: any) => { + console.log('Export notification', eventData); + const { studyUuid, node, rootNetworkUuid, format, userId: useId, exportUuid, fileName, error } = eventData; + + const exportIdentifierNotif = buildExportIdentifier({ + studyUuid: studyUuid, + nodeUuid: node, + rootNetworkUuid: rootNetworkUuid, + format, + fileName, + }); + console.log('exportIdentifierNotif', exportIdentifierNotif); + const isSubscribed = isExportSubscribed(exportIdentifierNotif); + console.log('isSubscribed', isSubscribed); + console.log('useId', useId); + 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] + ); + + const subscribeExport = useCallback( + (format: string, fileName: string) => { + buildExportIdentifier({ + studyUuid, + nodeUuid, + rootNetworkUuid, + format, + fileName, + }); + + setExportSubscription( + buildExportIdentifier({ + studyUuid, + nodeUuid, + rootNetworkUuid, + format, + fileName, + }) + ); + snackInfo({ + headerTxt: intl.formatMessage({ id: 'exportNetwork' }), + messageTxt: intl.formatMessage({ id: 'export.message.subscribed' }, { fileName }), + }); + }, + [studyUuid, nodeUuid, rootNetworkUuid, snackInfo, intl] + ); + + return { subscribeExport, handleExportNotification }; +} diff --git a/src/services/study/network.ts b/src/services/study/network.ts index acbddd7258..2949e97a63 100644 --- a/src/services/study/network.ts +++ b/src/services/study/network.ts @@ -398,6 +398,15 @@ export function getExportUrl(studyUuid: UUID, nodeUuid: UUID, rootNetworkUuid: U return getUrlWithToken(url); } +export function fetchExportNetworkFile(studyUuid: UUID, nodeUuid: UUID, rootNetworkUuid: UUID, exportUuid: UUID) { + const url = + getStudyUrlWithNodeUuidAndRootNetworkUuid(studyUuid, nodeUuid, rootNetworkUuid) + + '/download-network-file/' + + exportUuid; + const urlWithToken = getUrlWithToken(url); + return fetch(urlWithToken); +} + export function fetchSpreadsheetEquipmentTypeSchema(type: SpreadsheetEquipmentType): Promise { const fetchEquipmentTypeSchemaUrl = `${PREFIX_SCHEMAS_QUERIES}/v1/schemas/${type}/${EQUIPMENT_INFOS_TYPES.TAB.type}`; return backendFetchJson(fetchEquipmentTypeSchemaUrl, { diff --git a/src/translations/en.json b/src/translations/en.json index 353284c33a..a81ca1ef6d 100644 --- a/src/translations/en.json +++ b/src/translations/en.json @@ -2,6 +2,7 @@ "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.", "button.delete": "Delete", "button.continue": "Continue", diff --git a/src/translations/fr.json b/src/translations/fr.json index 86a8c03669..522caf7bc0 100644 --- a/src/translations/fr.json +++ b/src/translations/fr.json @@ -2,6 +2,7 @@ "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.", "button.delete": "Supprimer", "button.continue": "Continuer", diff --git a/src/types/notification-types.ts b/src/types/notification-types.ts index a46a9e664b..345856beeb 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; @@ -950,6 +952,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,6 +960,7 @@ 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; } @@ -1146,6 +1150,7 @@ export type StudyUpdateNotification = { }; /******************* TO REMOVE LATER ****************/ + // Headers /** * @deprecated The type should not be used @@ -1163,6 +1168,7 @@ export interface StudyUpdatedEventDataHeader { userId?: string; computationType?: ComputingType; } + // EventData /** * @deprecated The type should not be used @@ -1173,4 +1179,5 @@ export interface StudyUpdatedEventData { /** @see NetworkImpactsInfos */ payload: string; } + /******************* TO REMOVE LATER ****************/ From ae6d188254f0d85380a7d499b2465b16baf21ba1 Mon Sep 17 00:00:00 2001 From: Rehili Ghazwa Date: Thu, 18 Sep 2025 18:55:19 +0200 Subject: [PATCH 2/3] clean version --- .../network-modification-tree-pane.jsx | 10 ++++-- src/hooks/use-export-subscription.ts | 32 ++++++++++--------- src/services/study/index.ts | 7 +++- src/services/study/network.ts | 9 ------ src/translations/en.json | 1 + src/translations/fr.json | 1 + src/types/notification-types.ts | 21 +++++++++++- 7 files changed, 52 insertions(+), 29 deletions(-) diff --git a/src/components/network-modification-tree-pane.jsx b/src/components/network-modification-tree-pane.jsx index 2dc693ce05..9508e14a13 100644 --- a/src/components/network-modification-tree-pane.jsx +++ b/src/components/network-modification-tree-pane.jsx @@ -42,7 +42,12 @@ import { 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 { + isExportNetworkNotification, + NodeSequenceType, + NotificationType, + PENDING_MODIFICATION_NOTIFICATION_TYPES, +} from 'types/notification-types'; import useExportSubscription from '../hooks/use-export-subscription.js'; const noNodeSelectionForCopy = { @@ -318,8 +323,7 @@ export const NetworkModificationTreePane = ({ studyUuid, currentRootNetworkUuid, ) { resetNodeClipboard(); } - } else if (studyUpdatedForce.eventData.headers.updateType === NotificationType.NETWORK_EXPORT_SUCCEEDED) { - console.log('Export notification studyUpdatedForce', studyUpdatedForce); + } else if (isExportNetworkNotification(studyUpdatedForce.eventData)) { handleExportNotification(studyUpdatedForce.eventData.headers); } } diff --git a/src/hooks/use-export-subscription.ts b/src/hooks/use-export-subscription.ts index 89ba28dce5..36210990c5 100644 --- a/src/hooks/use-export-subscription.ts +++ b/src/hooks/use-export-subscription.ts @@ -10,9 +10,9 @@ import { useSelector } from 'react-redux'; import { AppState } from 'redux/reducer'; import { useSnackMessage } from '@gridsuite/commons-ui'; import { useCallback } from 'react'; -import { downloadZipFile } from '../services/utils'; import { HttpStatusCode } from '../utils/http-status-code'; -import { fetchExportNetworkFile } from '../services/study/network'; +import { fetchExportNetworkFile } from '../services/study'; +import { downloadZipFile } from '../services/utils'; export function buildExportIdentifier({ studyUuid, @@ -73,16 +73,15 @@ export default function useExportSubscription({ const downloadExportNetworkFile = useCallback( (exportUuid: UUID) => { - console.log('exportUuid', exportUuid); - fetchExportNetworkFile(studyUuid, nodeUuid, rootNetworkUuid, exportUuid) - .then(async (response: Response) => { + fetchExportNetworkFile(exportUuid) + .then(async (response) => { const contentDisposition = response.headers.get('Content-Disposition'); let filename = 'export.zip'; - - if (contentDisposition) { - const filenameMatch = contentDisposition.match(/filename[^;=\n]*=((['"]).*?\2|[^;\n]*)/); - if (filenameMatch && filenameMatch[1]) { - filename = filenameMatch[1].replace(/['"]/g, ''); + if (contentDisposition?.includes('filename=')) { + const regex = /filename="?([^"]+)"?/; + const match = regex.exec(contentDisposition); + if (match?.[1]) { + filename = match[1]; } } @@ -105,12 +104,11 @@ export default function useExportSubscription({ } }); }, - [studyUuid, nodeUuid, rootNetworkUuid, snackWarning, snackError] + [snackWarning, snackError] ); const handleExportNotification = useCallback( (eventData: any) => { - console.log('Export notification', eventData); const { studyUuid, node, rootNetworkUuid, format, userId: useId, exportUuid, fileName, error } = eventData; const exportIdentifierNotif = buildExportIdentifier({ @@ -120,10 +118,7 @@ export default function useExportSubscription({ format, fileName, }); - console.log('exportIdentifierNotif', exportIdentifierNotif); const isSubscribed = isExportSubscribed(exportIdentifierNotif); - console.log('isSubscribed', isSubscribed); - console.log('useId', useId); if (isSubscribed && useId === userId) { unsetExportSubscription(exportIdentifierNotif); @@ -162,6 +157,13 @@ export default function useExportSubscription({ fileName, }) ); + const exportKey = `${studyUuid}-${nodeUuid}-${rootNetworkUuid}`; + const exportInfo = { + format, + fileName, + timestamp: Date.now(), + }; + sessionStorage.setItem(`export-${exportKey}`, JSON.stringify(exportInfo)); snackInfo({ headerTxt: intl.formatMessage({ id: 'exportNetwork' }), messageTxt: intl.formatMessage({ id: 'export.message.subscribed' }, { fileName }), 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/services/study/network.ts b/src/services/study/network.ts index 2949e97a63..acbddd7258 100644 --- a/src/services/study/network.ts +++ b/src/services/study/network.ts @@ -398,15 +398,6 @@ export function getExportUrl(studyUuid: UUID, nodeUuid: UUID, rootNetworkUuid: U return getUrlWithToken(url); } -export function fetchExportNetworkFile(studyUuid: UUID, nodeUuid: UUID, rootNetworkUuid: UUID, exportUuid: UUID) { - const url = - getStudyUrlWithNodeUuidAndRootNetworkUuid(studyUuid, nodeUuid, rootNetworkUuid) + - '/download-network-file/' + - exportUuid; - const urlWithToken = getUrlWithToken(url); - return fetch(urlWithToken); -} - export function fetchSpreadsheetEquipmentTypeSchema(type: SpreadsheetEquipmentType): Promise { const fetchEquipmentTypeSchemaUrl = `${PREFIX_SCHEMAS_QUERIES}/v1/schemas/${type}/${EQUIPMENT_INFOS_TYPES.TAB.type}`; return backendFetchJson(fetchEquipmentTypeSchemaUrl, { diff --git a/src/translations/en.json b/src/translations/en.json index a81ca1ef6d..56d653efc0 100644 --- a/src/translations/en.json +++ b/src/translations/en.json @@ -3,6 +3,7 @@ "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...", "button.delete": "Delete", "button.continue": "Continue", diff --git a/src/translations/fr.json b/src/translations/fr.json index 522caf7bc0..18490daa39 100644 --- a/src/translations/fr.json +++ b/src/translations/fr.json @@ -3,6 +3,7 @@ "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", "button.delete": "Supprimer", "button.continue": "Continuer", diff --git a/src/types/notification-types.ts b/src/types/notification-types.ts index 345856beeb..b952a1db58 100644 --- a/src/types/notification-types.ts +++ b/src/types/notification-types.ts @@ -515,6 +515,15 @@ interface StateEstimationStatusEventDataHeaders extends ComputationStatusEventDa updateType: NotificationType.STATE_ESTIMATION_STATUS; } +interface ExportNetworkEventDataHeaders extends CommonStudyEventDataHeaders { + updateType: NotificationType.NETWORK_EXPORT_SUCCEEDED; + rootNetworkUuid: UUID; + node: UUID; + fileName: string; + exportUuid: UUID; + format: string; +} + // Payloads export interface DeletedEquipment { equipmentId: string; @@ -855,6 +864,11 @@ export interface StateEstimationStatusEventData { payload: undefined; } +export interface ExportNetworkEventData { + headers: ExportNetworkEventDataHeaders; + payload: undefined; +} + export interface SpreadsheetParametersUpdatedEventData extends Omit { headers: SpreadsheetParametersUpdatedDataHeaders; /** @@ -965,6 +979,10 @@ export function isNodSubTreeCreatedNotification(notif: unknown): notif is Subtre 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 @@ -1143,7 +1161,8 @@ export type StudyUpdateEventData = | VoltageInitStatusEventData | StateEstimationResultEventData | StateEstimationFailedEventData - | StateEstimationStatusEventData; + | StateEstimationStatusEventData + | ExportNetworkEventData; export type StudyUpdateNotification = { eventData: StudyUpdateEventData; From 610caf8ea8bcccd3b525bac7e70b9d4f0d3e630a Mon Sep 17 00:00:00 2001 From: Rehili Ghazwa Date: Fri, 19 Sep 2025 09:58:30 +0200 Subject: [PATCH 3/3] refacto and clean code --- .../network-modification-tree-pane.jsx | 12 +- src/components/study-container.jsx | 10 +- src/hooks/use-export-download.ts | 51 ++++++++ src/hooks/use-export-notification-handler.ts | 103 ++++++++++++++++ src/hooks/use-export-subscription.ts | 110 +----------------- src/translations/en.json | 2 + src/translations/fr.json | 2 + src/types/notification-types.ts | 2 + 8 files changed, 175 insertions(+), 117 deletions(-) create mode 100644 src/hooks/use-export-download.ts create mode 100644 src/hooks/use-export-notification-handler.ts diff --git a/src/components/network-modification-tree-pane.jsx b/src/components/network-modification-tree-pane.jsx index 9508e14a13..007b146c1a 100644 --- a/src/components/network-modification-tree-pane.jsx +++ b/src/components/network-modification-tree-pane.jsx @@ -42,12 +42,7 @@ import { import { buildNode, getUniqueNodeName, unbuildNode } from '../services/study/index'; import { RestoreNodesDialog } from './dialogs/restore-node-dialog'; import { CopyType } from './network-modification.type'; -import { - isExportNetworkNotification, - NodeSequenceType, - NotificationType, - PENDING_MODIFICATION_NOTIFICATION_TYPES, -} from 'types/notification-types'; +import { NodeSequenceType, NotificationType, PENDING_MODIFICATION_NOTIFICATION_TYPES } from 'types/notification-types'; import useExportSubscription from '../hooks/use-export-subscription.js'; const noNodeSelectionForCopy = { @@ -129,7 +124,7 @@ export const NetworkModificationTreePane = ({ studyUuid, currentRootNetworkUuid, const studyUpdatedForce = useSelector((state) => state.studyUpdated); - const { subscribeExport, handleExportNotification } = useExportSubscription({ + const { subscribeExport } = useExportSubscription({ studyUuid: studyUuid, nodeUuid: currentNode?.id, rootNetworkUuid: currentRootNetworkUuid, @@ -323,8 +318,6 @@ export const NetworkModificationTreePane = ({ studyUuid, currentRootNetworkUuid, ) { resetNodeClipboard(); } - } else if (isExportNetworkNotification(studyUpdatedForce.eventData)) { - handleExportNotification(studyUpdatedForce.eventData.headers); } } }, [ @@ -338,7 +331,6 @@ export const NetworkModificationTreePane = ({ studyUuid, currentRootNetworkUuid, currentRootNetworkUuid, isSubtreeImpacted, resetNodeClipboard, - handleExportNotification, ]); const handleCreateNode = useCallback( 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 index 36210990c5..a67e7fbfa9 100644 --- a/src/hooks/use-export-subscription.ts +++ b/src/hooks/use-export-subscription.ts @@ -6,13 +6,8 @@ */ import { UUID } from 'crypto'; import { useIntl } from 'react-intl'; -import { useSelector } from 'react-redux'; -import { AppState } from 'redux/reducer'; import { useSnackMessage } from '@gridsuite/commons-ui'; import { useCallback } from 'react'; -import { HttpStatusCode } from '../utils/http-status-code'; -import { fetchExportNetworkFile } from '../services/study'; -import { downloadZipFile } from '../services/utils'; export function buildExportIdentifier({ studyUuid, @@ -45,19 +40,6 @@ export function setExportSubscription(identifier: string): void { saveExportState(exportState); } -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 useExportSubscription({ studyUuid, nodeUuid, @@ -68,102 +50,18 @@ export default function useExportSubscription({ rootNetworkUuid: UUID; }) { const intl = useIntl(); - const { snackWarning, snackInfo, snackError } = useSnackMessage(); - const userId = useSelector((state: AppState) => state.user?.profile.sub); - - 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) { - // not found - snackWarning({ - headerId: 'debug.header.fileNotFound', - }); - } else { - // or whatever error - snackError({ - messageTxt: error.message, - headerId: 'debug.header.fileError', - }); - } - }); - }, - [snackWarning, snackError] - ); - - const handleExportNotification = useCallback( - (eventData: any) => { - const { studyUuid, node, rootNetworkUuid, format, userId: useId, exportUuid, fileName, error } = eventData; - - 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] - ); + const { snackInfo } = useSnackMessage(); const subscribeExport = useCallback( (format: string, fileName: string) => { - buildExportIdentifier({ + const identifier = buildExportIdentifier({ studyUuid, nodeUuid, rootNetworkUuid, format, fileName, }); - - setExportSubscription( - buildExportIdentifier({ - studyUuid, - nodeUuid, - rootNetworkUuid, - format, - fileName, - }) - ); - const exportKey = `${studyUuid}-${nodeUuid}-${rootNetworkUuid}`; - const exportInfo = { - format, - fileName, - timestamp: Date.now(), - }; - sessionStorage.setItem(`export-${exportKey}`, JSON.stringify(exportInfo)); + setExportSubscription(identifier); snackInfo({ headerTxt: intl.formatMessage({ id: 'exportNetwork' }), messageTxt: intl.formatMessage({ id: 'export.message.subscribed' }, { fileName }), @@ -172,5 +70,5 @@ export default function useExportSubscription({ [studyUuid, nodeUuid, rootNetworkUuid, snackInfo, intl] ); - return { subscribeExport, handleExportNotification }; + return { subscribeExport }; } diff --git a/src/translations/en.json b/src/translations/en.json index 56d653efc0..db5694d3c9 100644 --- a/src/translations/en.json +++ b/src/translations/en.json @@ -4,6 +4,8 @@ "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 18490daa39..592ca482ff 100644 --- a/src/translations/fr.json +++ b/src/translations/fr.json @@ -4,6 +4,8 @@ "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 b952a1db58..38a7fe3200 100644 --- a/src/types/notification-types.ts +++ b/src/types/notification-types.ts @@ -519,9 +519,11 @@ interface ExportNetworkEventDataHeaders extends CommonStudyEventDataHeaders { updateType: NotificationType.NETWORK_EXPORT_SUCCEEDED; rootNetworkUuid: UUID; node: UUID; + userId: string; fileName: string; exportUuid: UUID; format: string; + error: string; } // Payloads