diff --git a/src/components/graph/network-modification-tree-model.ts b/src/components/graph/network-modification-tree-model.ts index 77aaa37463..ecff835c12 100644 --- a/src/components/graph/network-modification-tree-model.ts +++ b/src/components/graph/network-modification-tree-model.ts @@ -61,6 +61,15 @@ export default class NetworkModificationTreeModel { * @returns true if the order was changed */ reorderChildrenNodes(parentNodeId: string, orderedNodeIds: string[]) { + // Guard against incoherent notification before reordering + const currentChildren = new Set(this.getChildren(parentNodeId).map((c) => c.id)); + if ( + orderedNodeIds.length !== currentChildren.size || + !orderedNodeIds.every((id) => currentChildren.has(id as UUID)) + ) { + console.warn('reorderChildrenNodes: orderedNodeIds does not match the current children set, skipping'); + return false; + } if (!this.needReorder(parentNodeId, orderedNodeIds)) { return false; } diff --git a/src/components/network-modification-tree-pane-event-handlers.ts b/src/components/network-modification-tree-pane-event-handlers.ts index 020add9a23..8d0fb4b205 100644 --- a/src/components/network-modification-tree-pane-event-handlers.ts +++ b/src/components/network-modification-tree-pane-event-handlers.ts @@ -7,11 +7,20 @@ import type { UUID } from 'node:crypto'; import type { NodeSelectionForCopy } from 'redux/reducer.type'; -import type { NodeCreatedEventData, NodeMovedEventData } from 'types/notification-types'; +import type { + NodeCreatedEventData, + NodeMovedEventData, + NodesColumnPositionsChangedEventData, + StudyUpdateEventData, +} from 'types/notification-types'; +import { NotificationType } from 'types/notification-types'; import { networkModificationHandleSubtree, networkModificationTreeNodeAdded, networkModificationTreeNodeMoved, + networkModificationTreeNodesRemoved, + networkModificationTreeNodesUpdated, + reorderNetworkModificationTreeNodes, } from '../redux/actions'; import type { AppDispatch } from '../redux/store'; import { @@ -97,3 +106,63 @@ export const fetchAndHandleSubtree = ( dispatch(networkModificationHandleSubtree(nodes, parentNode)); }); }; + +const fetchAndDispatchUpdatedNodes = ( + dispatch: AppDispatch, + studyUuid: UUID, + rootNetworkUuid: UUID, + nodeIds: UUID[] +): void => { + Promise.allSettled( + nodeIds.map((nodeId) => fetchNetworkModificationTreeNode(studyUuid, nodeId, rootNetworkUuid)) + ).then((results) => { + const values = results.flatMap((result) => (result.status === 'fulfilled' ? [result.value] : [])); + if (values.length > 0) { + dispatch(networkModificationTreeNodesUpdated(values)); + } + }); +}; + +export const handleTreeModelUpdate = ( + dispatch: AppDispatch, + studyUuid: UUID, + rootNetworkUuid: UUID, + eventData: StudyUpdateEventData +): void => { + switch (eventData.headers.updateType) { + case NotificationType.NODE_BUILD_STATUS_UPDATED: + if (eventData.headers.rootNetworkUuid !== rootNetworkUuid) break; + fetchAndDispatchUpdatedNodes(dispatch, studyUuid, rootNetworkUuid, eventData.headers.nodes); + break; + case NotificationType.NODE_CREATED: + fetchAndDispatchAddedNode(dispatch, studyUuid, rootNetworkUuid, eventData as NodeCreatedEventData); + break; + case NotificationType.SUBTREE_CREATED: + fetchAndHandleSubtree(dispatch, studyUuid, eventData.headers.newNode, eventData.headers.parentNode); + break; + case NotificationType.NODE_MOVED: + fetchAndDispatchMovedNode(dispatch, studyUuid, rootNetworkUuid, eventData as NodeMovedEventData); + break; + case NotificationType.SUBTREE_MOVED: + fetchAndHandleSubtree(dispatch, studyUuid, eventData.headers.movedNode, eventData.headers.parentNode); + break; + case NotificationType.NODES_COLUMN_POSITION_CHANGED: { + const { headers, payload } = eventData as NodesColumnPositionsChangedEventData; + dispatch(reorderNetworkModificationTreeNodes(headers.parentNode, JSON.parse(payload))); + break; + } + case NotificationType.NODES_DELETED: + dispatch(networkModificationTreeNodesRemoved(eventData.headers.nodes)); + break; + case NotificationType.NODES_UPDATED: + fetchAndDispatchUpdatedNodes(dispatch, studyUuid, rootNetworkUuid, eventData.headers.nodes); + break; + case NotificationType.NODE_EDITED: + fetchNetworkModificationTreeNode(studyUuid, eventData.headers.node, rootNetworkUuid).then((node) => + dispatch(networkModificationTreeNodesUpdated([node])) + ); + break; + default: + break; + } +}; diff --git a/src/components/network-modification-tree-pane.jsx b/src/components/network-modification-tree-pane.jsx index d1c8f931e3..3dfc139017 100644 --- a/src/components/network-modification-tree-pane.jsx +++ b/src/components/network-modification-tree-pane.jsx @@ -6,21 +6,8 @@ */ import { useCallback, useEffect, useRef, useState } from 'react'; -import { - networkModificationTreeNodesRemoved, - networkModificationTreeNodesUpdated, - removeNotificationByNode, - reorderNetworkModificationTreeNodes, - resetLogsFilter, - resetLogsPagination, -} from '../redux/actions'; -import { - fetchAndDispatchAddedNode, - fetchAndDispatchMovedNode, - fetchAndHandleSubtree, - invalidateClipboardIfImpacted, - refreshStashedNodes, -} from './network-modification-tree-pane-event-handlers'; +import { removeNotificationByNode, resetLogsFilter, resetLogsPagination } from '../redux/actions'; +import { invalidateClipboardIfImpacted, refreshStashedNodes } from './network-modification-tree-pane-event-handlers'; import { useDispatch, useSelector } from 'react-redux'; import PropTypes from 'prop-types'; import NetworkModificationTree from './network-modification-tree'; @@ -40,7 +27,6 @@ import { createTreeNode, cutSubtree, cutTreeNode, - fetchNetworkModificationTreeNode, fetchStashedNodes, stashSubtree, stashTreeNode, @@ -61,7 +47,7 @@ import { fetchNetworkModificationsToExport } from 'services/study/network-modifi export const NetworkModificationTreePane = ({ studyUuid, currentRootNetworkUuid }) => { const dispatch = useDispatch(); - const { snackError, snackWarning } = useSnackMessage(); + const { snackError } = useSnackMessage(); const [nodesToRestore, setNodesToRestore] = useState([]); const { selectionForCopy, copyNode, cutNode, cleanClipboard } = useCopiedNodes(); @@ -95,44 +81,10 @@ export const NetworkModificationTreePane = ({ studyUuid, currentRootNetworkUuid const { subscribeExport } = useExportSubscription(); - const updateNodes = useCallback( - (updatedNodesIds) => { - Promise.all( - updatedNodesIds.map((nodeId) => - fetchNetworkModificationTreeNode(studyUuid, nodeId, currentRootNetworkUuid) - ) - ).then((values) => { - dispatch(networkModificationTreeNodesUpdated(values)); - }); - }, - [studyUuid, currentRootNetworkUuid, dispatch] - ); - const resetNodeClipboard = useCallback(() => { cleanClipboard(); }, [cleanClipboard]); - const reorderSubtree = useCallback( - (parentNodeId, orderedChildrenNodeIds) => { - // We check that the received node order from the notification is coherent with what we have locally. - const children = new Set(treeModelRef.current.getChildren(parentNodeId).map((c) => c.id)); - let isListsEqual = - orderedChildrenNodeIds.length === children.size && - orderedChildrenNodeIds.every((id) => children.has(id)); - if (!isListsEqual) { - snackWarning({ - messageId: 'ReorderSubtreeInvalidNotifInfo', - }); - console.warn('Subtree order update cancelled : the ordered children list is incompatible'); - return; - } - - // dispatch reorder - dispatch(reorderNetworkModificationTreeNodes(parentNodeId, orderedChildrenNodeIds)); - }, - [dispatch, snackWarning] - ); - const handleEvent = useCallback( (event) => { const eventData = parseEventData(event); @@ -140,7 +92,7 @@ export const NetworkModificationTreePane = ({ studyUuid, currentRootNetworkUuid switch (eventData.headers.updateType) { case NotificationType.NODE_CREATED: { - fetchAndDispatchAddedNode(dispatch, studyUuid, currentRootNetworkUuid, eventData); + // Tree model update handled globally in study-container.jsx invalidateClipboardIfImpacted( [eventData.headers.parentNode], nodeSelectionForCopyRef.current, @@ -151,37 +103,23 @@ export const NetworkModificationTreePane = ({ studyUuid, currentRootNetworkUuid } case NotificationType.SUBTREE_CREATED: { + // Tree model update handled globally in study-container.jsx invalidateClipboardIfImpacted( [eventData.headers.parentNode], nodeSelectionForCopyRef.current, resetNodeClipboard ); - fetchAndHandleSubtree(dispatch, studyUuid, eventData.headers.newNode, eventData.headers.parentNode); break; } case NotificationType.NODES_COLUMN_POSITION_CHANGED: { - reorderSubtree(eventData.headers.parentNode, JSON.parse(eventData.payload)); - break; - } - - case NotificationType.NODE_MOVED: { - fetchAndDispatchMovedNode(dispatch, studyUuid, currentRootNetworkUuid, eventData); - invalidateClipboardIfImpacted( - [eventData.headers.movedNode, eventData.headers.parentNode], - nodeSelectionForCopyRef.current, - resetNodeClipboard - ); + // Tree model update handled globally in study-container.jsx break; } + case NotificationType.NODE_MOVED: case NotificationType.SUBTREE_MOVED: { - fetchAndHandleSubtree( - dispatch, - studyUuid, - eventData.headers.movedNode, - eventData.headers.parentNode - ); + // Tree model update handled globally in study-container.jsx invalidateClipboardIfImpacted( [eventData.headers.movedNode, eventData.headers.parentNode], nodeSelectionForCopyRef.current, @@ -191,20 +129,19 @@ export const NetworkModificationTreePane = ({ studyUuid, currentRootNetworkUuid } case NotificationType.NODES_DELETED: { + // Tree model update handled globally in study-container.jsx invalidateClipboardIfImpacted( eventData.headers.nodes, nodeSelectionForCopyRef.current, resetNodeClipboard ); - dispatch(networkModificationTreeNodesRemoved(eventData.headers.nodes)); refreshStashedNodes(studyUuid, setNodesToRestore); break; } case NotificationType.NODES_UPDATED: { - updateNodes(eventData.headers.nodes); - - if (eventData.headers.nodes.some((nodeId) => nodeId === currentNodeRef.current?.id)) { + // Tree model update handled globally in study-container.jsx + if (eventData.headers.nodes.includes(currentNodeRef.current?.id)) { dispatch(removeNotificationByNode([currentNodeRef.current?.id])); } invalidateClipboardIfImpacted( @@ -215,18 +152,13 @@ export const NetworkModificationTreePane = ({ studyUuid, currentRootNetworkUuid break; } - case NotificationType.NODE_EDITED: { - updateNodes([eventData.headers.node]); - break; - } - case NotificationType.NODE_BUILD_STATUS_UPDATED: { if (eventData.headers.rootNetworkUuid !== currentRootNetworkUuidRef.current) break; // Note: The actual node updates are now handled globally in study-container.jsx // to ensure all workspaces open in other browser tabs (including those without tree panel) stay synchronized. // Here we only handle tree-specific cleanup operations. - if (eventData.headers.nodes.some((nodeId) => nodeId === currentNodeRef.current?.id)) { + if (eventData.headers.nodes.includes(currentNodeRef.current?.id)) { dispatch(removeNotificationByNode([currentNodeRef.current?.id])); // when the current node is updated, we need to reset the logs filter dispatch(resetLogsFilter()); @@ -247,7 +179,7 @@ export const NetworkModificationTreePane = ({ studyUuid, currentRootNetworkUuid } } }, - [studyUuid, updateNodes, reorderSubtree, dispatch, currentRootNetworkUuid, resetNodeClipboard] + [studyUuid, dispatch, resetNodeClipboard] ); useNotificationsListener(NotificationsUrlKeys.STUDY, { diff --git a/src/components/study-container.jsx b/src/components/study-container.jsx index d56a354bdf..0e582dea41 100644 --- a/src/components/study-container.jsx +++ b/src/components/study-container.jsx @@ -13,7 +13,6 @@ import { PARAMS_LOADED } from '../utils/config-params'; import { closeStudy, loadNetworkModificationTreeSuccess, - networkModificationTreeNodesUpdated, openStudy, resetEquipmentsPostComputation, setCurrentRootNetworkUuid, @@ -41,7 +40,8 @@ import NetworkModificationTreeModel from './graph/network-modification-tree-mode import { getFirstNodeOfType } from './graph/util/model-functions'; import { BUILD_STATUS } from './network/constants'; import { useAllComputingStatus } from './computing-status/use-all-computing-status'; -import { fetchNetworkModificationTree, fetchNetworkModificationTreeNode } from '../services/study/tree-subtree'; +import { fetchNetworkModificationTree } from '../services/study/tree-subtree'; +import { handleTreeModelUpdate } from './network-modification-tree-pane-event-handlers'; import { fetchNetworkExistence, fetchRootNetworkIndexationStatus } from '../services/study/network'; import { fetchStudy, recreateStudyNetwork, reindexAllRootNetwork } from 'services/study/study'; @@ -265,29 +265,18 @@ export function StudyContainer() { const handleStudyUpdate = useCallback( (event) => { const eventData = JSON.parse(event.data); - const updateTypeHeader = eventData.headers.updateType; - if (updateTypeHeader === NotificationType.STUDY_ALERT) { + if (eventData.headers.updateType === NotificationType.STUDY_ALERT) { sendAlert(eventData); return; // here, we do not want to update the redux state } displayErrorNotifications(eventData); - - // Handle build status updates globally so all workspaces open in other browser tabs update currentTreeNode - // This fixes the issue where tabs without tree panel don't get updates - if ( - updateTypeHeader === NotificationType.NODE_BUILD_STATUS_UPDATED && - eventData.headers.rootNetworkUuid === currentRootNetworkUuidRef.current - ) { - // Fetch updated nodes and dispatch to Redux to sync currentTreeNode - const updatedNodeIds = eventData.headers.nodes; - Promise.all( - updatedNodeIds.map((nodeId) => - fetchNetworkModificationTreeNode(studyUuid, nodeId, currentRootNetworkUuidRef.current) - ) - ).then((values) => { - dispatch(networkModificationTreeNodesUpdated(values)); - }); + // Handle tree model updates globally so all workspaces (including those without a tree panel) + // stay synchronized. This ensures navigation sync works across browser tabs regardless of + // which panels are open in each tab's active workspace. + if (!currentRootNetworkUuidRef.current) { + return; // root networks not yet loaded, skip tree sync } + handleTreeModelUpdate(dispatch, studyUuid, currentRootNetworkUuidRef.current, eventData); }, // Note: dispatch doesn't change [dispatch, displayErrorNotifications, sendAlert, studyUuid] diff --git a/src/hooks/use-study-navigation-sync.ts b/src/hooks/use-study-navigation-sync.ts index 1d79cf1eaf..5e4666c32d 100644 --- a/src/hooks/use-study-navigation-sync.ts +++ b/src/hooks/use-study-navigation-sync.ts @@ -20,6 +20,7 @@ import { useStudyScopedNavigationKeys } from './use-study-scoped-navigation-keys const useStudyNavigationSync = () => { const syncEnabled = useSelector((state: AppState) => state.syncEnabled); const currentRootNetworkUuid = useSelector((state: AppState) => state.currentRootNetworkUuid); + const rootNetworks = useSelector((state: AppState) => state.rootNetworks); const currentTreeNode = useSelector((state: AppState) => state.currentTreeNode); const treeModel = useSelector((state: AppState) => state.networkModificationTreeModel); const dispatch = useDispatch(); @@ -29,10 +30,13 @@ const useStudyNavigationSync = () => { const updateRootNetworkUuid = useCallback( (uuid: UUID | null) => { if (uuid !== null && uuid !== currentRootNetworkUuid) { - dispatch(setCurrentRootNetworkUuid(uuid)); + const rootNetwork = rootNetworks.find((rn) => rn.rootNetworkUuid === uuid); + if (rootNetwork) { + dispatch(setCurrentRootNetworkUuid(uuid)); + } } }, - [dispatch, currentRootNetworkUuid] + [dispatch, currentRootNetworkUuid, rootNetworks] ); const updateTreeNode = useCallback(