diff --git a/src/components/app-top-bar.jsx b/src/components/app-top-bar.jsx
index 791e32247e..14f7b29e95 100644
--- a/src/components/app-top-bar.jsx
+++ b/src/components/app-top-bar.jsx
@@ -22,14 +22,10 @@ import { PARAM_USE_NAME } from '../utils/config-params';
import { useDispatch, useSelector } from 'react-redux';
import PropTypes from 'prop-types';
import AppPackage from '../../package.json';
-import { isNodeBuilt, isNodeReadOnly } from './graph/util/model-functions';
import { getServersInfos } from '../services/study';
import { fetchVersion } from '../services/utils';
-import { RunButtonContainer } from './run-button-container';
import { useParameterState } from './dialogs/parameters/use-parameters-state';
-import StudyNavigationSyncToggle from './study-navigation-sync-toggle';
import { WorkspaceToolbar } from './workspace/core/workspace-toolbar';
-import { WorkspaceSwitcher } from './workspace/core/workspace-switcher';
const styles = {
boxContent: (theme) => ({
@@ -39,23 +35,12 @@ const styles = {
width: '100%',
marginLeft: theme.spacing(1),
}),
- runButtonContainer: {
- display: 'flex',
- alignItems: 'center',
- marginRight: 1.5,
- },
- syncToggleContainer: {
- display: 'flex',
- alignItems: 'center',
- marginRight: 1.5,
- },
};
const AppTopBar = ({ userProfile, userManager }) => {
const dispatch = useDispatch();
const theme = useSelector((state) => state[PARAM_THEME]);
const studyUuid = useSelector((state) => state.studyUuid);
- const currentNode = useSelector((state) => state.currentTreeNode);
const currentRootNetworkUuid = useSelector((state) => state.currentRootNetworkUuid);
const [appsAndUrls, setAppsAndUrls] = useState([]);
@@ -97,23 +82,7 @@ const AppTopBar = ({ userProfile, userManager }) => {
>
{userProfile && studyUuid && currentRootNetworkUuid && (
-
-
-
-
-
-
-
-
-
-
-
-
+
)}
diff --git a/src/components/breadcrumbs/current-selection.tsx b/src/components/breadcrumbs/current-selection.tsx
new file mode 100644
index 0000000000..8ffe36bc2d
--- /dev/null
+++ b/src/components/breadcrumbs/current-selection.tsx
@@ -0,0 +1,96 @@
+/**
+ * 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 { Box, Paper, Tooltip } from '@mui/material';
+import { BuildStatusChip, type MuiStyles } from '@gridsuite/commons-ui';
+import type { UUID } from 'node:crypto';
+import { useIntl } from 'react-intl';
+import { useSelector } from 'react-redux';
+import { CurrentTreeNode, NodeType } from '../graph/tree-node.type';
+import { RootNetworkMetadata } from '../graph/menus/network-modifications/network-modification-menu.type';
+import { AppState } from '../../redux/reducer.type';
+import { BlockedByActivityIndicator, NodeActivityChip } from 'components/node-activity/node-activity-display';
+import { useNodeActivity } from 'components/node-activity/hooks/use-node-activity';
+import RootNetworkSelect from './root-network-select';
+
+const styles = {
+ container: {
+ display: 'flex',
+ alignItems: 'center',
+ borderRadius: '8px',
+ minHeight: 32,
+ px: 1,
+ },
+ label: (theme) => ({
+ display: { xs: 'none', lg: 'block' },
+ mr: 1,
+ fontSize: theme.typography.fontSize,
+ }),
+ rootNetworkSlot: {
+ display: 'flex',
+ alignItems: 'center',
+ mr: 1,
+ },
+ nodeLabel: (theme) => ({
+ maxWidth: 500,
+ overflow: 'hidden',
+ textOverflow: 'ellipsis',
+ whiteSpace: 'nowrap',
+ fontSize: theme.typography.fontSize,
+ }),
+ chipSlot: {
+ display: 'flex',
+ alignItems: 'center',
+ ml: 1,
+ },
+ blockedSpinner: {
+ ml: 1,
+ },
+} as const satisfies MuiStyles;
+
+export default function CurrentSelection() {
+ const intl = useIntl();
+ const currentNode: CurrentTreeNode | null = useSelector((state: AppState) => state.currentTreeNode);
+ const currentRootNetworkUuid: UUID | null = useSelector((state: AppState) => state.currentRootNetworkUuid);
+ const rootNetworks: RootNetworkMetadata[] = useSelector((state: AppState) => state.rootNetworks);
+ const isRootNode = currentNode?.type === NodeType.ROOT;
+ const nodeLabel = isRootNode ? intl.formatMessage({ id: 'root' }) : currentNode?.data.label;
+ const activity = useNodeActivity(currentNode?.id);
+
+ return (
+
+ {rootNetworks.length > 1 && (
+ <>
+ {intl.formatMessage({ id: 'root' })}
+
+
+
+ >
+ )}
+ {intl.formatMessage({ id: 'node' })}
+
+ {nodeLabel}
+
+
+ {activity && }
+ {!activity && currentNode && !isRootNode && (
+
+ )}
+
+
+
+ );
+}
diff --git a/src/components/breadcrumbs/root-network-select.tsx b/src/components/breadcrumbs/root-network-select.tsx
index d2eb6f77c0..e6b7b2244b 100644
--- a/src/components/breadcrumbs/root-network-select.tsx
+++ b/src/components/breadcrumbs/root-network-select.tsx
@@ -5,7 +5,7 @@
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
-import { Box, ListItemText, MenuItem, Select } from '@mui/material';
+import { Box, MenuItem, Select } from '@mui/material';
import type { UUID } from 'node:crypto';
import { RemoveRedEye, VisibilityOff } from '@mui/icons-material';
import { RootNetworkMetadata } from '../graph/menus/network-modifications/network-modification-menu.type';
@@ -15,12 +15,22 @@ import { mergeSx, type MuiStyles } from '@gridsuite/commons-ui';
const styles = {
selectRoot: (theme) => ({
height: theme.spacing(4),
- width: theme.spacing(15),
+ width: 'fit-content',
paddingTop: theme.spacing(1),
paddingBottom: theme.spacing(1),
+ '& .MuiOutlinedInput-notchedOutline': { border: 'none' },
+ '& .MuiSelect-select.MuiSelect-select': { paddingLeft: 0, paddingRight: theme.spacing(3.5) },
+ }),
+ selectInput: (theme) => ({
+ display: 'flex',
+ gap: 1,
+ alignItems: 'center',
+ fontSize: theme.typography.fontSize,
+ }),
+ selectItem: (theme) => ({
+ gap: 1,
+ fontSize: theme.typography.fontSize,
}),
- selectInput: { display: 'flex', gap: 1, alignItems: 'center' },
- selectItem: { gap: 1 },
hiddenItem: { display: 'none' },
} as const satisfies MuiStyles;
@@ -45,8 +55,8 @@ export default function RootNetworkSelect({ currentRootNetworkUuid, rootNetworks
const tag = rootNetworks.find((item) => item.rootNetworkUuid === value)?.tag;
return (
-
-
+ {tag}
+
);
}}
@@ -60,8 +70,8 @@ export default function RootNetworkSelect({ currentRootNetworkUuid, rootNetworks
item.rootNetworkUuid === currentRootNetworkUuid ? styles.hiddenItem : undefined
)}
>
-
-
+ {item.tag}
+
))}
diff --git a/src/components/breadcrumbs/study-path-breadcrumbs.tsx b/src/components/breadcrumbs/study-path-breadcrumbs.tsx
index a8bf026a34..ccacc2a66e 100644
--- a/src/components/breadcrumbs/study-path-breadcrumbs.tsx
+++ b/src/components/breadcrumbs/study-path-breadcrumbs.tsx
@@ -6,16 +6,10 @@
*/
import { MoreHoriz } from '@mui/icons-material';
-import { Box, Breadcrumbs as MuiBreadcrumbs, Tooltip } from '@mui/material';
+import { Box, Breadcrumbs as MuiBreadcrumbs, type Theme, Tooltip, useMediaQuery } from '@mui/material';
import { type MuiStyles } from '@gridsuite/commons-ui';
-import { CurrentTreeNode, NodeType } from '../graph/tree-node.type';
-import { useSelector } from 'react-redux';
-import { AppState } from '../../redux/reducer.type';
-import { RootNetworkMetadata } from '../graph/menus/network-modifications/network-modification-menu.type';
-import type { UUID } from 'node:crypto';
import KeyboardArrowRightIcon from '@mui/icons-material/KeyboardArrowRight';
-import RootNetworkSelect from './root-network-select';
-import { useIntl } from 'react-intl';
+import CurrentSelection from './current-selection';
const styles = {
tooltipItem: {
@@ -29,6 +23,12 @@ const styles = {
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
},
+ parentDirectoryItem: {
+ maxWidth: 200,
+ overflow: 'hidden',
+ textOverflow: 'ellipsis',
+ whiteSpace: 'nowrap',
+ },
} as const satisfies MuiStyles;
export interface StudyPathBreadcrumbsProps {
@@ -40,13 +40,8 @@ export default function StudyPathBreadcrumbs({
studyName,
parentDirectoriesNames,
}: Readonly) {
- const intl = useIntl();
- const currentNode: CurrentTreeNode | null = useSelector((state: AppState) => state.currentTreeNode);
- const currentRootNetworkUuid: UUID | null = useSelector((state: AppState) => state.currentRootNetworkUuid);
- const rootNetworks: RootNetworkMetadata[] = useSelector((state: AppState) => state.rootNetworks);
- const currentRootNetworkTag = rootNetworks.find((item) => item.rootNetworkUuid === currentRootNetworkUuid)?.tag;
- const isRootNode = currentNode?.type === NodeType.ROOT;
- const nodeLabel = isRootNode ? intl.formatMessage({ id: 'root' }) : currentNode?.data.label;
+ const nearestParentDirectoryName = parentDirectoriesNames?.at(-1);
+ const showParentDirectory = useMediaQuery((theme: Theme) => theme.breakpoints.up('md'));
return (
))}
-
- {studyName}
-
-
- {nodeLabel}
- {rootNetworks?.length > 1 && (
-
-
- {currentRootNetworkTag}
-
- )}
+ {studyName}
}
slotProps={{
@@ -87,17 +72,15 @@ export default function StudyPathBreadcrumbs({
>
+ {showParentDirectory && nearestParentDirectoryName && (
+
+ {nearestParentDirectoryName}
+
+ )}
{studyName}
-
- {nodeLabel}
-
- {rootNetworks && rootNetworks.length > 1 && (
-
-
-
- )}
+
);
}
diff --git a/src/components/graph/menus/create-node-menu.tsx b/src/components/graph/menus/create-node-menu.tsx
index 00ba2e5833..9c0c8f1ea1 100644
--- a/src/components/graph/menus/create-node-menu.tsx
+++ b/src/components/graph/menus/create-node-menu.tsx
@@ -8,11 +8,10 @@
import { useCallback, useState } from 'react';
import Menu from '@mui/material/Menu';
import { useIntl } from 'react-intl';
-import { useIsAnyNodeBuilding } from '../../utils/is-any-node-building-hook';
import { useSelector } from 'react-redux';
import ChildMenuItem from './create-child-menu-item';
import { CustomDialog } from '../../utils/custom-dialog';
-import { CustomNestedMenuItem, PARAM_DEVELOPER_MODE, BuildStatus } from '@gridsuite/commons-ui';
+import { CustomNestedMenuItem, PARAM_DEVELOPER_MODE } from '@gridsuite/commons-ui';
import { type AppState, type NodeSelectionForCopy } from 'redux/reducer.type';
import type { UUID } from 'node:crypto';
import NetworkModificationTreeModel from '../network-modification-tree-model';
@@ -21,6 +20,8 @@ import { CurrentTreeNode, isSecurityModificationNode, NetworkModificationNodeTyp
import { NodeInsertModes } from 'types/notification-types';
import { Divider } from '@mui/material';
import { useParameterState } from 'components/dialogs/parameters/use-parameters-state';
+import { useIsBuildBlocked, useIsEditBlocked } from 'components/node-activity/hooks/use-node-activity';
+import { isStatusBuilt } from '../util/model-functions';
type SubMenuItem = {
onRoot: boolean;
@@ -133,7 +134,9 @@ const CreateNodeMenu: React.FC = ({
disableRestoreNodes,
}) => {
const intl = useIntl();
- const isAnyNodeBuilding = useIsAnyNodeBuilding();
+ const isActiveNodeEditBlocked = useIsEditBlocked(activeNode?.id);
+ const isClipboardSourceEditBlocked = useIsEditBlocked(nodeSelectionForCopy?.nodeId);
+ const isActiveNodeBuildBlocked = useIsBuildBlocked(activeNode?.id, activeNode?.data);
const mapDataLoading = useSelector((state: AppState) => state.mapDataLoading);
const treeModel = useSelector((state: AppState) => state.networkModificationTreeModel);
const [isDeveloperMode] = useParameterState(PARAM_DEVELOPER_MODE);
@@ -294,16 +297,16 @@ const CreateNodeMenu: React.FC = ({
return isConstructionInsertionForbidden || isSecurityInsertionForbidden;
}
- function isNodeRemovingAllowed() {
- return !isAnyNodeBuilding && !mapDataLoading;
+ function isNodeRemovalDisabled() {
+ return isActiveNodeEditBlocked || mapDataLoading;
}
- function isNodeUnbuildingAllowed() {
- return !isAnyNodeBuilding && !mapDataLoading && activeNode?.data?.globalBuildStatus?.startsWith('BUILT');
+ function isNodeUnbuildDisabled() {
+ return isActiveNodeBuildBlocked || mapDataLoading || !isStatusBuilt(activeNode?.data?.globalBuildStatus);
}
- function isNodeRestorationAllowed() {
- return !isAnyNodeBuilding && !disableRestoreNodes;
+ function isNodeRestorationDisabled() {
+ return disableRestoreNodes || isActiveNodeEditBlocked;
}
function isNodeAlreadySelectedForCut() {
@@ -315,12 +318,39 @@ const CreateNodeMenu: React.FC = ({
nodeSelectionForCopy?.nodeId === activeNode.id && nodeSelectionForCopy?.copyType === CopyType.SUBTREE_CUT
);
}
+ function isCutDisabled(isAlreadySelectedForCut: boolean) {
+ // cancelling a cut only clears the clipboard, so it stays available while the node is busy
+ return !isAlreadySelectedForCut && isActiveNodeEditBlocked;
+ }
+
+ function isInsertionDisabled(insertMode: NodeInsertModes) {
+ // a new branch only appends a child : it leaves the active node and its subtree untouched
+ return insertMode !== NodeInsertModes.NewBranch && isActiveNodeEditBlocked;
+ }
+
+ function isNodePasteDisabled(insertMode: NodeInsertModes) {
+ return (
+ !isNodePastingAllowed() ||
+ isNodeInsertionForbidden(insertMode) ||
+ isInsertionDisabled(insertMode) ||
+ // a cut moves the source node, so it has to be free too
+ (nodeSelectionForCopy?.copyType === CopyType.NODE_CUT && isClipboardSourceEditBlocked)
+ );
+ }
+
+ function isSubtreePasteDisabled() {
+ return (
+ !isSubtreePastingAllowed() ||
+ !isSubtreeContentPasteable() ||
+ (nodeSelectionForCopy?.copyType === CopyType.SUBTREE_CUT && isClipboardSourceEditBlocked)
+ );
+ }
+
function isNodeHasChildren(node: CurrentTreeNode, treeModel: NetworkModificationTreeModel | null): boolean {
return treeModel?.treeNodes.some((item) => item.parentId === node.id) ?? false;
}
- function isSubtreeRemovingAllowed() {
- // check if the subtree has children
- return !isAnyNodeBuilding && !mapDataLoading && isNodeHasChildren(activeNode, treeModel);
+ function isSubtreeRemovalDisabled() {
+ return isActiveNodeEditBlocked || mapDataLoading || !isNodeHasChildren(activeNode, treeModel);
}
const SUBTREE_SUBMENU_ITEMS: Record = {
@@ -328,7 +358,7 @@ const CreateNodeMenu: React.FC = ({
onRoot: false,
action: () => copySubtree(),
id: 'copyNetworkModificationSubtree',
- disabled: isAnyNodeBuilding || !isNodeHasChildren(activeNode, treeModel),
+ disabled: !isNodeHasChildren(activeNode, treeModel),
},
CUT_SUBTREE: {
onRoot: false,
@@ -336,20 +366,20 @@ const CreateNodeMenu: React.FC = ({
id: isSubtreeAlreadySelectedForCut()
? 'cancelCutNetworkModificationSubtree'
: 'cutNetworkModificationSubtree',
- disabled: isAnyNodeBuilding || !isNodeHasChildren(activeNode, treeModel),
+ disabled: isCutDisabled(isSubtreeAlreadySelectedForCut()) || !isNodeHasChildren(activeNode, treeModel),
},
PASTE_SUBTREE: {
onRoot: true,
action: () => pasteSubtree(),
id: 'pasteNetworkModificationSubtree',
- disabled: !isSubtreePastingAllowed() || !isSubtreeContentPasteable(),
+ disabled: isSubtreePasteDisabled(),
withDivider: activeNode?.type !== NodeType.ROOT,
},
REMOVE_SUBTREE: {
onRoot: false,
action: () => removeSubtree(),
id: 'removeNetworkModificationSubtree',
- disabled: !isSubtreeRemovingAllowed(),
+ disabled: isSubtreeRemovalDisabled(),
},
};
@@ -358,15 +388,13 @@ const CreateNodeMenu: React.FC = ({
onRoot: false,
action: () => buildNode(),
id: 'buildNode',
- disabled:
- activeNode?.data?.globalBuildStatus?.startsWith('BUILT') ||
- activeNode?.data?.globalBuildStatus === BuildStatus.BUILDING,
+ disabled: isStatusBuilt(activeNode?.data?.globalBuildStatus) || isActiveNodeBuildBlocked,
},
UNBUILD_NODE: {
onRoot: false,
action: () => unbuildNode(),
id: 'unbuildNode',
- disabled: !isNodeUnbuildingAllowed(),
+ disabled: isNodeUnbuildDisabled(),
withDivider: true,
},
RENAME_NODE: {
@@ -385,6 +413,7 @@ const CreateNodeMenu: React.FC = ({
action: () =>
isNodeAlreadySelectedForCut() ? cancelCutNetworkModificationNode() : cutNetworkModificationNode(),
id: isNodeAlreadySelectedForCut() ? 'cancelCutNetworkModificationNode' : 'cutNetworkModificationNode',
+ disabled: isCutDisabled(isNodeAlreadySelectedForCut()),
},
PASTE_MODIFICATION_NODE: {
onRoot: true,
@@ -395,19 +424,19 @@ const CreateNodeMenu: React.FC = ({
onRoot: true,
action: () => pasteNetworkModificationNode(NodeInsertModes.NewBranch),
id: 'insertNodeInNewBranch',
- disabled: !isNodePastingAllowed() || isNodeInsertionForbidden(NodeInsertModes.NewBranch),
+ disabled: isNodePasteDisabled(NodeInsertModes.NewBranch),
},
PASTE_BEFORE: {
onRoot: false,
action: () => pasteNetworkModificationNode(NodeInsertModes.Before),
id: 'insertNodeAbove',
- disabled: !isNodePastingAllowed() || isNodeInsertionForbidden(NodeInsertModes.Before),
+ disabled: isNodePasteDisabled(NodeInsertModes.Before),
},
PASTE_AFTER: {
onRoot: true,
action: () => pasteNetworkModificationNode(NodeInsertModes.After),
id: 'insertNodeBelow',
- disabled: !isNodePastingAllowed() || isNodeInsertionForbidden(NodeInsertModes.After),
+ disabled: isNodePasteDisabled(NodeInsertModes.After),
},
},
},
@@ -415,7 +444,7 @@ const CreateNodeMenu: React.FC = ({
onRoot: false,
action: () => removeNode(),
id: 'removeNode',
- disabled: !isNodeRemovingAllowed(),
+ disabled: isNodeRemovalDisabled(),
sectionEnd: true,
withDivider: isSecurityModificationNode(activeNode),
},
@@ -424,7 +453,7 @@ const CreateNodeMenu: React.FC = ({
hidden: isSecurityModificationNode(activeNode),
action: () => restoreNodes(),
id: 'restoreNodes',
- disabled: !isNodeRestorationAllowed(),
+ disabled: isNodeRestorationDisabled(),
withDivider: !isSecurityModificationNode(activeNode),
},
@@ -441,18 +470,21 @@ const CreateNodeMenu: React.FC = ({
NetworkModificationNodeType.CONSTRUCTION
),
id: 'insertNodeInNewBranch',
+ disabled: isInsertionDisabled(NodeInsertModes.NewBranch),
},
INSERT_NODE_BEFORE: {
onRoot: false,
action: () =>
createNetworkModificationNode(NodeInsertModes.Before, NetworkModificationNodeType.CONSTRUCTION),
id: 'insertNodeAbove',
+ disabled: isInsertionDisabled(NodeInsertModes.Before),
},
INSERT_NODE_AFTER: {
onRoot: true,
action: () =>
createNetworkModificationNode(NodeInsertModes.After, NetworkModificationNodeType.CONSTRUCTION),
id: 'insertNodeBelow',
+ disabled: isInsertionDisabled(NodeInsertModes.After),
},
},
},
@@ -465,17 +497,18 @@ const CreateNodeMenu: React.FC = ({
action: () =>
createNetworkModificationNode(NodeInsertModes.NewBranch, NetworkModificationNodeType.SECURITY),
id: 'insertNodeInNewBranch',
+ disabled: isInsertionDisabled(NodeInsertModes.NewBranch),
},
INSERT_NODE_BEFORE: {
onRoot: false,
- disabled: !isSecurityModificationNode(activeNode),
+ disabled: !isSecurityModificationNode(activeNode) || isInsertionDisabled(NodeInsertModes.Before),
action: () =>
createNetworkModificationNode(NodeInsertModes.Before, NetworkModificationNodeType.SECURITY),
id: 'insertNodeAbove',
},
INSERT_NODE_AFTER: {
onRoot: true,
- disabled: !isSecurityModificationNode(activeNode),
+ disabled: !isSecurityModificationNode(activeNode) || isInsertionDisabled(NodeInsertModes.After),
action: () =>
createNetworkModificationNode(NodeInsertModes.After, NetworkModificationNodeType.SECURITY),
id: 'insertNodeBelow',
@@ -507,7 +540,7 @@ const CreateNodeMenu: React.FC = ({
onRoot: true,
action: () => exportCaseOnNode(),
id: 'exportCaseOnNode',
- disabled: activeNode?.type !== NodeType.ROOT && !activeNode?.data?.globalBuildStatus?.startsWith('BUILT'),
+ disabled: activeNode?.type !== NodeType.ROOT && !isStatusBuilt(activeNode?.data?.globalBuildStatus),
},
};
diff --git a/src/components/graph/menus/dynamic-simulation/event-modification-scenario-editor.tsx b/src/components/graph/menus/dynamic-simulation/event-modification-scenario-editor.tsx
index bf8a5da789..65ed1342b9 100644
--- a/src/components/graph/menus/dynamic-simulation/event-modification-scenario-editor.tsx
+++ b/src/components/graph/menus/dynamic-simulation/event-modification-scenario-editor.tsx
@@ -14,13 +14,12 @@ import {
useNotificationsListener,
useSnackMessage,
} from '@gridsuite/commons-ui';
-import { useDispatch, useSelector } from 'react-redux';
+import { useSelector } from 'react-redux';
import { Box, Checkbox, CircularProgress, Toolbar, Typography } from '@mui/material';
import { FormattedMessage, useIntl } from 'react-intl';
import DeleteIcon from '@mui/icons-material/Delete';
import IconButton from '@mui/material/IconButton';
-import { useIsAnyNodeBuilding } from '../../../utils/is-any-node-building-hook';
-import { addNotification, removeNotificationByNode, setModificationsInProgress } from '../../../../redux/actions';
+import { useIsEventEditBlocked, useIsNodeUpdating } from 'components/node-activity/hooks/use-node-activity';
import type { UUID } from 'node:crypto';
import { Event, EventType } from '../../../dialogs/dynamicsimulation/event/types/event.type';
import { DynamicSimulationEventDialog } from '../../../dialogs/dynamicsimulation/event/dynamic-simulation-event-dialog';
@@ -29,17 +28,7 @@ import { isChecked, isPartial, styles } from '../network-modifications/network-m
import { EQUIPMENT_TYPE_LABEL_KEYS } from '../../util/model-constants';
import EditIcon from '@mui/icons-material/Edit';
import { AppState } from '../../../../redux/reducer.type';
-import { AppDispatch } from '../../../../redux/store';
-import {
- EventCreatingInProgressEventData,
- EventDeletingInProgressEventData,
- EventUpdatingInProgressEventData,
- isEventCrudFinishedNotification,
- isEventNotification,
- NotificationType,
- parseEventData,
- CommonStudyEventData,
-} from 'types/notification-types';
+import { isEventCrudFinishedNotification, parseEventData, CommonStudyEventData } from 'types/notification-types';
import {
deleteDynamicSimulationEvents,
fetchDynamicSimulationEvents,
@@ -56,14 +45,12 @@ const paperStyles = {
const EventModificationScenarioEditor = memo(() => {
const intl = useIntl();
- const notificationIdList = useSelector((state: AppState) => state.notificationIdList);
const studyUuid = useSelector((state: AppState) => state.studyUuid);
const { snackError } = useSnackMessage();
const [events, setEvents] = useState([]);
const currentNode = useSelector((state: AppState) => state.currentTreeNode);
const currentNodeIdRef = useRef(null); // initial empty to get first update
- const [pendingState, setPendingState] = useState(false);
const [selectedItems, setSelectedItems] = useState([]);
@@ -76,47 +63,12 @@ const EventModificationScenarioEditor = memo(() => {
| undefined
>();
- const dispatch = useDispatch();
- const [messageId, setMessageId] = useState('');
const [launchLoader, setLaunchLoader] = useState(false);
const handleCloseDialog = () => {
setEditDialogOpen(undefined);
};
- const fillNotification = useCallback(
- (
- eventData:
- EventCreatingInProgressEventData | EventUpdatingInProgressEventData | EventDeletingInProgressEventData,
- messageId: string
- ) => {
- // (work for all users)
- // specific message id for each action type
- setMessageId(messageId);
-
- dispatch(addNotification([eventData.headers.parentNode, ...(eventData.headers.nodes ?? [])]));
- },
- [dispatch]
- );
-
- const manageNotification = useCallback(
- (
- eventData:
- EventCreatingInProgressEventData | EventUpdatingInProgressEventData | EventDeletingInProgressEventData
- ) => {
- let messageId = '';
- if (eventData.headers.updateType === NotificationType.EVENT_CREATING_IN_PROGRESS) {
- messageId = 'DynamicSimulationEventCreating';
- } else if (eventData.headers.updateType === NotificationType.EVENT_UPDATING_IN_PROGRESS) {
- messageId = 'DynamicSimulationEventUpdating';
- } else if (eventData.headers.updateType === NotificationType.EVENT_DELETING_IN_PROGRESS) {
- messageId = 'DynamicSimulationEventDeleting';
- }
- fillNotification(eventData, messageId);
- },
- [fillNotification]
- );
-
const updateSelectedItems = useCallback((events: Event[]) => {
const toKeepIdsSet = new Set(events.map((e) => e.uuid));
setSelectedItems((oldselectedItems) => oldselectedItems.filter((s) => toKeepIdsSet.has(s.uuid)));
@@ -143,11 +95,9 @@ const EventModificationScenarioEditor = memo(() => {
snackWithFallback(snackError, error);
})
.finally(() => {
- setPendingState(false);
setLaunchLoader(false);
- dispatch(setModificationsInProgress(false));
});
- }, [currentNode?.type, currentNode?.id, studyUuid, updateSelectedItems, snackError, dispatch]);
+ }, [currentNode?.type, currentNode?.id, studyUuid, updateSelectedItems, snackError]);
useEffect(() => {
// first time with currentNode initialized then fetch events
@@ -167,32 +117,21 @@ const EventModificationScenarioEditor = memo(() => {
if (!eventData) {
return;
}
- if (isEventNotification(eventData)) {
- if (currentNodeIdRef.current !== eventData.headers.parentNode) {
- return;
- }
-
- dispatch(setModificationsInProgress(true));
- setPendingState(true);
- manageNotification(eventData);
- } else if (isEventCrudFinishedNotification(eventData)) {
- // notify finished action (success or error => we remove the loader)
- // error handling in dialog for each equipment (snackbar with specific error showed only for current user)
- // fetch events because it must have changed
- // Do not clear the events list, because currentNode is the concerned one
- // this allows to append new events to the existing list.
+ // success or error, the events may have changed : the spinner is driven by the node activity
+ if (isEventCrudFinishedNotification(eventData)) {
+ // append to the existing list : currentNode is the concerned one, so it is not cleared
doFetchEvents();
- dispatch(removeNotificationByNode([eventData.headers.parentNode, ...(eventData.headers.nodes ?? [])]));
}
},
- [dispatch, doFetchEvents, manageNotification]
+ [doFetchEvents]
);
useNotificationsListener(NotificationsUrlKeys.STUDY, {
listenerCallbackMessage: handleEvent,
});
- const isAnyNodeBuilding = useIsAnyNodeBuilding();
+ const isEventEditBlocked = useIsEventEditBlocked(currentNode?.id);
+ const isNodeUpdating = useIsNodeUpdating(currentNode?.id);
const doDeleteEvent = useCallback(() => {
if (!studyUuid || !currentNode?.id) {
@@ -216,10 +155,6 @@ const EventModificationScenarioEditor = memo(() => {
setSelectedItems((oldVals: Event[]) => (oldVals.length === 0 ? events : []));
}, [events]);
- const isLoading = useCallback(() => {
- return notificationIdList.filter((notification) => notification === currentNode?.id).length > 0;
- }, [currentNode?.id, notificationIdList]);
-
const getItemLabel = (item: Event) => {
if (!studyUuid || !currentNode || !item) {
return '';
@@ -246,17 +181,12 @@ const EventModificationScenarioEditor = memo(() => {
const handleSecondaryAction = useCallback(
(item: Event, isItemHovered?: boolean) =>
- isItemHovered && !isAnyNodeBuilding ? (
- doEditEvent(item)}
- size={'small'}
- sx={styles.iconEdit}
- disabled={isLoading()}
- >
+ isItemHovered && !isEventEditBlocked ? (
+ doEditEvent(item)} size={'small'} sx={styles.iconEdit}>
) : null,
- [isAnyNodeBuilding, isLoading]
+ [isEventEditBlocked]
);
const renderEventList = () => {
@@ -277,25 +207,12 @@ const EventModificationScenarioEditor = memo(() => {
getItemId={(v: Event) => v.equipmentId}
getItemLabel={getItemLabel}
secondaryAction={handleSecondaryAction}
- isDisabled={() => isLoading()}
+ isDisabled={() => isEventEditBlocked}
divider
/>
);
};
- const renderEventListTitleLoading = () => {
- return (
-
-
-
-
-
-
-
-
- );
- };
-
const renderEventListTitleUpdating = () => {
return (
@@ -313,14 +230,14 @@ const EventModificationScenarioEditor = memo(() => {
return (
- {pendingState && }
+ {isNodeUpdating && }
@@ -329,9 +246,6 @@ const EventModificationScenarioEditor = memo(() => {
};
const renderPaneSubtitle = () => {
- if (isLoading() && messageId) {
- return renderEventListTitleLoading();
- }
if (launchLoader) {
return renderEventListTitleUpdating();
}
@@ -354,7 +268,7 @@ const EventModificationScenarioEditor = memo(() => {
diff --git a/src/components/graph/menus/network-modifications/network-modification-node-editor-utils.ts b/src/components/graph/menus/network-modifications/network-modification-node-editor-utils.ts
index 5b2a87f8fd..f32954b45c 100644
--- a/src/components/graph/menus/network-modifications/network-modification-node-editor-utils.ts
+++ b/src/components/graph/menus/network-modifications/network-modification-node-editor-utils.ts
@@ -67,14 +67,6 @@ export const styles = {
marginRight: theme.spacing(2),
color: theme.palette.primary.main,
}),
- toolbarCircularProgress: (theme) => ({
- display: 'flex',
- alignItems: 'center',
- justifyContent: 'center',
- marginLeft: theme.spacing(1.25),
- marginRight: theme.spacing(2),
- color: theme.palette.secondary.main,
- }),
notification: (theme) => ({
flex: 1,
alignContent: 'center',
diff --git a/src/components/graph/menus/network-modifications/network-modification-node-editor.tsx b/src/components/graph/menus/network-modifications/network-modification-node-editor.tsx
index f4ede0f30d..534dc98318 100644
--- a/src/components/graph/menus/network-modifications/network-modification-node-editor.tsx
+++ b/src/components/graph/menus/network-modifications/network-modification-node-editor.tsx
@@ -35,7 +35,7 @@ import ContentCutIcon from '@mui/icons-material/ContentCut';
import ContentPasteIcon from '@mui/icons-material/ContentPaste';
import DeleteIcon from '@mui/icons-material/Delete';
import SaveIcon from '@mui/icons-material/Save';
-import { Alert, Box, CircularProgress, Toolbar, Tooltip } from '@mui/material';
+import { Alert, Box, Divider, Toolbar, Tooltip } from '@mui/material';
import IconButton from '@mui/material/IconButton';
import BatteryCreationDialog from 'components/dialogs/network-modifications/battery/creation/battery-creation-dialog';
@@ -73,14 +73,13 @@ import NetworkModificationsMenu from 'components/graph/menus/network-modificatio
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { FormattedMessage } from 'react-intl';
import { useDispatch, useSelector } from 'react-redux';
-import {
- addNotification,
- removeNotificationByNode,
- setHighlightModification,
- setModificationsInProgress,
-} from '../../../../redux/actions';
+import { setHighlightModification } from '../../../../redux/actions';
import TwoWindingsTransformerModificationDialog from '../../../dialogs/network-modifications/two-windings-transformer/modification/two-windings-transformer-modification-dialog';
-import { useIsAnyNodeBuilding } from '../../../utils/is-any-node-building-hook';
+import {
+ useIsBuildBlocked,
+ useIsEditBlocked,
+ useIsNodeUpdating,
+} from 'components/node-activity/hooks/use-node-activity';
import { FileUpload, RestoreFromTrash } from '@mui/icons-material';
@@ -115,13 +114,6 @@ import {
isModificationsDeleteFinishedNotification,
isModificationsUpdateFinishedNotification,
isNodeDeletedNotification,
- isPendingModificationNotification,
- ModificationsCreationInProgressEventData,
- ModificationsDeletingInProgressEventData,
- ModificationsRestoringInProgressEventData,
- ModificationsStashingInProgressEventData,
- ModificationsUpdatingInProgressEventData,
- NotificationType,
parseEventData,
} from 'types/notification-types';
import { LccModificationDialog } from '../../../dialogs/network-modifications/hvdc-line/lcc/modification/lcc-modification-dialog';
@@ -130,6 +122,7 @@ import CreateCouplingDeviceDialog from '../../../dialogs/network-modifications/c
import { BalancesAdjustmentDialog } from '../../../dialogs/network-modifications/balances-adjustment/balances-adjustment-dialog';
import CreateVoltageLevelTopologyDialog from '../../../dialogs/network-modifications/voltage-level/topology-creation/create-voltage-level-topology-dialog';
import { NodeType } from 'components/graph/tree-node.type';
+import { BuildButton } from 'components/graph/nodes/build-button';
import { LimitSetsModificationDialog } from '../../../dialogs/network-modifications/limit-sets/limit-sets-modification-dialog';
import CreateVoltageLevelSectionDialog from '../../../dialogs/network-modifications/voltage-level/section/create-voltage-level-section-dialog';
import MoveVoltageLevelFeederBaysDialog from '../../../dialogs/network-modifications/voltage-level/move-feeder-bays/move-voltage-level-feeder-bays-dialog';
@@ -146,6 +139,10 @@ const nonEditableModificationTypes = new Set([
'MODIFICATION_REFERENCE',
]);
+// commons-ui only spins on this when paired with notificationMessageId, which the node activity spinner
+// replaced : the prop is required, so it stays pinned off until commons-ui drops it.
+const NEVER_IMPACTED_BY_NOTIFICATION = () => false;
+
const isEditableModification = (modif: NetworkModificationMetadata) => {
if (!modif) {
return false;
@@ -154,7 +151,6 @@ const isEditableModification = (modif: NetworkModificationMetadata) => {
};
const NetworkModificationNodeEditor = () => {
- const notificationIdList = useSelector((state: AppState) => state.notificationIdList);
const studyUuid = useSelector((state: AppState) => state.studyUuid);
const rootNetworks = useSelector((state: AppState) => state.rootNetworks);
const createdRootNetworks = rootNetworks.filter((rn) => !rn.isCreating);
@@ -165,7 +161,6 @@ const NetworkModificationNodeEditor = () => {
const [modifications, setModifications] = useState([]);
const [modificationsToExclude, setModificationsToExclude] = useState([]);
const [saveInProgress, setSaveInProgress] = useState(false);
- const [deleteInProgress, setDeleteInProgress] = useState(false);
const [modificationsToRestore, setModificationsToRestore] = useState([]);
const currentNode = useSelector((state: AppState) => state.currentTreeNode);
const isRootNode = currentNode?.type === NodeType.ROOT;
@@ -173,7 +168,6 @@ const NetworkModificationNodeEditor = () => {
const isMonoRootStudy = useSelector((state: AppState) => state.isMonoRootStudy);
const currentNodeIdRef = useRef(null); // initial empty to get first update
- const [pendingState, setPendingState] = useState(false);
const [selectedNetworkModifications, setSelectedNetworkModifications] = useState(
[]
@@ -197,7 +191,6 @@ const NetworkModificationNodeEditor = () => {
const [importDialogOpen, setImportDialogOpen] = useState(false);
const [createCompositeModificationDialogOpen, setCreateCompositeModificationDialogOpen] = useState(false);
const dispatch = useDispatch();
- const [notificationMessageId, setNotificationMessageId] = useState('');
const [isFetchingModifications, setIsFetchingModifications] = useState(false);
const [isUpdate, setIsUpdate] = useState(false);
const buttonAddRef = useRef(null);
@@ -668,56 +661,6 @@ const NetworkModificationNodeEditor = () => {
)
.filter((item) => !('hide' in item && item.hide));
- const fillNotification = useCallback(
- (
- eventData:
- | ModificationsCreationInProgressEventData
- | ModificationsUpdatingInProgressEventData
- | ModificationsStashingInProgressEventData
- | ModificationsRestoringInProgressEventData
- | ModificationsDeletingInProgressEventData,
- messageId: string
- ) => {
- // (work for all users)
- // specific message id for each action type
-
- setNotificationMessageId(messageId);
- dispatch(addNotification([eventData.headers.parentNode ?? []]));
- },
- [dispatch]
- );
-
- const manageNotification = useCallback(
- (
- eventData:
- | ModificationsCreationInProgressEventData
- | ModificationsUpdatingInProgressEventData
- | ModificationsStashingInProgressEventData
- | ModificationsRestoringInProgressEventData
- | ModificationsDeletingInProgressEventData
- ) => {
- let messageId;
- switch (eventData.headers.updateType) {
- case NotificationType.MODIFICATIONS_CREATION_IN_PROGRESS:
- messageId = 'network_modifications.creatingModification';
- break;
- case NotificationType.MODIFICATIONS_UPDATING_IN_PROGRESS:
- messageId = 'network_modifications.updatingModification';
- break;
- case NotificationType.MODIFICATIONS_STASHING_IN_PROGRESS:
- messageId = 'network_modifications.stashingModification';
- break;
- case NotificationType.MODIFICATIONS_RESTORING_IN_PROGRESS:
- messageId = 'network_modifications.restoringModification';
- break;
- default:
- messageId = '';
- }
- fillNotification(eventData, messageId);
- },
- [fillNotification]
- );
-
const dofetchNetworkModificationsToRestore = useCallback(() => {
if (currentNode?.type !== NodeType.NETWORK_MODIFICATION) {
return;
@@ -733,11 +676,9 @@ const NetworkModificationNodeEditor = () => {
snackWithFallback(snackError, error);
})
.finally(() => {
- setPendingState(false);
setIsFetchingModifications(false);
- dispatch(setModificationsInProgress(false));
});
- }, [studyUuid, currentNode?.id, currentNode?.type, snackError, dispatch]);
+ }, [studyUuid, currentNode?.id, currentNode?.type, snackError]);
const updateSelectedItems = useCallback((modifications: NetworkModificationMetadata[]) => {
const toKeepIdsSet = new Set(modifications.map((e) => e.uuid));
@@ -765,11 +706,9 @@ const NetworkModificationNodeEditor = () => {
snackWithFallback(snackError, error);
})
.finally(() => {
- setPendingState(false);
setIsFetchingModifications(false);
- dispatch(setModificationsInProgress(false));
});
- }, [currentNode?.type, currentNode?.id, studyUuid, updateSelectedItems, snackError, dispatch]);
+ }, [currentNode?.type, currentNode?.id, studyUuid, updateSelectedItems, snackError]);
const dofetchExcludedNetworkModifications = useCallback(() => {
// Do not fetch modifications status on the root node
@@ -789,11 +728,9 @@ const NetworkModificationNodeEditor = () => {
snackWithFallback(snackError, error);
})
.finally(() => {
- setPendingState(false);
setIsFetchingModifications(false);
- dispatch(setModificationsInProgress(false));
});
- }, [currentNode?.type, currentNode?.id, studyUuid, snackError, dispatch]);
+ }, [currentNode?.type, currentNode?.id, studyUuid, snackError]);
useEffect(() => {
if (!currentNode) {
@@ -852,41 +789,23 @@ const NetworkModificationNodeEditor = () => {
}
}
- if (isPendingModificationNotification(eventData)) {
- if (currentNodeIdRef.current !== eventData.headers.parentNode) {
- return;
- }
- if (eventData.headers.updateType === NotificationType.MODIFICATIONS_DELETING_IN_PROGRESS) {
- // deleting means removing from trashcan (stashed elements) so there is no network modification
- setDeleteInProgress(true);
- } else {
- dispatch(setModificationsInProgress(true));
- setPendingState(true);
- manageNotification(eventData);
- }
- }
- // notify finished action (success or error => we remove the loader)
- // error handling in dialog for each equipment (snackbar with specific error showed only for current user)
+ // success or error, the modifications may have changed : the spinner is driven by the node activity
if (isModificationsUpdateFinishedNotification(eventData)) {
if (currentNodeIdRef.current !== eventData.headers.parentNode) {
return;
}
- // fetch modifications because it must have changed
- // Do not clear the modifications list, because currentNode is the concerned one
- // this allows to append new modifications to the existing list.
+ // append to the existing list : currentNode is the concerned one, so it is not cleared
dofetchNetworkModifications();
dofetchExcludedNetworkModifications();
- dispatch(removeNotificationByNode([eventData.headers.parentNode, ...(eventData.headers.nodes ?? [])]));
}
if (isModificationsDeleteFinishedNotification(eventData)) {
if (currentNodeIdRef.current !== eventData.headers.parentNode) {
return;
}
- setDeleteInProgress(false);
dofetchNetworkModifications();
}
},
- [dispatch, dofetchNetworkModifications, manageNotification, cleanClipboard, dofetchExcludedNetworkModifications]
+ [dofetchNetworkModifications, cleanClipboard, dofetchExcludedNetworkModifications]
);
useNotificationsListener(NotificationsUrlKeys.STUDY, {
@@ -895,7 +814,9 @@ const NetworkModificationNodeEditor = () => {
const [openNetworkModificationsMenu, setOpenNetworkModificationsMenu] = useState(false);
- const isAnyNodeBuilding = useIsAnyNodeBuilding();
+ const isEditBlocked = useIsEditBlocked(currentNode?.id);
+ const isBuildBlocked = useIsBuildBlocked(currentNode?.id, currentNode?.data);
+ const isNodeUpdating = useIsNodeUpdating(currentNode?.id);
const mapDataLoading = useSelector((state: AppState) => state.mapDataLoading);
@@ -1125,14 +1046,10 @@ const NetworkModificationNodeEditor = () => {
return undefined;
};
- const isImpactedByNotification = useCallback(() => {
- return notificationIdList.filter((notification) => notification === currentNode?.id).length > 0;
- }, [notificationIdList, currentNode?.id]);
-
const isModificationClickable = useCallback(
(modification: ComposedModificationMetadata) =>
- !isAnyNodeBuilding && !mapDataLoading && !isDragging && isEditableModification(modification),
- [isAnyNodeBuilding, mapDataLoading, isDragging]
+ !isEditBlocked && !mapDataLoading && !isDragging && isEditableModification(modification),
+ [isEditBlocked, mapDataLoading, isDragging]
);
const columns = useMemo[]>(
@@ -1167,11 +1084,10 @@ const NetworkModificationNodeEditor = () => {
onRowDragStart={onRowDragStart}
onRowDragEnd={onRowDragEnd}
onSelectedRowsChange={handleRowSelected}
- isRowDragDisabled={isImpactedByNotification() || isAnyNodeBuilding || mapDataLoading}
- isImpactedByNotification={isImpactedByNotification}
- notificationMessageId={notificationMessageId}
+ isRowDragDisabled={isEditBlocked || mapDataLoading}
+ isImpactedByNotification={NEVER_IMPACTED_BY_NOTIFICATION}
isFetchingModifications={isFetchingModifications}
- pendingState={pendingState}
+ pendingState={isNodeUpdating}
columns={columns}
highlightedModificationUuid={highlightedModificationUuid}
modificationUuidsToReset={modificationUuidsToReset}
@@ -1182,7 +1098,7 @@ const NetworkModificationNodeEditor = () => {
rootNetworks={isMonoRootStudy ? undefined : rootNetworks}
modificationsToExclude={modificationsToExclude}
setModificationsToExclude={setModificationsToExclude}
- isDisabled={isAnyNodeBuilding || mapDataLoading}
+ isDisabled={isEditBlocked || mapDataLoading}
/>
);
};
@@ -1238,12 +1154,12 @@ const NetworkModificationNodeEditor = () => {
}, []);
const isPasteButtonDisabled = useMemo(() => {
- return networkModificationsToCopy.length <= 0 || isAnyNodeBuilding || mapDataLoading || !currentNode;
- }, [networkModificationsToCopy.length, isAnyNodeBuilding, mapDataLoading, currentNode]);
+ return networkModificationsToCopy.length <= 0 || isEditBlocked || mapDataLoading || !currentNode;
+ }, [networkModificationsToCopy.length, isEditBlocked, mapDataLoading, currentNode]);
const isRestoreButtonDisabled = useMemo(() => {
- return modificationsToRestore.length === 0 || isAnyNodeBuilding || deleteInProgress;
- }, [modificationsToRestore.length, isAnyNodeBuilding, deleteInProgress]);
+ return modificationsToRestore.length === 0 || isEditBlocked;
+ }, [modificationsToRestore.length, isEditBlocked]);
const isCompositeNestingLimitReached = useMemo(
() => selectedNetworkModifications.some((row) => (row.maxDepth ?? 0) >= MAX_COMPOSITE_NESTING_DEPTH),
@@ -1251,8 +1167,14 @@ const NetworkModificationNodeEditor = () => {
);
const disabledCompositeCreation: boolean = useMemo(() => {
- return selectedNetworkModifications?.length === 0 || saveInProgress || isRootNode || isAssemblyDepthExceeded;
- }, [selectedNetworkModifications, saveInProgress, isRootNode, isAssemblyDepthExceeded]);
+ return (
+ selectedNetworkModifications?.length === 0 ||
+ saveInProgress ||
+ isRootNode ||
+ isAssemblyDepthExceeded ||
+ isEditBlocked
+ );
+ }, [selectedNetworkModifications, saveInProgress, isRootNode, isAssemblyDepthExceeded, isEditBlocked]);
const disabledCompositeExport: boolean = useMemo(() => {
return (
@@ -1264,13 +1186,25 @@ const NetworkModificationNodeEditor = () => {
<>
+ {currentNode?.type === NodeType.NETWORK_MODIFICATION && (
+ <>
+
+
+ >
+ )}
}>
@@ -1305,7 +1239,7 @@ const NetworkModificationNodeEditor = () => {
@@ -1342,7 +1276,7 @@ const NetworkModificationNodeEditor = () => {
size={'small'}
disabled={
selectedNetworkModifications.length === 0 ||
- isAnyNodeBuilding ||
+ isEditBlocked ||
mapDataLoading ||
!currentNode ||
isRootNode ||
@@ -1361,7 +1295,7 @@ const NetworkModificationNodeEditor = () => {
size={'small'}
disabled={
selectedNetworkModifications.length === 0 ||
- isAnyNodeBuilding ||
+ isEditBlocked ||
mapDataLoading ||
isRootNode ||
selectionContainsShared
@@ -1401,9 +1335,8 @@ const NetworkModificationNodeEditor = () => {
size={'small'}
disabled={
selectedNetworkModifications.length === 0 ||
- isAnyNodeBuilding ||
+ isEditBlocked ||
mapDataLoading ||
- deleteInProgress ||
!currentNode ||
isRootNode
}
@@ -1413,36 +1346,28 @@ const NetworkModificationNodeEditor = () => {
- {deleteInProgress ? (
- }>
-
-
-
-
- ) : (
- 1 ? 's' : '',
- }}
- />
- }
- >
-
-
-
-
-
-
- )}
+ 1 ? 's' : '',
+ }}
+ />
+ }
+ >
+
+
+
+
+
+
{restoreDialogOpen && renderNetworkModificationsToRestoreDialog()}
{importDialogOpen && renderImportNetworkModificationsDialog()}
diff --git a/src/components/graph/menus/root-network/unbuild-all-nodes-button.tsx b/src/components/graph/menus/root-network/unbuild-all-nodes-button.tsx
index 033f32ef23..367bd9cfe0 100644
--- a/src/components/graph/menus/root-network/unbuild-all-nodes-button.tsx
+++ b/src/components/graph/menus/root-network/unbuild-all-nodes-button.tsx
@@ -21,6 +21,7 @@ import { FormattedMessage, useIntl } from 'react-intl';
import { useSelector } from 'react-redux';
import { AppState } from 'redux/reducer.type';
import { unbuildAllStudyNodes } from 'services/study/study';
+import { useIsUnbuildAllBlocked } from 'components/node-activity/hooks/use-node-activity';
import { NETWORK_MODIFICATION } from '../../../../utils/report/report.constant';
const styles = {
@@ -41,6 +42,8 @@ export const UnbuildAllNodesButton = () => {
const [isValidationDialogOpen, setIsValidationDialogOpen] = useState(false);
const [isLoading, setIsLoading] = useState(false);
+ const isUnbuildAllBlocked = useIsUnbuildAllBlocked();
+
const handleCloseDialog = () => {
setIsValidationDialogOpen(false);
};
@@ -81,8 +84,15 @@ export const UnbuildAllNodesButton = () => {
<>
-
diff --git a/src/components/graph/network-modification-tree-model.ts b/src/components/graph/network-modification-tree-model.ts
index 87d7969170..5a60eb6532 100644
--- a/src/components/graph/network-modification-tree-model.ts
+++ b/src/components/graph/network-modification-tree-model.ts
@@ -6,7 +6,6 @@
*/
import { convertNodetoReactFlowModelNode, getModificationNodeDataOrUndefined } from './util/model-functions';
-import { BuildStatus } from '@gridsuite/commons-ui';
import type { UUID } from 'node:crypto';
import { Edge } from '@xyflow/react';
import { AbstractNode, CurrentTreeNode, NetworkModificationNodeData, RootNodeData } from './tree-node.type';
@@ -28,8 +27,6 @@ export default class NetworkModificationTreeModel {
treeNodes: CurrentTreeNode[] = [];
treeEdges: Edge[] = [];
- isAnyNodeBuilding = false;
-
// Will sort if columnPosition is defined, and not move the nodes if undefined
childrenNodeSorter(a: AbstractNode, b: AbstractNode) {
if (a.columnPosition !== undefined && b.columnPosition !== undefined) {
@@ -272,16 +269,10 @@ export default class NetworkModificationTreeModel {
elements.children.forEach((child) => {
this.addChild(child, elements.id);
});
- this.setBuildingStatus();
}
newSharedForUpdate() {
/* shallow clone of the network https://stackoverflow.com/a/44782052 */
return Object.assign(Object.create(Object.getPrototypeOf(this)), this);
}
-
- setBuildingStatus() {
- this.isAnyNodeBuilding =
- this.treeNodes.find((node) => node?.data?.globalBuildStatus === BuildStatus.BUILDING) !== undefined;
- }
}
diff --git a/src/components/graph/nodes/build-button.tsx b/src/components/graph/nodes/build-button.tsx
index 402310d257..067a6c522a 100644
--- a/src/components/graph/nodes/build-button.tsx
+++ b/src/components/graph/nodes/build-button.tsx
@@ -7,7 +7,7 @@
import React, { useCallback, useState } from 'react';
import { PlayCircleFilled, StopCircleOutlined } from '@mui/icons-material';
-import { Button, CircularProgress } from '@mui/material';
+import { Button } from '@mui/material';
import { buildNode, unbuildNode } from '../../../services/study';
import type { UUID } from 'node:crypto';
import { type MuiStyles, snackWithFallback, BuildStatus, useSnackMessage } from '@gridsuite/commons-ui';
@@ -18,6 +18,7 @@ type BuildButtonProps = {
currentRootNetworkUuid: UUID | null;
nodeUuid: UUID;
onClick?: () => void;
+ disabled?: boolean;
};
const styles = {
@@ -35,6 +36,7 @@ export const BuildButton = ({
currentRootNetworkUuid,
nodeUuid,
onClick,
+ disabled,
}: BuildButtonProps) => {
const [isLoading, setIsLoading] = useState(false);
const { snackError } = useSnackMessage();
@@ -69,9 +71,6 @@ export const BuildButton = ({
);
const getIcon = () => {
- if (isLoading) {
- return ;
- }
return !buildStatus || buildStatus === BuildStatus.NOT_BUILT ? (
) : (
@@ -79,7 +78,7 @@ export const BuildButton = ({
);
};
- const isButtonDisabled = isLoading || !studyUuid || !currentRootNetworkUuid;
+ const isButtonDisabled = isLoading || !studyUuid || !currentRootNetworkUuid || disabled;
return (
diff --git a/src/components/graph/nodes/network-modification-node.tsx b/src/components/graph/nodes/network-modification-node.tsx
index afd9e03db4..4ab0db5ad8 100644
--- a/src/components/graph/nodes/network-modification-node.tsx
+++ b/src/components/graph/nodes/network-modification-node.tsx
@@ -9,14 +9,7 @@ import { NodeProps, Position } from '@xyflow/react';
import ArrowUpwardIcon from '@mui/icons-material/ArrowUpward';
import { useSelector } from 'react-redux';
import Box from '@mui/material/Box';
-import {
- copyToClipboard,
- LIGHT_THEME,
- type MuiStyles,
- useSnackMessage,
- BuildStatusChip,
- BuildStatus,
-} from '@gridsuite/commons-ui';
+import { copyToClipboard, LIGHT_THEME, type MuiStyles, useSnackMessage, BuildStatusChip } from '@gridsuite/commons-ui';
import { getLocalStorageTheme } from '../../../redux/session-storage/local-storage';
import { AppState } from 'redux/reducer.type';
import { CopyType } from 'components/network-modification.type';
@@ -24,6 +17,8 @@ import { ModificationNode } from '../tree-node.type';
import NodeHandle from './node-handle';
import { baseNodeStyles, interactiveNodeStyles } from './styles';
import NodeOverlaySpinner from './node-overlay-spinner';
+import { BlockedByActivityIndicator } from 'components/node-activity/node-activity-display';
+import { useIsBuildBlocked, useNodeActivity } from 'components/node-activity/hooks/use-node-activity';
import { BuildButton } from './build-button';
import { Tooltip, Typography } from '@mui/material';
@@ -87,6 +82,13 @@ const styles = {
left: theme.spacing(1),
zIndex: 2,
}),
+ blockedSpinner: (theme) => ({
+ position: 'absolute',
+ top: '50%',
+ right: theme.spacing(-4),
+ transform: 'translateY(-50%)',
+ zIndex: 2,
+ }),
tooltip: {
maxWidth: '720px',
},
@@ -101,6 +103,9 @@ const NetworkModificationNode = (props: NodeProps) => {
const intl = useIntl();
+ const activity = useNodeActivity(props.id);
+ const isBuildBlocked = useIsBuildBlocked(props.id, props.data);
+
const onClipboardCopy = useCallback(() => {
snackInfo({ headerId: 'uuidCopiedToClipboard' });
}, [snackInfo]);
@@ -192,25 +197,35 @@ const NetworkModificationNode = (props: NodeProps) => {
- {props.data.globalBuildStatus !== BuildStatus.BUILDING && (
-
- )}
+ {!activity && }
- {props.data.localBuildStatus !== BuildStatus.BUILDING && (
+ {!activity && (
)}
- {props.data.localBuildStatus === BuildStatus.BUILDING && }
+ {activity && }
+
+
>
);
};
diff --git a/src/components/graph/nodes/node-overlay-spinner.tsx b/src/components/graph/nodes/node-overlay-spinner.tsx
index 8487e39159..fe3ef017c5 100644
--- a/src/components/graph/nodes/node-overlay-spinner.tsx
+++ b/src/components/graph/nodes/node-overlay-spinner.tsx
@@ -5,22 +5,30 @@
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
-import { alpha, Box, CircularProgress, colors } from '@mui/material';
+import { alpha, Box, CircularProgress, colors, Typography } from '@mui/material';
+import { FormattedMessage } from 'react-intl';
+import { nodeActivityLabelId, type NodeActivity } from 'components/node-activity/types/node-activity.type';
-const NodeOverlaySpinner = () => {
+const NodeOverlaySpinner = ({ activity }: Readonly<{ activity: NodeActivity }>) => {
return (
({
position: 'absolute',
inset: 0,
display: 'flex',
+ flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
+ borderRadius: '8px',
+ gap: 0.5,
zIndex: 10,
- backgroundColor: alpha(theme.node.common.background, 0.6),
+ backgroundColor: alpha(theme.node.common.background, 0.85),
})}
>
-
+
+
+
+
);
};
diff --git a/src/components/graph/nodes/root-node.tsx b/src/components/graph/nodes/root-node.tsx
index c0cfcfbcdd..dde2917212 100644
--- a/src/components/graph/nodes/root-node.tsx
+++ b/src/components/graph/nodes/root-node.tsx
@@ -14,8 +14,10 @@ import { Box } from '@mui/material';
import { type MuiStyles, OverflowableText } from '@gridsuite/commons-ui';
import { DeviceHub } from '@mui/icons-material';
import NodeHandle from './node-handle';
+import NodeOverlaySpinner from './node-overlay-spinner';
import { baseNodeStyles, interactiveNodeStyles } from './styles';
import { UnbuildAllNodesButton } from '../menus/root-network/unbuild-all-nodes-button';
+import { useNodeActivity } from 'components/node-activity/hooks/use-node-activity';
const styles = {
// full node container styles
@@ -92,6 +94,8 @@ const RootNode = (props: NodeProps) => {
(rootNetwork) => rootNetwork.rootNetworkUuid === currentRootNetworkUuid
);
+ const activity = useNodeActivity(props.id);
+
const isSelectedNode = () => {
return props.id === currentNode?.id;
};
@@ -113,6 +117,8 @@ const RootNode = (props: NodeProps) => {
+
+ {activity && }
>
);
diff --git a/src/components/graph/util/model-functions.ts b/src/components/graph/util/model-functions.ts
index a6bda0f57b..99622b79e8 100644
--- a/src/components/graph/util/model-functions.ts
+++ b/src/components/graph/util/model-functions.ts
@@ -121,8 +121,8 @@ export function isNodeReadOnly(node: CurrentTreeNode | null) {
return node?.data?.readOnly ? true : false; // ternary operator because of potential undefined
}
-export function isStatusBuilt(status: BuildStatus | undefined) {
- return status?.startsWith('BUILT');
+export function isStatusBuilt(status: BuildStatus | undefined): boolean {
+ return !!status?.startsWith('BUILT');
}
export function isNodeBuilt(node: CurrentTreeNode | null) {
@@ -160,13 +160,6 @@ export function isNodeEdited(node1: CurrentTreeNode | null, node2: CurrentTreeNo
return isDescriptionNodeEdited(node1, node2) || isNodeRenamed(node1, node2);
}
-export function isNodeInNotificationList(node: CurrentTreeNode, notificationIdList: UUID[]) {
- if (!node || !notificationIdList) {
- return false;
- }
- return notificationIdList.includes(node.id);
-}
-
export function isSameNodeAndBuilt(node1: CurrentTreeNode | null, node2: CurrentTreeNode | null) {
return isSameNode(node1, node2) && isNodeBuilt(node1);
}
@@ -190,3 +183,36 @@ export function getAllChildren(elements: NetworkModificationTreeModel | null, no
export const getNetworkModificationNode = (treeModel: NetworkModificationTreeModel | null, nodeId: UUID) => {
return treeModel?.treeNodes.find((n) => n.id === nodeId);
};
+
+export function isDescendantOf(nodeId: UUID, ancestorId: UUID, ancestorsByNode: Map>): boolean {
+ return !!ancestorsByNode.get(nodeId)?.has(ancestorId);
+}
+
+export function getAncestorsByNode(treeModel: NetworkModificationTreeModel | null): Map> {
+ const ancestorsByNode = new Map>();
+ if (!treeModel) {
+ return ancestorsByNode;
+ }
+ const parentByNode = new Map(
+ treeModel.treeNodes.map((node) => [node.id, node.parentId as UUID | undefined])
+ );
+
+ function ancestorsOf(nodeId: UUID): Set {
+ const known = ancestorsByNode.get(nodeId);
+ if (known) {
+ return known;
+ }
+ const ancestors = new Set();
+ // registered before walking up, so each chain is walked once
+ ancestorsByNode.set(nodeId, ancestors);
+ const parentId = parentByNode.get(nodeId);
+ if (parentId) {
+ ancestors.add(parentId);
+ ancestorsOf(parentId).forEach((ancestor) => ancestors.add(ancestor));
+ }
+ return ancestors;
+ }
+
+ treeModel.treeNodes.forEach((node) => ancestorsOf(node.id));
+ return ancestorsByNode;
+}
diff --git a/src/components/grid-layout/cards/diagrams/singleLineDiagram/single-line-diagram-content.tsx b/src/components/grid-layout/cards/diagrams/singleLineDiagram/single-line-diagram-content.tsx
index d7d7655ba9..53392f501d 100644
--- a/src/components/grid-layout/cards/diagrams/singleLineDiagram/single-line-diagram-content.tsx
+++ b/src/components/grid-layout/cards/diagrams/singleLineDiagram/single-line-diagram-content.tsx
@@ -27,7 +27,7 @@ import {
SLDMetadata,
} from '@powsybl/network-viewer';
import { isNodeReadOnly } from '../../../../graph/util/model-functions';
-import { useIsAnyNodeBuilding } from '../../../../utils/is-any-node-building-hook';
+import { useIsEditBlocked } from 'components/node-activity/hooks/use-node-activity';
import { darken, lighten, Theme, useTheme } from '@mui/material/styles';
import {
ComputingType,
@@ -124,7 +124,7 @@ const SingleLineDiagramContent = memo(function SingleLineDiagramContent(props: S
const currentRootNetworkUuid = useSelector((state: AppState) => state.currentRootNetworkUuid);
const [modificationInProgress, setModificationInProgress] = useState(false);
- const isAnyNodeBuilding = useIsAnyNodeBuilding();
+ const isEditBlocked = useIsEditBlocked(currentNode?.id);
const [locallySwitchedBreaker, setLocallySwitchedBreaker] = useState();
const [shouldDisplayTooltip, setShouldDisplayTooltip] = useState(false);
const [equipmentPopoverAnchorEl, setEquipmentPopoverAnchorEl] = useState(null);
@@ -361,7 +361,7 @@ const SingleLineDiagramContent = memo(function SingleLineDiagramContent(props: S
useLayoutEffect(() => {
if (svg && svgRef.current) {
const isReadyForInteraction =
- !computationStarting && !isAnyNodeBuilding && !modificationInProgress && !loadingState;
+ !computationStarting && !isEditBlocked && !modificationInProgress && !loadingState;
const diagramViewer = new SingleLineDiagramViewer(
svgRef.current, //container
@@ -425,7 +425,7 @@ const SingleLineDiagramContent = memo(function SingleLineDiagramContent(props: S
svg,
svgMetadata,
currentNode,
- isAnyNodeBuilding,
+ isEditBlocked,
showEquipmentMenu,
showBusMenu,
isDeveloperMode,
diff --git a/src/components/menus/base-equipment-menu.tsx b/src/components/menus/base-equipment-menu.tsx
index 144216659a..5e67bbe444 100644
--- a/src/components/menus/base-equipment-menu.tsx
+++ b/src/components/menus/base-equipment-menu.tsx
@@ -12,10 +12,8 @@ import ListItemIcon from '@mui/material/ListItemIcon';
import TableChartIcon from '@mui/icons-material/TableChart';
import DeleteIcon from '@mui/icons-material/Delete';
import { useIntl } from 'react-intl';
-import { useSelector } from 'react-redux';
import { useNameOrId } from '../utils/equipmentInfosHandler';
import { getCommonEquipmentType } from 'components/grid-layout/cards/diagrams/diagram-utils';
-import { isNodeReadOnly } from '../graph/util/model-functions';
import {
CustomMenuItem,
CustomNestedMenuItem,
@@ -24,7 +22,7 @@ import {
type ExtendedEquipmentType,
type MuiStyles,
} from '@gridsuite/commons-ui';
-import { AppState } from 'redux/reducer.type';
+import { useCanModifyEquipment } from './use-can-modify-equipment';
const styles = {
menuItem: {
@@ -80,7 +78,7 @@ const DeleteEquipmentItem = ({
itemText: string;
handleDeleteEquipment: HandleDeleteEquipment;
}) => {
- const currentNode = useSelector((state: AppState) => state.currentTreeNode);
+ const canModifyEquipment = useCanModifyEquipment();
return (
@@ -115,7 +113,7 @@ const ModifyEquipmentItem = ({
itemText: string;
handleOpenModificationDialog: HandleOpenModificationDialog;
}) => {
- const currentNode = useSelector((state: AppState) => state.currentTreeNode);
+ const canModifyEquipment = useCanModifyEquipment();
return (
@@ -155,13 +153,13 @@ const ItemViewInForm = ({
equipmentSubstype: ExtendedEquipmentType | null
) => void;
}) => {
- const currentNode = useSelector((state: AppState) => state.currentTreeNode);
+ const canModifyEquipment = useCanModifyEquipment();
return (
handleOpenModificationDialog(equipmentId, equipmentType, equipmentSubtype)}
- disabled={isNodeReadOnly(currentNode)}
+ disabled={!canModifyEquipment}
>
diff --git a/src/components/menus/bus-menu.tsx b/src/components/menus/bus-menu.tsx
index 8447d312e1..e7dc4477e6 100644
--- a/src/components/menus/bus-menu.tsx
+++ b/src/components/menus/bus-menu.tsx
@@ -8,11 +8,10 @@
import { ListItemIcon, ListItemText, Menu, Typography } from '@mui/material';
import BoltIcon from '@mui/icons-material/Bolt';
import { FormattedMessage } from 'react-intl';
-import { FunctionComponent, MouseEvent as ReactMouseEvent, useCallback, useEffect, useMemo, useState } from 'react';
-import { isNodeBuilt, isNodeReadOnly } from 'components/graph/util/model-functions';
+import { FunctionComponent, MouseEvent as ReactMouseEvent, useCallback, useEffect, useState } from 'react';
import { useSelector } from 'react-redux';
import { AppState } from 'redux/reducer.type';
-import { useIsAnyNodeBuilding } from 'components/utils/is-any-node-building-hook';
+import { useCanModifyEquipment } from './use-can-modify-equipment';
import { EQUIPMENT_INFOS_TYPES } from '../utils/equipment-types';
import { getEventType } from '../dialogs/dynamicsimulation/event/model/event.model';
import DynamicSimulationEventMenuItem from './dynamic-simulation/dynamic-simulation-event-menu-item';
@@ -82,11 +81,7 @@ export const BusMenu: FunctionComponent = ({
const currentNode = useSelector((state: AppState) => state.currentTreeNode);
const currentRootNetworkUuid = useSelector((state: AppState) => state.currentRootNetworkUuid);
const studyUuid = useSelector((state: AppState) => state.studyUuid);
- const isAnyNodeBuilding = useIsAnyNodeBuilding();
- const isNodeEditable = useMemo(
- () => isNodeBuilt(currentNode) && !isNodeReadOnly(currentNode) && !isAnyNodeBuilding,
- [currentNode, isAnyNodeBuilding]
- );
+ const isNodeEditable = useCanModifyEquipment();
useEffect(() => {
fetchNetworkElementInfos(
diff --git a/src/components/menus/equipment-menu.tsx b/src/components/menus/equipment-menu.tsx
index b79287e53b..46cdd979d6 100644
--- a/src/components/menus/equipment-menu.tsx
+++ b/src/components/menus/equipment-menu.tsx
@@ -5,14 +5,11 @@
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
-import { useCallback, useMemo } from 'react';
+import { useCallback } from 'react';
import Menu from '@mui/material/Menu';
import { getEventType } from '../dialogs/dynamicsimulation/event/model/event.model';
-import { useSelector } from 'react-redux';
-import { useIsAnyNodeBuilding } from '../utils/is-any-node-building-hook';
-import { isNodeBuilt, isNodeReadOnly } from '../graph/util/model-functions';
+import { useCanModifyEquipment } from './use-can-modify-equipment';
import DynamicSimulationEventMenuItem from './dynamic-simulation/dynamic-simulation-event-menu-item';
-import { AppState } from 'redux/reducer.type';
import {
type EquipmentType,
type ExtendedEquipmentType,
@@ -49,13 +46,7 @@ const withEquipmentMenu =
}: MenuBranchProps) => {
const [isDeveloperMode] = useParameterState(PARAM_DEVELOPER_MODE);
- // to check is node editable
- const currentNode = useSelector((state: AppState) => state.currentTreeNode);
- const isAnyNodeBuilding = useIsAnyNodeBuilding();
- const isNodeEditable = useMemo(
- () => isNodeBuilt(currentNode) && !isNodeReadOnly(currentNode) && !isAnyNodeBuilding,
- [currentNode, isAnyNodeBuilding]
- );
+ const isNodeEditable = useCanModifyEquipment();
const handleOpenDynamicSimulationEventDialog = useCallback(
(equipmentId: string, equipmentType: EquipmentType, dialogTitle: string) => {
diff --git a/src/components/menus/operating-status-menu.tsx b/src/components/menus/operating-status-menu.tsx
index e761a2683a..0c8dda21ef 100644
--- a/src/components/menus/operating-status-menu.tsx
+++ b/src/components/menus/operating-status-menu.tsx
@@ -5,7 +5,7 @@
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
-import { useCallback, useEffect, useMemo, useState } from 'react';
+import { useCallback, useEffect, useState } from 'react';
import Menu from '@mui/material/Menu';
import ListItemText from '@mui/material/ListItemText';
import Typography from '@mui/material/Typography';
@@ -30,8 +30,7 @@ import {
useSnackMessage,
PARAM_DEVELOPER_MODE,
} from '@gridsuite/commons-ui';
-import { isNodeBuilt, isNodeReadOnly } from '../graph/util/model-functions';
-import { useIsAnyNodeBuilding } from '../utils/is-any-node-building-hook';
+import { useCanModifyEquipment } from './use-can-modify-equipment';
import { BRANCH_SIDE } from '../network/constants';
import { EQUIPMENT_INFOS_TYPES } from '../utils/equipment-types';
import {
@@ -97,7 +96,6 @@ const withOperatingStatusMenu =
}: MenuBranchProps) => {
const intl = useIntl();
const { snackError } = useSnackMessage();
- const isAnyNodeBuilding = useIsAnyNodeBuilding();
const { getNameOrId } = useNameOrId();
const [equipmentInfos, setEquipmentInfos] = useState(null);
@@ -127,20 +125,8 @@ const withOperatingStatusMenu =
}
}, [studyUuid, currentNode?.id, currentRootNetworkUuid, equipmentType, equipment?.id]);
- const isNodeEditable = useMemo(
- function () {
- if (currentNode) {
- return (
- equipmentInfos &&
- isNodeBuilt(currentNode) &&
- !isNodeReadOnly(currentNode) &&
- !isAnyNodeBuilding &&
- !modificationInProgress
- );
- }
- },
- [equipmentInfos, currentNode, isAnyNodeBuilding, modificationInProgress]
- );
+ const isCurrentNodeEditable = useCanModifyEquipment();
+ const isNodeEditable = !!equipmentInfos && isCurrentNodeEditable && !modificationInProgress;
function handleError(error: Error, translationKey: string) {
snackWithFallback(snackError, error, { headerId: getTranslationKey(translationKey) });
diff --git a/src/components/menus/use-can-modify-equipment.ts b/src/components/menus/use-can-modify-equipment.ts
new file mode 100644
index 0000000000..e7fea7b39e
--- /dev/null
+++ b/src/components/menus/use-can-modify-equipment.ts
@@ -0,0 +1,17 @@
+/**
+ * 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 { useSelector } from 'react-redux';
+import { isNodeBuilt, isNodeReadOnly } from 'components/graph/util/model-functions';
+import { useIsEditBlocked } from 'components/node-activity/hooks/use-node-activity';
+import { AppState } from 'redux/reducer.type';
+
+export function useCanModifyEquipment(): boolean {
+ const currentNode = useSelector((state: AppState) => state.currentTreeNode);
+ const isEditBlocked = useIsEditBlocked(currentNode?.id);
+ return isNodeBuilt(currentNode) && !isNodeReadOnly(currentNode) && !isEditBlocked;
+}
diff --git a/src/components/network-modification-tree-pane-event-handlers.ts b/src/components/network-modification-tree-pane-event-handlers.ts
index b6e8049450..ed3029dc31 100644
--- a/src/components/network-modification-tree-pane-event-handlers.ts
+++ b/src/components/network-modification-tree-pane-event-handlers.ts
@@ -21,7 +21,6 @@ import {
networkModificationTreeNodesRemoved,
networkModificationTreeNodesUpdated,
reorderNetworkModificationTreeNodes,
- removeNotificationByNode,
resetLogsFilter,
resetLogsPagination,
} from '../redux/actions';
@@ -133,7 +132,6 @@ export const handleTreeModelUpdate = (
if (eventData.headers.rootNetworkUuid !== rootNetworkUuid) break;
fetchAndDispatchUpdatedNodes(dispatch, studyUuid, rootNetworkUuid, eventData.headers.nodes);
if (currentNodeId && eventData.headers.nodes.includes(currentNodeId)) {
- dispatch(removeNotificationByNode([currentNodeId]));
dispatch(resetLogsFilter());
dispatch(resetLogsPagination());
}
@@ -163,9 +161,6 @@ export const handleTreeModelUpdate = (
break;
case NotificationType.NODES_UPDATED:
fetchAndDispatchUpdatedNodes(dispatch, studyUuid, rootNetworkUuid, eventData.headers.nodes);
- if (currentNodeId && eventData.headers.nodes.includes(currentNodeId)) {
- dispatch(removeNotificationByNode([currentNodeId]));
- }
break;
case NotificationType.NODE_EDITED:
fetchAndDispatchUpdatedNodes(dispatch, studyUuid, rootNetworkUuid, [eventData.headers.node]);
diff --git a/src/components/network-modification-tree-pane.jsx b/src/components/network-modification-tree-pane.jsx
index 318bcce08d..fdb2e18f5a 100644
--- a/src/components/network-modification-tree-pane.jsx
+++ b/src/components/network-modification-tree-pane.jsx
@@ -34,12 +34,7 @@ import { buildNode, getUniqueNodeName, unbuildNode } from '../services/study/ind
import { RestoreNodesDialog } from './dialogs/restore-node-dialog';
import NetworkModificationNodeDialog from './graph/menus/network-modifications/network-modification-node-dialog';
import { CopyType } from './network-modification.type';
-import {
- NodeSequenceType,
- NotificationType,
- parseEventData,
- PENDING_MODIFICATION_NOTIFICATION_TYPES,
-} from 'types/notification-types';
+import { NodeSequenceType, NotificationType, parseEventData } from 'types/notification-types';
import useExportSubscription from '../hooks/use-export-subscription';
import { exportNetworkFile } from '../services/study/network.js';
import { useCopiedNodes } from 'hooks/copy-paste/use-copied-nodes';
@@ -94,15 +89,6 @@ export const NetworkModificationTreePane = ({ panelId, studyUuid, currentRootNet
break;
}
- case NotificationType.SUBTREE_CREATED: {
- invalidateClipboardIfImpacted(
- [eventData.headers.parentNode],
- nodeSelectionForCopyRef.current,
- resetNodeClipboard
- );
- break;
- }
-
case NotificationType.NODE_MOVED:
case NotificationType.SUBTREE_MOVED: {
invalidateClipboardIfImpacted(
@@ -131,17 +117,20 @@ export const NetworkModificationTreePane = ({ panelId, studyUuid, currentRootNet
);
break;
}
- //creating, updating or deleting modifications must invalidate the node clipboard
- default: {
- if (PENDING_MODIFICATION_NOTIFICATION_TYPES.includes(eventData.headers.updateType)) {
- invalidateClipboardIfImpacted(
- [eventData.headers.parentNode],
- nodeSelectionForCopyRef.current,
- resetNodeClipboard
- );
- }
+ //a subtree insertion, and creating, updating or deleting modifications, all invalidate
+ //the node clipboard through the parent node
+ case NotificationType.SUBTREE_CREATED:
+ case NotificationType.MODIFICATIONS_UPDATE_FINISHED:
+ case NotificationType.MODIFICATIONS_DELETE_FINISHED: {
+ invalidateClipboardIfImpacted(
+ [eventData.headers.parentNode],
+ nodeSelectionForCopyRef.current,
+ resetNodeClipboard
+ );
break;
}
+ default:
+ break;
}
},
[studyUuid, resetNodeClipboard]
diff --git a/src/components/node-activity/hooks/use-node-activity-sync.ts b/src/components/node-activity/hooks/use-node-activity-sync.ts
new file mode 100644
index 0000000000..1bc8827cff
--- /dev/null
+++ b/src/components/node-activity/hooks/use-node-activity-sync.ts
@@ -0,0 +1,55 @@
+/**
+ * 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 { NotificationsUrlKeys, useNotificationsListener } from '@gridsuite/commons-ui';
+import type { UUID } from 'node:crypto';
+import { useCallback, useEffect, useRef } from 'react';
+import { useDispatch } from 'react-redux';
+import { setNodeActivities } from 'redux/actions';
+import { fetchNodeActivities } from 'services/study/node-activities';
+import type { NodeActivity } from '../types/node-activity.type';
+import {
+ type CommonStudyEventData,
+ isNodeActivitiesUpdatedNotification,
+ parseEventData,
+} from 'types/notification-types';
+
+export function useNodeActivitySync(studyUuid: UUID | null) {
+ const dispatch = useDispatch();
+ const abortControllerRef = useRef(undefined);
+
+ useEffect(() => {
+ if (!studyUuid) {
+ return;
+ }
+ const abortController = new AbortController();
+ abortControllerRef.current = abortController;
+ fetchNodeActivities(studyUuid, abortController.signal)
+ .then((activities) => dispatch(setNodeActivities(activities)))
+ .catch((error) => {
+ if (!abortController.signal.aborted) {
+ console.error('Failed to fetch node activities', error);
+ }
+ });
+ return () => abortController.abort();
+ }, [studyUuid, dispatch]);
+
+ const handleNodeActivitiesNotification = useCallback(
+ (event: MessageEvent) => {
+ const eventData = parseEventData(event);
+ if (eventData && isNodeActivitiesUpdatedNotification(eventData)) {
+ abortControllerRef.current?.abort();
+ dispatch(setNodeActivities(JSON.parse(eventData.payload) as NodeActivity[]));
+ }
+ },
+ [dispatch]
+ );
+
+ useNotificationsListener(NotificationsUrlKeys.STUDY, {
+ listenerCallbackMessage: handleNodeActivitiesNotification,
+ });
+}
diff --git a/src/components/node-activity/hooks/use-node-activity.ts b/src/components/node-activity/hooks/use-node-activity.ts
new file mode 100644
index 0000000000..210873e666
--- /dev/null
+++ b/src/components/node-activity/hooks/use-node-activity.ts
@@ -0,0 +1,100 @@
+/**
+ * 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 { createSelector } from '@reduxjs/toolkit';
+import type { UUID } from 'node:crypto';
+import { useSelector } from 'react-redux';
+import { getAncestorsByNode, isStatusBuilt } from 'components/graph/util/model-functions';
+import {
+ findActivityOnNode,
+ findConflictingActivity,
+ REQUESTED_ACTIVITY,
+ type RequestedActivity,
+} from '../utils/node-activity-conflicts';
+import {
+ NetworkModificationNodeType,
+ NodeType,
+ type ReactFlowModificationNodeData,
+} from 'components/graph/tree-node.type';
+import { AppState } from 'redux/reducer.type';
+import { NodeActivityLabel, type NodeActivity } from '../types/node-activity.type';
+
+type NodeBuildData = Pick;
+
+function isSecurityNode(nodeData: NodeBuildData | undefined): boolean {
+ return nodeData?.nodeType === NetworkModificationNodeType.SECURITY;
+}
+
+const selectAncestorsByNode = createSelector(
+ (state: AppState) => state.networkModificationTreeModel,
+ getAncestorsByNode
+);
+
+const selectRootNodeId = createSelector(
+ (state: AppState) => state.networkModificationTreeModel,
+ (treeModel) => treeModel?.treeNodes.find((treeNode) => treeNode.type === NodeType.ROOT)?.id
+);
+
+function useBlockingActivity(nodeId: UUID | null | undefined, requested: RequestedActivity): NodeActivity | undefined {
+ return useSelector((state: AppState) => {
+ if (!nodeId || state.nodeActivities.length === 0) {
+ return undefined;
+ }
+ return findConflictingActivity(state.nodeActivities, selectAncestorsByNode(state), {
+ nodeId,
+ rootNetworkId: requested.affectsAllRootNetworks ? null : state.currentRootNetworkUuid,
+ invalidatesChildren: requested.invalidatesChildren,
+ });
+ });
+}
+
+export function useActivityBlockingNode(nodeId: UUID | null | undefined): NodeActivity | undefined {
+ return useBlockingActivity(nodeId, REQUESTED_ACTIVITY.ANY);
+}
+
+export function useIsEditBlocked(nodeId: UUID | null | undefined): boolean {
+ return !!useBlockingActivity(nodeId, REQUESTED_ACTIVITY.EDIT_MODIFICATIONS);
+}
+
+export function useIsEventEditBlocked(nodeId: UUID | null | undefined): boolean {
+ return !!useBlockingActivity(nodeId, REQUESTED_ACTIVITY.EDIT_EVENTS);
+}
+
+export function useIsBuildBlocked(nodeId: UUID | null | undefined, nodeData: NodeBuildData | undefined): boolean {
+ const willUnbuildChildren = isSecurityNode(nodeData) && isStatusBuilt(nodeData?.localBuildStatus);
+ return !!useBlockingActivity(
+ nodeId,
+ willUnbuildChildren ? REQUESTED_ACTIVITY.UNBUILD_CHILDREN : REQUESTED_ACTIVITY.BUILD
+ );
+}
+
+export function useIsComputationBlocked(nodeId: UUID | null | undefined): boolean {
+ return !!useBlockingActivity(nodeId, REQUESTED_ACTIVITY.COMPUTE);
+}
+
+/** A loadflow on a security node writes solved values onto its variant, so its children are invalidated. */
+export function useIsLoadFlowBlocked(nodeId: UUID | null | undefined, nodeData: NodeBuildData | undefined): boolean {
+ return !!useBlockingActivity(
+ nodeId,
+ isSecurityNode(nodeData) ? REQUESTED_ACTIVITY.COMPUTE_AND_UNBUILD_CHILDREN : REQUESTED_ACTIVITY.COMPUTE
+ );
+}
+
+export function useIsUnbuildAllBlocked(): boolean {
+ const rootNodeId = useSelector(selectRootNodeId);
+ return !!useBlockingActivity(rootNodeId, REQUESTED_ACTIVITY.UNBUILD_ALL);
+}
+
+export function useNodeActivity(nodeId: UUID | null | undefined): NodeActivity | undefined {
+ return useSelector((state: AppState) =>
+ nodeId ? findActivityOnNode(state.nodeActivities, nodeId, state.currentRootNetworkUuid) : undefined
+ );
+}
+
+export function useIsNodeUpdating(nodeId: UUID | null | undefined): boolean {
+ return useNodeActivity(nodeId)?.label === NodeActivityLabel.UPDATING;
+}
diff --git a/src/components/node-activity/node-activity-display.tsx b/src/components/node-activity/node-activity-display.tsx
new file mode 100644
index 0000000000..71b0b60697
--- /dev/null
+++ b/src/components/node-activity/node-activity-display.tsx
@@ -0,0 +1,86 @@
+/**
+ * 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 { Box, Chip, CircularProgress, type SxProps, type Theme, Tooltip, type TooltipProps } from '@mui/material';
+import { type MuiStyles } from '@gridsuite/commons-ui';
+import { FormattedMessage, useIntl } from 'react-intl';
+import { useSelector } from 'react-redux';
+import { getNetworkModificationNode } from 'components/graph/util/model-functions';
+import { useActivityBlockingNode } from './hooks/use-node-activity';
+import type { UUID } from 'node:crypto';
+import { AppState } from 'redux/reducer.type';
+import { nodeActivityInProgressId, nodeActivityLabelId, type NodeActivity } from './types/node-activity.type';
+
+const styles = {
+ rootNetworkLine: { display: 'flex', alignItems: 'center', gap: 0.5 },
+} as const satisfies MuiStyles;
+
+function useOtherRootNetworkTag(activity: NodeActivity): string | undefined {
+ return useSelector((state: AppState) =>
+ activity.rootNetworkId && activity.rootNetworkId !== state.currentRootNetworkUuid
+ ? state.rootNetworks.find((rootNetwork) => rootNetwork.rootNetworkUuid === activity.rootNetworkId)?.tag
+ : undefined
+ );
+}
+
+export function NodeActivityChip({ activity }: Readonly<{ activity: NodeActivity }>) {
+ return (
+ }
+ label={}
+ />
+ );
+}
+
+function NodeActivityDetails({ activity }: Readonly<{ activity: NodeActivity }>) {
+ const intl = useIntl();
+ const treeModel = useSelector((state: AppState) => state.networkModificationTreeModel);
+ const otherRootNetworkTag = useOtherRootNetworkTag(activity);
+ const nodeName = getNetworkModificationNode(treeModel, activity.nodeId)?.data?.label ?? '';
+
+ return (
+
+ {intl.formatMessage({ id: 'nodeActivityNode' }, { nodeName })}
+ {intl.formatMessage({ id: nodeActivityInProgressId(activity.label) })}
+ {otherRootNetworkTag && (
+
+ }}
+ />
+
+ )}
+
+ );
+}
+
+type BlockedByActivityIndicatorProps = {
+ nodeId: UUID | null | undefined;
+ ownActivity: NodeActivity | undefined;
+ size: number;
+ sx?: SxProps;
+} & Pick;
+
+export function BlockedByActivityIndicator({
+ nodeId,
+ ownActivity,
+ size,
+ sx,
+ ...tooltipProps
+}: Readonly) {
+ const blockingActivity = useActivityBlockingNode(nodeId);
+
+ if (ownActivity || !blockingActivity) {
+ return null;
+ }
+ return (
+ } {...tooltipProps}>
+
+
+ );
+}
diff --git a/src/components/node-activity/types/node-activity.type.ts b/src/components/node-activity/types/node-activity.type.ts
new file mode 100644
index 0000000000..2579e86817
--- /dev/null
+++ b/src/components/node-activity/types/node-activity.type.ts
@@ -0,0 +1,33 @@
+/**
+ * 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 type { UUID } from 'node:crypto';
+
+export enum NodeActivityLabel {
+ UPDATING = 'UPDATING',
+ DELETING = 'DELETING',
+ BUILDING = 'BUILDING',
+ UNBUILDING = 'UNBUILDING',
+ COMPUTING = 'COMPUTING',
+}
+
+type MessageId = keyof (typeof import('translations/messages-en'))['default'];
+
+export function nodeActivityLabelId(label: NodeActivityLabel): MessageId {
+ return `nodeActivity.${label}`;
+}
+
+export function nodeActivityInProgressId(label: NodeActivityLabel): MessageId {
+ return `nodeActivityInProgress.${label}`;
+}
+
+export type NodeActivity = {
+ nodeId: UUID;
+ rootNetworkId: UUID | null;
+ label: NodeActivityLabel;
+ invalidatesChildren: boolean;
+};
diff --git a/src/components/node-activity/utils/node-activity-conflicts.ts b/src/components/node-activity/utils/node-activity-conflicts.ts
new file mode 100644
index 0000000000..bca9ffa9bd
--- /dev/null
+++ b/src/components/node-activity/utils/node-activity-conflicts.ts
@@ -0,0 +1,66 @@
+/**
+ * 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 type { UUID } from 'node:crypto';
+import { isDescendantOf } from 'components/graph/util/model-functions';
+import type { NodeActivity } from '../types/node-activity.type';
+
+/**
+ * Mirrors only needed NodeActivityType in study-server : keep the flags in sync with it.
+ */
+export const REQUESTED_ACTIVITY = {
+ BUILD: { invalidatesChildren: false, affectsAllRootNetworks: false },
+ UNBUILD_CHILDREN: { invalidatesChildren: true, affectsAllRootNetworks: false },
+ UNBUILD_ALL: { invalidatesChildren: true, affectsAllRootNetworks: true },
+ COMPUTE: { invalidatesChildren: false, affectsAllRootNetworks: false },
+ COMPUTE_AND_UNBUILD_CHILDREN: { invalidatesChildren: true, affectsAllRootNetworks: false },
+ EDIT_MODIFICATIONS: { invalidatesChildren: true, affectsAllRootNetworks: true },
+ EDIT_EVENTS: { invalidatesChildren: false, affectsAllRootNetworks: true },
+ /** Not a server type : it is here to ask whether a node is usable at all. */
+ ANY: { invalidatesChildren: true, affectsAllRootNetworks: true },
+} as const;
+
+export type RequestedActivity = (typeof REQUESTED_ACTIVITY)[keyof typeof REQUESTED_ACTIVITY];
+
+type Activity = Pick;
+
+/** A null rootNetworkId means it affects every root network */
+function hasSameRootNetwork(a: UUID | null, b: UUID | null): boolean {
+ return a === null || b === null || a === b;
+}
+
+/** 'a' unbuilds its subtree, and 'b' sits inside it. */
+function invalidates(a: Activity, b: Activity, ancestorsByNode: Map>): boolean {
+ return a.invalidatesChildren && isDescendantOf(b.nodeId, a.nodeId, ancestorsByNode);
+}
+
+function hasConflictWith(activity: Activity, other: Activity, ancestorsByNode: Map>): boolean {
+ return (
+ hasSameRootNetwork(activity.rootNetworkId, other.rootNetworkId) &&
+ (activity.nodeId === other.nodeId ||
+ invalidates(activity, other, ancestorsByNode) ||
+ invalidates(other, activity, ancestorsByNode))
+ );
+}
+
+export function findConflictingActivity(
+ activities: NodeActivity[],
+ ancestorsByNode: Map>,
+ requested: Activity
+): NodeActivity | undefined {
+ return activities.find((activity) => hasConflictWith(activity, requested, ancestorsByNode));
+}
+
+export function findActivityOnNode(
+ activities: NodeActivity[],
+ nodeId: UUID,
+ rootNetworkUuid: UUID | null
+): NodeActivity | undefined {
+ return activities.find(
+ (activity) => activity.nodeId === nodeId && hasSameRootNetwork(activity.rootNetworkId, rootNetworkUuid)
+ );
+}
diff --git a/src/components/parameters-tabs.tsx b/src/components/parameters-tabs.tsx
index c28f74b054..ef80ca7efd 100644
--- a/src/components/parameters-tabs.tsx
+++ b/src/components/parameters-tabs.tsx
@@ -63,6 +63,7 @@ import {
} from 'services/study/short-circuit-analysis';
import { useGetPccMinParameters } from './dialogs/parameters/use-get-pcc-min-parameters';
import { fetchContingencyCount } from '../services/study';
+import { useIsNodeUpdating } from 'components/node-activity/hooks/use-node-activity';
import {
fetchDynamicMarginCalculationParameters,
updateDynamicMarginCalculationParameters,
@@ -108,6 +109,7 @@ const ParametersTabs: FunctionComponent = () => {
const currentNodeBuildStatus = useSelector((state: AppState) => state.currentTreeNode?.data.globalBuildStatus);
const currentRootNetworkUuid = useSelector((state: AppState) => state.currentRootNetworkUuid);
const isTreeModelUpToDate = useSelector((state: AppState) => state.isNetworkModificationTreeModelUpToDate);
+ const isNodeUpdating = useIsNodeUpdating(currentNode?.id);
const [tabValue, setTabValue] = useState(TAB_VALUES.networkVisualizationsParams);
const [nextTabValue, setNextTabValue] = useState(undefined);
const isDirtyComputationParameters = useSelector((state: AppState) => state.isDirtyComputationParameters);
@@ -353,10 +355,7 @@ const ParametersTabs: FunctionComponent = () => {
studyUuid={studyUuid}
parametersBackend={securityAnalysisParametersBackend}
fetchContingencyCount={fetchContingencyCountBackend}
- isBuiltCurrentNode={
- currentNodeBuildStatus !== BuildStatus.NOT_BUILT &&
- currentNodeBuildStatus !== BuildStatus.BUILDING
- }
+ isBuiltCurrentNode={!isNodeUpdating && currentNodeBuildStatus !== BuildStatus.NOT_BUILT}
setHaveDirtyFields={setDirtyFields}
isDeveloperMode={isDeveloperMode}
/>
@@ -372,7 +371,9 @@ const ParametersTabs: FunctionComponent = () => {
globalBuildStatus={
// to avoid bad current node globalBuildStatus at root network change
// pass not built status by defaut to avoid unwanted fetch
- isTreeModelUpToDate ? currentNode?.data?.globalBuildStatus : BuildStatus.NOT_BUILT
+ isTreeModelUpToDate && !isNodeUpdating
+ ? currentNode?.data?.globalBuildStatus
+ : BuildStatus.NOT_BUILT
}
isRootNode={currentNode?.type === NodeType.ROOT}
isDeveloperMode={isDeveloperMode}
@@ -464,6 +465,7 @@ const ParametersTabs: FunctionComponent = () => {
securityAnalysisParametersBackend,
fetchContingencyCountBackend,
currentNodeBuildStatus,
+ isNodeUpdating,
currentNodeUuid,
currentRootNetworkUuid,
sensitivityAnalysisBackend,
diff --git a/src/components/run-button-container.jsx b/src/components/run-button-container.jsx
index 0d2b395619..d74a773b66 100644
--- a/src/components/run-button-container.jsx
+++ b/src/components/run-button-container.jsx
@@ -26,6 +26,11 @@ import {
RunningStatus,
} from '@gridsuite/commons-ui';
import RunButton from './run-button';
+import {
+ LOAD_FLOW_RUNNABLES,
+ LOAD_FLOW_WITH_RATIO_TAP_CHANGERS,
+ LOAD_FLOW_WITHOUT_RATIO_TAP_CHANGERS,
+} from './run-button.constant';
import { startSensitivityAnalysis, stopSensitivityAnalysis } from '../services/study/sensitivity-analysis';
import {
fetchDynamicSimulationProvider,
@@ -46,6 +51,8 @@ import {
} from '../services/study/dynamic-security-analysis';
import { useParameterState } from './dialogs/parameters/use-parameters-state';
import { isSecurityModificationNode } from './graph/tree-node.type';
+import { isNodeBuilt, isNodeReadOnly } from './graph/util/model-functions';
+import { useIsComputationBlocked, useIsLoadFlowBlocked } from 'components/node-activity/hooks/use-node-activity';
import { PaginationType } from 'types/custom-aggrid-types';
import { usePaginationReset } from 'hooks/use-pagination-selector';
import { useLogsPaginationResetByType } from './report-viewer/use-logs-pagination';
@@ -64,8 +71,11 @@ const COMPUTATIONS_WITH_PAGINATION = [
ComputingType.SHORT_CIRCUIT,
];
-export function RunButtonContainer({ studyUuid, currentNode, currentRootNetworkUuid, disabled }) {
+export function RunButtonContainer({ studyUuid, currentNode, currentRootNetworkUuid }) {
const loadFlowStatus = useSelector((state) => state.computingStatus[ComputingType.LOAD_FLOW]);
+ const isNodeRunnable = isNodeBuilt(currentNode) && !isNodeReadOnly(currentNode);
+ const isComputationBlocked = useIsComputationBlocked(currentNode?.id);
+ const isLoadFlowBlocked = useIsLoadFlowBlocked(currentNode?.id, currentNode?.data);
const loadFlowStatusInfos = useSelector((state) => state.computingStatusParameters[ComputingType.LOAD_FLOW]);
// only one of those type can be different from idle, depending on loadFlowStatusInfos.withRatioTapChangers
@@ -105,8 +115,6 @@ export function RunButtonContainer({ studyUuid, currentNode, currentRootNetworkU
const [isDeveloperMode] = useParameterState(PARAM_DEVELOPER_MODE);
- const isModificationsInProgress = useSelector((state) => state.isModificationsInProgress);
-
const securityAnalysisAvailability = useOptionalServiceStatus(OptionalServicesNames.SecurityAnalysis);
const sensitivityAnalysisUnavailability = useOptionalServiceStatus(OptionalServicesNames.SensitivityAnalysis);
@@ -254,7 +262,7 @@ export function RunButtonContainer({ studyUuid, currentNode, currentRootNetworkU
}
return {
- LOAD_FLOW_WITHOUT_RATIO_TAP_CHANGERS: {
+ [LOAD_FLOW_WITHOUT_RATIO_TAP_CHANGERS]: {
messageId: 'LoadFlow',
startComputation() {
// with DynaFlow provider, we need to verify that the current node is a security node before starting the computation.
@@ -270,7 +278,7 @@ export function RunButtonContainer({ studyUuid, currentNode, currentRootNetworkU
actionOnRunnables(ComputingType.LOAD_FLOW, () => stopLoadFlow(studyUuid, currentNode?.id, false));
},
},
- LOAD_FLOW_WITH_RATIO_TAP_CHANGERS: {
+ [LOAD_FLOW_WITH_RATIO_TAP_CHANGERS]: {
messageId: 'LoadFlowWithRatioTapChangers',
startComputation() {
checkForbiddenProvider(studyUuid, ComputingType.LOAD_FLOW, getLoadFlowProvider, [
@@ -523,13 +531,18 @@ export function RunButtonContainer({ studyUuid, currentNode, currentRootNetworkU
subscribeDebug,
]);
+ const canRun = useCallback(
+ (runnableType) => (LOAD_FLOW_RUNNABLES.includes(runnableType) ? !isLoadFlowBlocked : !isComputationBlocked),
+ [isLoadFlowBlocked, isComputationBlocked]
+ );
+
// running status is refreshed more often, so we memoize it apart
const getRunningStatus = useCallback(
(runnableType) => {
switch (runnableType) {
- case 'LOAD_FLOW_WITHOUT_RATIO_TAP_CHANGERS':
+ case LOAD_FLOW_WITHOUT_RATIO_TAP_CHANGERS:
return loadFlowWithoutRatioTapChangersStatus;
- case 'LOAD_FLOW_WITH_RATIO_TAP_CHANGERS':
+ case LOAD_FLOW_WITH_RATIO_TAP_CHANGERS:
return loadFlowWithRatioTapChangersStatus;
case ComputingType.SECURITY_ANALYSIS:
return securityAnalysisStatus;
@@ -571,8 +584,8 @@ export function RunButtonContainer({ studyUuid, currentNode, currentRootNetworkU
// list of visible runnable isn't static
const activeRunnables = useMemo(() => {
return [
- 'LOAD_FLOW_WITH_RATIO_TAP_CHANGERS',
- 'LOAD_FLOW_WITHOUT_RATIO_TAP_CHANGERS',
+ LOAD_FLOW_WITH_RATIO_TAP_CHANGERS,
+ LOAD_FLOW_WITHOUT_RATIO_TAP_CHANGERS,
...(securityAnalysisAvailability === OptionalServicesStatus.Up ? [ComputingType.SECURITY_ANALYSIS] : []),
...(sensitivityAnalysisUnavailability === OptionalServicesStatus.Up
? [ComputingType.SENSITIVITY_ANALYSIS]
@@ -607,15 +620,14 @@ export function RunButtonContainer({ studyUuid, currentNode, currentRootNetworkU
]);
return (
- <>
-
- >
+
);
}
@@ -623,5 +635,4 @@ RunButtonContainer.propTypes = {
studyUuid: PropTypes.string.isRequired,
currentNode: PropTypes.object,
currentRootNetworkUuid: PropTypes.string.isRequired,
- disabled: PropTypes.bool,
};
diff --git a/src/components/run-button.constant.ts b/src/components/run-button.constant.ts
new file mode 100644
index 0000000000..1f26d20e82
--- /dev/null
+++ b/src/components/run-button.constant.ts
@@ -0,0 +1,10 @@
+/**
+ * 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/.
+ */
+
+export const LOAD_FLOW_WITHOUT_RATIO_TAP_CHANGERS = 'LOAD_FLOW_WITHOUT_RATIO_TAP_CHANGERS';
+export const LOAD_FLOW_WITH_RATIO_TAP_CHANGERS = 'LOAD_FLOW_WITH_RATIO_TAP_CHANGERS';
+export const LOAD_FLOW_RUNNABLES = [LOAD_FLOW_WITHOUT_RATIO_TAP_CHANGERS, LOAD_FLOW_WITH_RATIO_TAP_CHANGERS];
diff --git a/src/components/run-button.jsx b/src/components/run-button.jsx
index b4af638b0b..98f188985f 100644
--- a/src/components/run-button.jsx
+++ b/src/components/run-button.jsx
@@ -14,8 +14,9 @@ import { ComputingType, RunningStatus } from '@gridsuite/commons-ui';
import { useSelector } from 'react-redux';
import { SelectOptionsDialog } from '../utils/dialogs';
import { DialogContentText } from '@mui/material';
+import { LOAD_FLOW_RUNNABLES } from './run-button.constant';
-const RunButton = ({ runnables, activeRunnables, getStatus, computationStopped, disabled }) => {
+const RunButton = ({ runnables, activeRunnables, getStatus, computationStopped, disabled, canRun = () => true }) => {
const intl = useIntl();
const isDirtyComputationParameters = useSelector((state) => state.isDirtyComputationParameters);
const [isLaunchingPopupOpen, setIsLaunchingPopupOpen] = useState(false);
@@ -42,22 +43,32 @@ const RunButton = ({ runnables, activeRunnables, getStatus, computationStopped,
}
}
+ // only one computation can run at a time on a node, so we can take the first running one found
+ const runningRunnable = useMemo(
+ () => activeRunnables.find((runnable) => getStatus(runnable) === RunningStatus.RUNNING),
+ [activeRunnables, getStatus]
+ );
+
useEffect(() => {
- if (!activeRunnables.includes(selectedRunnable)) {
+ if (runningRunnable) {
+ // always show the running computation (ex : when switching to a node with a computation already running)
+ setSelectedRunnable(runningRunnable);
+ } else if (!activeRunnables.includes(selectedRunnable)) {
// a computation may become unavailable when developer mode is disabled, then switch on first one
setSelectedRunnable(activeRunnables[0]);
}
- }, [activeRunnables, selectedRunnable, setSelectedRunnable]);
+ }, [runningRunnable, activeRunnables, selectedRunnable]);
const getRunningStatus = useCallback(() => {
return getStatus(selectedRunnable);
}, [selectedRunnable, getStatus]);
function isButtonDisable() {
- if (
- selectedRunnable === 'LOAD_FLOW_WITHOUT_RATIO_TAP_CHANGERS' ||
- selectedRunnable === 'LOAD_FLOW_WITH_RATIO_TAP_CHANGERS'
- ) {
+ if (!canRun(selectedRunnable)) {
+ return true;
+ }
+
+ if (LOAD_FLOW_RUNNABLES.includes(selectedRunnable)) {
// We run once loadflow analysis, as it will always return the same result for one hypothesis
return getRunningStatus() !== RunningStatus.IDLE;
}
@@ -66,8 +77,7 @@ const RunButton = ({ runnables, activeRunnables, getStatus, computationStopped,
// Load flow button's status must be "SUCCEED"
return (
getRunningStatus() === RunningStatus.RUNNING ||
- (getStatus('LOAD_FLOW_WITHOUT_RATIO_TAP_CHANGERS') !== RunningStatus.SUCCEED &&
- getStatus('LOAD_FLOW_WITH_RATIO_TAP_CHANGERS') !== RunningStatus.SUCCEED)
+ !LOAD_FLOW_RUNNABLES.some((runnable) => getStatus(runnable) === RunningStatus.SUCCEED)
);
}
@@ -83,8 +93,7 @@ const RunButton = ({ runnables, activeRunnables, getStatus, computationStopped,
// Load flow button's status must be "SUCCEED"
return (
getRunningStatus() === RunningStatus.RUNNING ||
- (getStatus('LOAD_FLOW_WITHOUT_RATIO_TAP_CHANGERS') !== RunningStatus.SUCCEED &&
- getStatus('LOAD_FLOW_WITH_RATIO_TAP_CHANGERS') !== RunningStatus.SUCCEED)
+ !LOAD_FLOW_RUNNABLES.some((runnable) => getStatus(runnable) === RunningStatus.SUCCEED)
);
}
@@ -155,6 +164,7 @@ RunButton.propTypes = {
getStatus: PropTypes.func.isRequired,
computationStopped: PropTypes.bool.isRequired,
disabled: PropTypes.bool,
+ canRun: PropTypes.func,
};
export default RunButton;
diff --git a/src/components/spreadsheet-view/spreadsheet/spreadsheet-content/equipment-table.tsx b/src/components/spreadsheet-view/spreadsheet/spreadsheet-content/equipment-table.tsx
index d2f62c6ab2..a3d1bfbe90 100644
--- a/src/components/spreadsheet-view/spreadsheet/spreadsheet-content/equipment-table.tsx
+++ b/src/components/spreadsheet-view/spreadsheet/spreadsheet-content/equipment-table.tsx
@@ -13,6 +13,7 @@ import { ColDef, ColumnMovedEvent, GetRowIdParams, GridOptions, RowClassParams,
import { useSelector } from 'react-redux';
import { AgGridReact } from 'ag-grid-react';
import { AppState } from '../../../../redux/reducer.type';
+import { useIsEditBlocked } from 'components/node-activity/hooks/use-node-activity';
import { suppressEventsToPreventEditMode } from '../../../dialogs/commons/utils';
import { CurrentTreeNode, NodeType } from 'components/graph/tree-node.type';
import { CalculationRowType } from '../../types/calculation.type';
@@ -88,7 +89,8 @@ export const EquipmentTable: FunctionComponent = ({
const intl = useIntl();
const studyUuid = useSelector((state: AppState) => state.studyUuid);
- const isEditDisabled = currentNode?.type === NodeType.ROOT || !isDataEditable;
+ const isEditBlocked = useIsEditBlocked(currentNode?.id);
+ const isEditDisabled = currentNode?.type === NodeType.ROOT || !isDataEditable || isEditBlocked;
const { contextMenu, menuItems, openContextMenu, closeContextMenu } = useEquipmentContextMenu({
equipmentType,
diff --git a/src/components/study-container.jsx b/src/components/study-container.jsx
index f35b4a2c02..35baf9b557 100644
--- a/src/components/study-container.jsx
+++ b/src/components/study-container.jsx
@@ -45,6 +45,7 @@ import { getFirstNodeOfType } from './graph/util/model-functions';
import { useAllComputingStatus } from './computing-status/use-all-computing-status';
import { fetchNetworkModificationTree } from '../services/study/tree-subtree';
import { useTreeModelSync } from '../hooks/use-tree-model-sync';
+import { useNodeActivitySync } from 'components/node-activity/hooks/use-node-activity-sync';
import { fetchNetworkExistence, fetchRootNetworkIndexationStatus } from '../services/study/network';
import { fetchStudy, recreateStudyNetwork, reindexAllRootNetwork } from 'services/study/study';
@@ -158,6 +159,7 @@ export function StudyContainer() {
const currentNodeRef = useRef();
const currentRootNetworkUuidRef = useRef();
+ const isNetworkModificationTreeModelUpToDate = useSelector((state) => state.isNetworkModificationTreeModelUpToDate);
useAllComputingStatus(studyUuid, currentNode?.id, currentRootNetworkUuid);
@@ -165,6 +167,7 @@ export function StudyContainer() {
useExportNotification();
useTreeModelSync(studyUuid);
+ useNodeActivitySync(studyUuid);
const displayErrorNotifications = useCallback(
(eventData) => {
@@ -549,7 +552,12 @@ export function StudyContainer() {
return (
diff --git a/src/components/study-pane.jsx b/src/components/study-pane.jsx
index f853775f59..809e4095b9 100644
--- a/src/components/study-pane.jsx
+++ b/src/components/study-pane.jsx
@@ -14,11 +14,12 @@ import { useResetSpreadsheetOnRootNetwork } from './spreadsheet-view/hooks/use-r
import { useNodeAliasesUpdateOnNotification } from './spreadsheet-view/hooks/use-node-aliases-update-on-notification';
import { useSpreadsheetEquipments } from './spreadsheet-view/hooks/use-spreadsheet-equipments';
import { useNodeAliasesLoadFlowStatus } from './spreadsheet-view/hooks/use-node-aliases-loadflow-status';
-import WaitingLoader from './utils/waiting-loader';
import { WorkspaceContainer } from './workspace/core/workspace-container';
import useStudyPath from 'hooks/use-study-path';
import StudyPathBreadcrumbs from './breadcrumbs/study-path-breadcrumbs';
import { CustomAggridReduxProvider } from './custom-aggrid/custom-aggrid-redux-provider';
+import { RunButtonContainer } from './run-button-container';
+import StudyNavigationSyncToggle from './study-navigation-sync-toggle';
const styles = {
paneContainer: {
@@ -31,9 +32,18 @@ const styles = {
position: 'relative',
overflow: 'hidden',
},
+ studyControls: {
+ display: 'flex',
+ alignItems: 'center',
+ gap: 2,
+ },
breadCrumbs: (theme) => ({
backgroundColor: theme.palette.toolbarBackground,
- pl: 1,
+ display: 'flex',
+ alignItems: 'center',
+ justifyContent: 'space-between',
+ gap: 1,
+ p: 1,
}),
'@global': {
'@keyframes spin': {
@@ -48,8 +58,9 @@ const styles = {
};
const StudyPane = () => {
- const isNetworkModificationTreeModelUpToDate = useSelector((state) => state.isNetworkModificationTreeModelUpToDate);
const studyUuid = useSelector((state) => state.studyUuid);
+ const currentNode = useSelector((state) => state.currentTreeNode);
+ const currentRootNetworkUuid = useSelector((state) => state.currentRootNetworkUuid);
const { studyName, parentDirectoriesNames } = useStudyPath(studyUuid);
@@ -67,9 +78,18 @@ const StudyPane = () => {
return (
-
+
+
+ {studyUuid && currentRootNetworkUuid && (
+
+ )}
+
diff --git a/src/components/utils/is-any-node-building-hook.ts b/src/components/utils/is-any-node-building-hook.ts
deleted file mode 100644
index c4c6cba34f..0000000000
--- a/src/components/utils/is-any-node-building-hook.ts
+++ /dev/null
@@ -1,12 +0,0 @@
-/**
- * Copyright (c) 2022, 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 { useSelector } from 'react-redux';
-import { AppState } from 'redux/reducer.type';
-
-export const useIsAnyNodeBuilding = () =>
- useSelector((state: AppState) => state.networkModificationTreeModel?.isAnyNodeBuilding ?? false);
diff --git a/src/components/utils/split-button.tsx b/src/components/utils/split-button.tsx
index 2975ca7546..76c3d72bd9 100644
--- a/src/components/utils/split-button.tsx
+++ b/src/components/utils/split-button.tsx
@@ -224,7 +224,7 @@ const SplitButton = ({
return (
<>
-
+
= ({
const currentNode = useSelector((state: AppState) => state.currentTreeNode);
const currentRootNetworkUuid = useSelector((state: AppState) => state.currentRootNetworkUuid);
const { snackError } = useSnackMessage();
+ const isEditBlocked = useIsEditBlocked(currentNode?.id);
const language = useSelector((state: AppState) => state[PARAM_COMPUTED_LANGUAGE]);
const [disableApplyModifications, setDisableApplyModifications] = useState(false);
@@ -442,7 +444,7 @@ export const VoltageInitResult: FunctionComponent = ({
diff --git a/src/components/workspace/constants/workspace.constants.tsx b/src/components/workspace/constants/workspace.constants.tsx
index 3612edf94f..e957be6812 100644
--- a/src/components/workspace/constants/workspace.constants.tsx
+++ b/src/components/workspace/constants/workspace.constants.tsx
@@ -101,7 +101,7 @@ export const DEFAULT_PANEL_CONFIGS: Record = {
[PanelType.MODIFICATIONS]: {
title: 'modifications',
defaultSize: { width: 0.2, height: 0.6 },
- minSize: { width: 340, height: 300 },
+ minSize: { width: 360, height: 300 },
defaultPosition: { x: 0.05, y: 0 },
icon: ,
},
diff --git a/src/components/workspace/core/workspace-toolbar.tsx b/src/components/workspace/core/workspace-toolbar.tsx
index e677feb88e..c4df469e8f 100644
--- a/src/components/workspace/core/workspace-toolbar.tsx
+++ b/src/components/workspace/core/workspace-toolbar.tsx
@@ -35,6 +35,7 @@ import { useSelector } from 'react-redux';
import { PanelType } from '../types/workspace.types';
import { useWorkspacePanelActions } from '../hooks/use-workspace-panel-actions';
import { selectOpenPanels } from '../../../redux/slices/workspace-selectors';
+import { WorkspaceSwitcher } from './workspace-switcher';
const styles = {
container: {
@@ -253,6 +254,7 @@ export const WorkspaceToolbar = () => {
+
= {
@@ -101,9 +101,6 @@ export type AppActions =
| CopiedNetworkModificationsAction
| SetModificationsDrawerOpenAction
| CenterOnSubstationAction
- | AddNotificationAction
- | RemoveNotificationByNodeAction
- | SetModificationsInProgressAction
| SetComputingStatusAction
| SetComputingStatusParametersAction
| SetComputationStartingAction
@@ -142,6 +139,7 @@ export type AppActions =
| AddGlobalFiltersAction
| RemoveGlobalFiltersAction
| ClearGlobalFiltersAction
+ | SetNodeActivitiesAction
| UpdateAliasedNodesValidityAction;
export const SET_APP_TAB_INDEX = 'SET_APP_TAB_INDEX';
@@ -788,42 +786,6 @@ export function centerOnSubstation(substationId: string): CenterOnSubstationActi
};
}
-export const ADD_NOTIFICATION = 'ADD_NOTIFICATION';
-export type AddNotificationAction = Readonly> & {
- notificationIds: UUID[];
-};
-
-export function addNotification(notificationIds: UUID[]): AddNotificationAction {
- return {
- type: ADD_NOTIFICATION,
- notificationIds: notificationIds,
- };
-}
-
-export const REMOVE_NOTIFICATION_BY_NODE = 'REMOVE_NOTIFICATION_BY_NODE';
-export type RemoveNotificationByNodeAction = Readonly> & {
- notificationIds: UnknownArray;
-};
-
-export function removeNotificationByNode(notificationIds: UnknownArray): RemoveNotificationByNodeAction {
- return {
- type: REMOVE_NOTIFICATION_BY_NODE,
- notificationIds: notificationIds,
- };
-}
-
-export const SET_MODIFICATIONS_IN_PROGRESS = 'SET_MODIFICATIONS_IN_PROGRESS';
-export type SetModificationsInProgressAction = Readonly> & {
- isModificationsInProgress: boolean;
-};
-
-export function setModificationsInProgress(isModificationsInProgress: boolean): SetModificationsInProgressAction {
- return {
- type: SET_MODIFICATIONS_IN_PROGRESS,
- isModificationsInProgress: isModificationsInProgress,
- };
-}
-
export const SET_COMPUTING_STATUS = 'SET_COMPUTING_STATUS';
export type SetComputingStatusAction = Readonly> & {
computingType: ComputingType;
@@ -1417,6 +1379,18 @@ export function selectSyncEnabled(syncEnabled: boolean): SelectSyncEnabledAction
};
}
+export const SET_NODE_ACTIVITIES = 'SET_NODE_ACTIVITIES';
+export type SetNodeActivitiesAction = Readonly> & {
+ nodeActivities: NodeActivity[];
+};
+
+export function setNodeActivities(nodeActivities: NodeActivity[]): SetNodeActivitiesAction {
+ return {
+ type: SET_NODE_ACTIVITIES,
+ nodeActivities,
+ };
+}
+
export const UPDATE_NODE_ALIASES = 'UPDATE_NODE_ALIASES';
export type UpdateNodeAliasesAction = Readonly> & {
nodeAliases: NodeAlias[];
diff --git a/src/redux/reducer.ts b/src/redux/reducer.ts
index bd1b0017ee..3a4511ae24 100644
--- a/src/redux/reducer.ts
+++ b/src/redux/reducer.ts
@@ -40,12 +40,10 @@ import {
import {
ADD_GLOBAL_FILTERS,
- ADD_NOTIFICATION,
ADD_SORT_FOR_NEW_SPREADSHEET,
ADD_SPREADSHEET_LOADED_NODES_IDS,
ADD_TO_GLOBAL_FILTER_OPTIONS,
AddGlobalFiltersAction,
- type AddNotificationAction,
type AddSortForNewSpreadsheetAction,
AddSpreadsheetLoadedNodesIdsAction,
type AddToGlobalFilterOptionsAction,
@@ -116,7 +114,6 @@ import {
REMOVE_FROM_GLOBAL_FILTER_OPTIONS,
REMOVE_GLOBAL_FILTERS,
REMOVE_NODE_DATA,
- REMOVE_NOTIFICATION_BY_NODE,
REMOVE_SPREADSHEET_LOADED_NODES_IDS,
REMOVE_TABLE_DEFINITION,
type RemoveColumnDefinitionAction,
@@ -124,7 +121,6 @@ import {
type RemoveFromGlobalFilterOptionsAction,
RemoveGlobalFiltersAction,
type RemoveNodeDataAction,
- type RemoveNotificationByNodeAction,
RemoveSpreadsheetLoadedNodesIdsAction,
type RemoveTableDefinitionAction,
RENAME_TABLE_DEFINITION,
@@ -173,7 +169,6 @@ import {
SET_COMPUTING_STATUS_INFOS,
SET_DIRTY_COMPUTATION_PARAMETERS,
SET_LAST_COMPLETED_COMPUTATION,
- SET_MODIFICATIONS_IN_PROGRESS,
SET_MONO_ROOT_STUDY,
SET_ONE_BUS_SHORTCIRCUIT_ANALYSIS_CONTEXT,
SET_OPTIONAL_SERVICES,
@@ -192,7 +187,6 @@ import {
type SetComputingStatusParametersAction,
type SetDirtyComputationParametersAction,
type SetLastCompletedComputationAction,
- type SetModificationsInProgressAction,
type SetMonoRootStudyAction,
type SetOneBusShortcircuitAnalysisContextAction,
type SetOptionalServicesAction,
@@ -209,6 +203,8 @@ import {
UPDATE_COLUMNS_DEFINITION,
UPDATE_EQUIPMENTS,
UPDATE_NETWORK_VISUALIZATION_PARAMETERS,
+ SET_NODE_ACTIVITIES,
+ SetNodeActivitiesAction,
UPDATE_NODE_ALIASES,
UPDATE_SPREADSHEET_PARTIAL_DATA,
UPDATE_TABLE_COLUMNS,
@@ -492,6 +488,7 @@ const initialState: AppState = {
},
tables: initialTablesState,
nodeAliases: [],
+ nodeActivities: [],
aliasedNodesValidity: {},
calculationSelections: {},
mapEquipments: undefined,
@@ -507,8 +504,6 @@ const initialState: AppState = {
mapDataLoading: false,
isExplorerDrawerOpen: true,
centerOnSubstation: undefined,
- notificationIdList: [],
- isModificationsInProgress: false,
isMonoRootStudy: true,
nadNodeMovements: [],
nadTextNodeMovements: [],
@@ -712,6 +707,7 @@ export const reducer = createReducer(initialState, (builder) => {
state.studyUuid = null;
state.geoData = null;
state.networkModificationTreeModel = null;
+ state.nodeActivities = [];
});
builder.addCase(MAP_EQUIPMENTS_CREATED, (state, action: MapEquipmentsCreatedAction) => {
@@ -881,7 +877,6 @@ export const reducer = createReducer(initialState, (builder) => {
LOAD_NETWORK_MODIFICATION_TREE_SUCCESS,
(state, action: LoadNetworkModificationTreeSuccessAction) => {
state.networkModificationTreeModel = action.networkModificationTreeModel;
- state.networkModificationTreeModel.setBuildingStatus();
state.isNetworkModificationTreeModelUpToDate = true;
state.reloadMapNeeded = true;
}
@@ -995,7 +990,6 @@ export const reducer = createReducer(initialState, (builder) => {
let newModel = state.networkModificationTreeModel.newSharedForUpdate();
newModel.updateNodes(action.networkModificationTreeNodes);
state.networkModificationTreeModel = newModel;
- state.networkModificationTreeModel?.setBuildingStatus();
// check if current node is in the nodes updated list
if (action.networkModificationTreeNodes.find((node) => node.id === state.currentTreeNode?.id)) {
synchCurrentTreeNode(state, state.currentTreeNode?.id);
@@ -1130,20 +1124,6 @@ export const reducer = createReducer(initialState, (builder) => {
state.centerOnSubstation = action.centerOnSubstation;
});
- builder.addCase(ADD_NOTIFICATION, (state, action: AddNotificationAction) => {
- state.notificationIdList = [...state.notificationIdList, ...action.notificationIds];
- });
-
- builder.addCase(REMOVE_NOTIFICATION_BY_NODE, (state, action: RemoveNotificationByNodeAction) => {
- state.notificationIdList = [
- ...state.notificationIdList.filter((nodeId) => !action.notificationIds.includes(nodeId)),
- ];
- });
-
- builder.addCase(SET_MODIFICATIONS_IN_PROGRESS, (state, action: SetModificationsInProgressAction) => {
- state.isModificationsInProgress = action.isModificationsInProgress;
- });
-
builder.addCase(SET_MONO_ROOT_STUDY, (state, action: SetMonoRootStudyAction) => {
state.isMonoRootStudy = action.isMonoRootStudy;
});
@@ -1636,7 +1616,9 @@ export const reducer = createReducer(initialState, (builder) => {
builder.addCase(UPDATE_NODE_ALIASES, (state, action: UpdateNodeAliasesAction) => {
state.nodeAliases = action.nodeAliases;
});
-
+ builder.addCase(SET_NODE_ACTIVITIES, (state, action: SetNodeActivitiesAction) => {
+ state.nodeActivities = action.nodeActivities;
+ });
builder.addCase(UPDATE_ALIASED_NODES_VALIDITY, (state, action: UpdateAliasedNodesValidityAction) => {
state.aliasedNodesValidity = action.aliasedNodesValidity;
});
diff --git a/src/redux/reducer.type.ts b/src/redux/reducer.type.ts
index dc65ab6d57..0964cbdca4 100644
--- a/src/redux/reducer.type.ts
+++ b/src/redux/reducer.type.ts
@@ -54,6 +54,7 @@ import type {
} from '../components/graph/menus/network-modifications/network-modification-menu.type';
import type { CalculationType } from '../components/spreadsheet-view/types/calculation.type';
import type { RootNetworkIndexationStatus } from '../types/notification-types';
+import type { NodeActivity } from '../components/node-activity/types/node-activity.type';
import type { NodeAlias } from '../components/spreadsheet-view/types/node-alias.type';
import type { NodeValidity } from '../components/spreadsheet-view/columns/utils/column-validity';
import type NetworkModificationTreeModel from '../components/graph/network-modification-tree-model';
@@ -220,7 +221,6 @@ export interface AppState extends CommonStoreState, AppConfigState {
computationStarting: boolean;
optionalServices: IOptionalService[];
oneBusShortCircuitAnalysisContext: OneBusShortCircuitAnalysisContext | null;
- notificationIdList: UUID[];
globalFilterOptions: GlobalFilter[];
mapEquipments: GSMapEquipments | undefined;
networkAreaDiagramDepth: number;
@@ -228,6 +228,7 @@ export interface AppState extends CommonStoreState, AppConfigState {
tableSort: TableSort;
tables: TablesState;
nodeAliases: NodeAlias[];
+ nodeActivities: NodeActivity[];
// the current node is not in there, computingStatus covers it
aliasedNodesValidity: Record;
@@ -241,7 +242,6 @@ export interface AppState extends CommonStoreState, AppConfigState {
nadTextNodeMovements: NadTextMovement[];
isExplorerDrawerOpen: boolean;
centerOnSubstation: undefined | { to: string };
- isModificationsInProgress: boolean;
isMonoRootStudy: boolean;
reloadMapNeeded: boolean;
isEditMode: boolean;
diff --git a/src/services/study/node-activities.ts b/src/services/study/node-activities.ts
new file mode 100644
index 0000000000..395de7ac3d
--- /dev/null
+++ b/src/services/study/node-activities.ts
@@ -0,0 +1,17 @@
+/**
+ * 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 { backendFetchJson } from '@gridsuite/commons-ui';
+import type { UUID } from 'node:crypto';
+import type { NodeActivity } from '../../components/node-activity/types/node-activity.type';
+import { getStudyUrl } from './index';
+
+export function fetchNodeActivities(studyUuid: UUID, abortSignal?: AbortSignal): Promise {
+ const url = getStudyUrl(studyUuid) + '/tree/node-activities';
+ console.debug(url);
+ return backendFetchJson(url, { signal: abortSignal });
+}
diff --git a/src/translations/messages-en.ts b/src/translations/messages-en.ts
index 824bf449db..e8814d76cf 100644
--- a/src/translations/messages-en.ts
+++ b/src/translations/messages-en.ts
@@ -272,9 +272,6 @@ const messages_en = {
DynamicSimulationEventCount:
'{hide, select, false {{count, plural, =0 {no event} =1 {{count} event} other {{count} events}}} other {...}}',
DynamicSimulationEventUpdatingList: 'Updating event list ...',
- DynamicSimulationEventCreating: 'Creating event ...',
- DynamicSimulationEventUpdating: 'Updating event ...',
- DynamicSimulationEventDeleting: 'Deleting event ...',
DynamicSimulationTabTimeSeries: 'Curves',
DynamicSimulationTabTimeline: 'Chronology',
@@ -812,6 +809,7 @@ const messages_en = {
depth: 'Depth: ',
root: 'Root',
+ node: 'Node',
LogOnlySingleNode: 'Log single node',
ReportFetchError: 'An error occurred while fetching the logs',
@@ -1338,6 +1336,26 @@ const messages_en = {
Enum: 'Enumeration',
nodeType: 'Type',
nodeStatus: 'Status',
+ 'nodeActivity.UPDATING': 'Updating',
+ 'nodeActivity.DELETING': 'Deleting',
+ 'nodeActivity.BUILDING': 'Building',
+ 'nodeActivity.UNBUILDING': 'Unbuilding',
+ 'nodeActivity.COMPUTING': 'Computing',
+ 'study.nodeActivityConflict':
+ '{requestedLabel, select, BUILDING {Build refused} UNBUILDING {Unbuild refused} ' +
+ 'COMPUTING {Computation refused} UPDATING {Update refused} DELETING {Deletion refused} ' +
+ 'other {Action refused}} on ' +
+ '{requestedOnRootNode, select, true {the root node} other {node {requestedNodeName}}}: ' +
+ '{label, select, BUILDING {a build} UNBUILDING {an unbuild} COMPUTING {a computation} ' +
+ 'UPDATING {an update} DELETING {a deletion} other {an operation}} is running on ' +
+ '{onRootNode, select, true {the root node} other {node {nodeName}}}.',
+ nodeActivityNode: 'Node {nodeName}',
+ 'nodeActivityInProgress.UPDATING': 'Updating',
+ 'nodeActivityInProgress.DELETING': 'Deleting',
+ 'nodeActivityInProgress.BUILDING': 'Building',
+ 'nodeActivityInProgress.UNBUILDING': 'Unbuilding',
+ 'nodeActivityInProgress.COMPUTING': 'Computing',
+ nodeActivityOnRootNetwork: 'On root network {rootNetwork}',
CONSTRUCTION: 'Construction',
SECURITY: 'Security',
MOVE_VOLTAGE_LEVEL_FEEDER_BAYS: 'Move connections',
diff --git a/src/translations/messages-fr.ts b/src/translations/messages-fr.ts
index 66562c3855..8268ee778f 100644
--- a/src/translations/messages-fr.ts
+++ b/src/translations/messages-fr.ts
@@ -276,9 +276,6 @@ const messages_fr = {
DynamicSimulationEventCount:
'{hide, select, false {{count, plural, =0 {aucun évènement} =1 {# évènement} other {# évènements}}} other {...}}',
DynamicSimulationEventUpdatingList: 'Mise à jour de la liste des évènements en cours ...',
- DynamicSimulationEventCreating: "Création d'un évènement en cours ...",
- DynamicSimulationEventUpdating: "Mise à jour d'un évènement en cours ...",
- DynamicSimulationEventDeleting: "Suppression d'un évènement en cours ...",
DynamicSimulationTabTimeSeries: 'Courbes',
DynamicSimulationTabTimeline: 'Chronologie',
@@ -818,6 +815,7 @@ const messages_fr = {
depth: 'Profondeur : ',
root: 'Racine',
+ node: 'Nœud',
LogOnlySingleNode: 'Log nœud seul',
ReportFetchError: 'Erreur lors de la récupération des logs',
@@ -1354,6 +1352,26 @@ const messages_fr = {
Enum: 'Énumération',
nodeType: 'Type',
nodeStatus: 'Statut',
+ 'nodeActivity.UPDATING': 'Mise à jour',
+ 'nodeActivity.DELETING': 'Suppression',
+ 'nodeActivity.BUILDING': 'Réalisation',
+ 'nodeActivity.UNBUILDING': 'Déréalisation',
+ 'nodeActivity.COMPUTING': 'Calcul',
+ 'study.nodeActivityConflict':
+ '{requestedLabel, select, BUILDING {Réalisation refusée} UNBUILDING {Déréalisation refusée} ' +
+ 'COMPUTING {Calcul refusé} UPDATING {Mise à jour refusée} DELETING {Suppression refusée} ' +
+ 'other {Action refusée}} sur ' +
+ '{requestedOnRootNode, select, true {le nœud racine} other {le nœud {requestedNodeName}}} : ' +
+ '{label, select, BUILDING {une réalisation} UNBUILDING {une déréalisation} ' +
+ 'COMPUTING {un calcul} UPDATING {une mise à jour} DELETING {une suppression} other {une opération}} ' +
+ 'est en cours sur {onRootNode, select, true {le nœud racine} other {le nœud {nodeName}}}.',
+ nodeActivityNode: 'Nœud {nodeName}',
+ 'nodeActivityInProgress.UPDATING': 'En cours de mise à jour',
+ 'nodeActivityInProgress.DELETING': 'En cours de suppression',
+ 'nodeActivityInProgress.BUILDING': 'En cours de réalisation',
+ 'nodeActivityInProgress.UNBUILDING': 'En cours de déréalisation',
+ 'nodeActivityInProgress.COMPUTING': 'En cours de calcul',
+ nodeActivityOnRootNetwork: 'Sur le réseau racine {rootNetwork}',
CONSTRUCTION: 'Construction',
SECURITY: 'Sécurité',
MOVE_VOLTAGE_LEVEL_FEEDER_BAYS: 'Déplacer les départs',
diff --git a/src/types/notification-types.ts b/src/types/notification-types.ts
index afff0fce46..9daee4d293 100644
--- a/src/types/notification-types.ts
+++ b/src/types/notification-types.ts
@@ -33,22 +33,15 @@ export enum NotificationType {
NODES_UPDATED = 'nodeUpdated',
NODE_EDITED = 'nodeEdited',
NODE_BUILD_STATUS_UPDATED = 'nodeBuildStatusUpdated',
+ NODE_ACTIVITIES_UPDATED = 'nodeActivitiesUpdated',
SUBTREE_MOVED = 'subtreeMoved',
SUBTREE_CREATED = 'subtreeCreated',
NODES_COLUMN_POSITION_CHANGED = 'nodesColumnPositionsChanged',
NETWORK_EXPORT_FINISHED = 'networkExportFinished',
// Modifications
- MODIFICATIONS_CREATION_IN_PROGRESS = 'creatingInProgress',
- MODIFICATIONS_UPDATING_IN_PROGRESS = 'updatingInProgress',
- MODIFICATIONS_STASHING_IN_PROGRESS = 'stashingInProgress',
- MODIFICATIONS_RESTORING_IN_PROGRESS = 'restoringInProgress',
- MODIFICATIONS_DELETING_IN_PROGRESS = 'deletingInProgress',
MODIFICATIONS_UPDATE_FINISHED = 'UPDATE_FINISHED',
MODIFICATIONS_DELETE_FINISHED = 'DELETE_FINISHED',
// Events
- EVENT_CREATING_IN_PROGRESS = 'eventCreatingInProgress',
- EVENT_UPDATING_IN_PROGRESS = 'eventUpdatingInProgress',
- EVENT_DELETING_IN_PROGRESS = 'eventDeletingInProgress',
EVENT_CRUD_FINISHED = 'EVENT_CRUD_FINISHED',
// Computations filters
@@ -106,20 +99,6 @@ export enum NotificationType {
WORKSPACE_NAD_CONFIG_UPDATED = 'workspaceNadConfigUpdated',
}
-export const PENDING_MODIFICATION_NOTIFICATION_TYPES = [
- NotificationType.MODIFICATIONS_CREATION_IN_PROGRESS,
- NotificationType.MODIFICATIONS_UPDATING_IN_PROGRESS,
- NotificationType.MODIFICATIONS_STASHING_IN_PROGRESS,
- NotificationType.MODIFICATIONS_RESTORING_IN_PROGRESS,
- NotificationType.MODIFICATIONS_DELETING_IN_PROGRESS,
-] as NotificationType[];
-
-export const EVENT_CRUD_NOTIFICATION_TYPES = [
- NotificationType.EVENT_CREATING_IN_PROGRESS,
- NotificationType.EVENT_UPDATING_IN_PROGRESS,
- NotificationType.EVENT_DELETING_IN_PROGRESS,
-] as NotificationType[];
-
export enum RootNetworkIndexationStatus {
NOT_INDEXED = 'NOT_INDEXED',
INDEXING_ONGOING = 'INDEXING_ONGOING',
@@ -289,26 +268,6 @@ interface WorkspaceNadConfigUpdatedEventDataHeaders extends CommonStudyEventData
clientId?: UUID;
}
-interface ModificationsCreationInProgressEventDataHeaders extends ModificationProgressionEventDataHeaders {
- updateType: NotificationType.MODIFICATIONS_CREATION_IN_PROGRESS;
-}
-
-interface ModificationsUpdatingInProgressEventDataHeaders extends ModificationProgressionEventDataHeaders {
- updateType: NotificationType.MODIFICATIONS_UPDATING_IN_PROGRESS;
-}
-
-interface ModificationsStashingInProgressEventDataHeaders extends ModificationProgressionEventDataHeaders {
- updateType: NotificationType.MODIFICATIONS_STASHING_IN_PROGRESS;
-}
-
-interface ModificationsRestoringInProgressEventDataHeaders extends ModificationProgressionEventDataHeaders {
- updateType: NotificationType.MODIFICATIONS_RESTORING_IN_PROGRESS;
-}
-
-interface ModificationsDeletingInProgressEventDataHeaders extends ModificationProgressionEventDataHeaders {
- updateType: NotificationType.MODIFICATIONS_DELETING_IN_PROGRESS;
-}
-
interface ModificationsUpdateFinishedEventDataHeaders extends ModificationProgressionEventDataHeaders {
updateType: NotificationType.MODIFICATIONS_UPDATE_FINISHED;
}
@@ -320,24 +279,6 @@ interface ModificationsDeleteFinishedEventDataHeaders extends CommonStudyEventDa
nodes: UUID[];
}
-interface EventCreatingInProgressEventDataHeaders extends CommonStudyEventDataHeaders {
- updateType: NotificationType.EVENT_CREATING_IN_PROGRESS;
- parentNode: UUID;
- nodes: UUID[];
-}
-
-interface EventUpdatingInProgressEventDataHeaders extends CommonStudyEventDataHeaders {
- updateType: NotificationType.EVENT_UPDATING_IN_PROGRESS;
- parentNode: UUID;
- nodes: UUID[];
-}
-
-interface EventDeletingInProgressEventDataHeaders extends CommonStudyEventDataHeaders {
- updateType: NotificationType.EVENT_DELETING_IN_PROGRESS;
- parentNode: UUID;
- nodes: UUID[];
-}
-
interface EventCrudFinishedEventDataHeaders extends CommonStudyEventDataHeaders {
updateType: NotificationType.EVENT_CRUD_FINISHED;
parentNode: UUID;
@@ -468,6 +409,15 @@ export interface MetadataUpdatedEventData extends CommonStudyEventData {
payload: undefined;
}
+interface NodeActivitiesUpdatedEventDataHeaders extends CommonStudyEventDataHeaders {
+ updateType: NotificationType.NODE_ACTIVITIES_UPDATED;
+}
+
+export interface NodeActivitiesUpdatedEventData extends CommonStudyEventData {
+ headers: NodeActivitiesUpdatedEventDataHeaders;
+ payload: string;
+}
+
export interface NodeCreatedEventData extends CommonStudyEventData {
headers: NodeCreatedEventDataHeaders;
payload: undefined;
@@ -529,31 +479,6 @@ export type TreeModelUpdateEventData =
| NodesUpdatedEventData
| NodeEditedEventData;
-export interface ModificationsCreationInProgressEventData extends CommonStudyEventData {
- headers: ModificationsCreationInProgressEventDataHeaders;
- payload: undefined;
-}
-
-export interface ModificationsUpdatingInProgressEventData extends CommonStudyEventData {
- headers: ModificationsUpdatingInProgressEventDataHeaders;
- payload: undefined;
-}
-
-export interface ModificationsStashingInProgressEventData extends CommonStudyEventData {
- headers: ModificationsStashingInProgressEventDataHeaders;
- payload: undefined;
-}
-
-export interface ModificationsRestoringInProgressEventData extends CommonStudyEventData {
- headers: ModificationsRestoringInProgressEventDataHeaders;
- payload: undefined;
-}
-
-export interface ModificationsDeletingInProgressEventData extends CommonStudyEventData {
- headers: ModificationsDeletingInProgressEventDataHeaders;
- payload: undefined;
-}
-
export interface ModificationsUpdateFinishedEventData extends CommonStudyEventData {
headers: ModificationsUpdateFinishedEventDataHeaders;
payload: undefined;
@@ -564,21 +489,6 @@ export interface ModificationsDeleteFinishedEventData extends CommonStudyEventDa
payload: undefined;
}
-export interface EventCreatingInProgressEventData extends CommonStudyEventData {
- headers: EventCreatingInProgressEventDataHeaders;
- payload: undefined;
-}
-
-export interface EventUpdatingInProgressEventData extends CommonStudyEventData {
- headers: EventUpdatingInProgressEventDataHeaders;
- payload: undefined;
-}
-
-export interface EventDeletingInProgressEventData extends CommonStudyEventData {
- headers: EventDeletingInProgressEventDataHeaders;
- payload: undefined;
-}
-
export interface EventCrudFinishedEventData extends CommonStudyEventData {
headers: EventCrudFinishedEventDataHeaders;
payload: undefined;
@@ -730,12 +640,6 @@ export function isNetworkVisualizationParametersUpdatedNotification(
return notif.headers?.updateType === NotificationType.NETWORK_VISUALIZATION_PARAMETERS_UPDATED;
}
-export function isEventNotification(
- notif: CommonStudyEventData
-): notif is EventCreatingInProgressEventData | EventUpdatingInProgressEventData | EventDeletingInProgressEventData {
- return EVENT_CRUD_NOTIFICATION_TYPES.includes(notif.headers?.updateType);
-}
-
export function isEventCrudFinishedNotification(notif: CommonStudyEventData): notif is EventCrudFinishedEventData {
return notif.headers?.updateType === NotificationType.EVENT_CRUD_FINISHED;
}
@@ -758,17 +662,6 @@ export function isExportNetworkNotification(notif: CommonStudyEventData): notif
return notif.headers?.updateType === NotificationType.NETWORK_EXPORT_FINISHED;
}
-export function isPendingModificationNotification(
- notif: CommonStudyEventData
-): notif is
- | ModificationsCreationInProgressEventData
- | ModificationsUpdatingInProgressEventData
- | ModificationsStashingInProgressEventData
- | ModificationsRestoringInProgressEventData
- | ModificationsDeletingInProgressEventData {
- return PENDING_MODIFICATION_NOTIFICATION_TYPES.includes(notif.headers?.updateType);
-}
-
export function isModificationsUpdateFinishedNotification(
notif: CommonStudyEventData
): notif is ModificationsUpdateFinishedEventData {
@@ -795,6 +688,12 @@ export function isMetadataUpdatedNotification(notif: CommonStudyEventData): noti
return notif.headers?.updateType === NotificationType.METADATA_UPDATED;
}
+export function isNodeActivitiesUpdatedNotification(
+ notif: CommonStudyEventData
+): notif is NodeActivitiesUpdatedEventData {
+ return notif.headers?.updateType === NotificationType.NODE_ACTIVITIES_UPDATED;
+}
+
export function isSpreadsheetNodeAliasesUpdatedNotification(notif: CommonStudyEventData): boolean {
return notif.headers?.updateType === NotificationType.SPREADSHEET_NODE_ALIASES_UPDATED;
}