diff --git a/src/components/graph/layout.ts b/src/components/graph/layout.ts index 21217854f6..11e37d9d31 100644 --- a/src/components/graph/layout.ts +++ b/src/components/graph/layout.ts @@ -261,9 +261,9 @@ function compressTreePlacements(nodes: CurrentTreeNode[], placements: PlacementG // to the maximum column placement values (per row) of the nodes on the left. // The resulting space we find represents how much we can shift the current column to the left. - const currentNodeFamilySize = 1 + countNodes(nodes, currentNodeId as UUID); + const currentSubTreeSize = 1 + countNodes(nodes, currentNodeId as UUID); const indexOfCurrentNode = nodes.findIndex((n) => n.id === currentNodeId); - const nodesOfTheCurrentBranch = nodes.slice(indexOfCurrentNode, indexOfCurrentNode + currentNodeFamilySize); + const nodesOfTheCurrentBranch = nodes.slice(indexOfCurrentNode, indexOfCurrentNode + currentSubTreeSize); const currentBranchMinimumColumnByRow = getMinimumColumnByRows(nodesOfTheCurrentBranch, placements); // We have to compare with all the left nodes, not only the current branch's left neighbor, because in some @@ -343,39 +343,6 @@ export function getFirstAncestorWithSibling( return undefined; } -/** - * Will find the sibling node whose X position is closer to xDestination in the X range provided. - */ -export function findClosestSiblingInRange( - nodes: CurrentTreeNode[], - node: CurrentTreeNode, - xOrigin: number, - xDestination: number -): CurrentTreeNode | null { - const minX = Math.min(xOrigin, xDestination); - const maxX = Math.max(xOrigin, xDestination); - const siblingNodes = findSiblings(nodes, node); - const nodesBetween = siblingNodes.filter((n) => n.position.x < maxX && n.position.x > minX); - if (nodesBetween.length > 0) { - const closestNode = nodesBetween.reduce( - (closest, current) => - Math.abs(current.position.x - xDestination) < Math.abs(closest.position.x - xDestination) - ? current - : closest, - nodesBetween[0] - ); - return closestNode; - } - return null; -} - -/** - * Will find the siblings of a provided node (all siblings have the same parent). - */ -function findSiblings(nodes: CurrentTreeNode[], node: CurrentTreeNode): CurrentTreeNode[] { - return nodes.filter((n) => n.parentId === node.parentId && n.id !== node.id); -} - /** * Computes the absolute position of a node by calculating the sum of all the relative positions of * the node's lineage. diff --git a/src/components/graph/network-modification-tree-model.ts b/src/components/graph/network-modification-tree-model.ts index 33833af3de..589da0fc86 100644 --- a/src/components/graph/network-modification-tree-model.ts +++ b/src/components/graph/network-modification-tree-model.ts @@ -11,7 +11,7 @@ import { BUILD_STATUS } from '../network/constants'; import { UUID } from 'crypto'; import { CurrentTreeNode, isReactFlowRootNodeData } from '../../redux/reducer'; import { Edge } from '@xyflow/react'; -import { NetworkModificationNodeData, RootNodeData } from './tree-node.type'; +import { AbstractNode, NetworkModificationNodeData, RootNodeData } from './tree-node.type'; // Function to count children nodes for a given parentId recursively in an array of nodes. // TODO refactoring when changing NetworkModificationTreeModel as it becomes an object containing nodes @@ -30,119 +30,52 @@ export default class NetworkModificationTreeModel { isAnyNodeBuilding = false; - /** - * Will switch the order of two nodes in the tree. - * The nodeToMove will be moved, either to the left or right of the destinationNode, depending - * on their initial positions. - * Both nodes should have the same parent. - */ - switchSiblingsOrder(nodeToMove: CurrentTreeNode, destinationNode: CurrentTreeNode) { - if (!nodeToMove.parentId || nodeToMove.parentId !== destinationNode.parentId) { - console.error('Both nodes should have the same parent to switch their order'); - return; - } - const nodeToMoveIndex = this.treeNodes.findIndex((node) => node.id === nodeToMove.id); - const destinationNodeIndex = this.treeNodes.findIndex((node) => node.id === destinationNode.id); - - const numberOfNodesToMove: number = 1 + countNodes(this.treeNodes, nodeToMove.id); - const nodesToMove = this.treeNodes.splice(nodeToMoveIndex, numberOfNodesToMove); - - if (nodeToMoveIndex > destinationNodeIndex) { - this.treeNodes.splice(destinationNodeIndex, 0, ...nodesToMove); - } else { - // When moving nodeToMove to the right, we have to take into account the splice function that changed the nodes' indexes. - // We also need to find the correct position of nodeToMove, to the right of the destination node, meaning we need to find - // how many children the destination node has and add all of them to the new index. - const destinationNodeIndexAfterSplice = this.treeNodes.findIndex((node) => node.id === destinationNode.id); - const destinationNodeFamilySize: number = 1 + countNodes(this.treeNodes, destinationNode.id); - this.treeNodes.splice(destinationNodeIndexAfterSplice + destinationNodeFamilySize, 0, ...nodesToMove); + // 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) { + return a.columnPosition - b.columnPosition; } - - this.treeNodes = [...this.treeNodes]; + return 0; } - /** - * Finds the lowest common ancestor of two nodes in the tree. - * - * Example tree: - * A - * / \ - * B D - * / / \ - * C E F - * - * Examples: - * - getCommonAncestor(B, E) will return A - * - getCommonAncestor(E, F) will return D - */ - getCommonAncestor(nodeA: CurrentTreeNode, nodeB: CurrentTreeNode): CurrentTreeNode | null { - const getAncestors = (node: CurrentTreeNode) => { - const ancestors = []; - let current: CurrentTreeNode | undefined = node; - while (current && current.parentId) { - const parentId: string = current.parentId; - ancestors.push(parentId); - current = this.treeNodes.find((n) => n.id === parentId); - } - return ancestors; - }; - // We get the entire ancestors of one of the nodes in an array, then iterate over the other node's ancestors - // until we find a node that is in the first array : this common node is an ancestor of both intial nodes. - const ancestorsA: string[] = getAncestors(nodeA); - let current: CurrentTreeNode | undefined = nodeB; - while (current && current.parentId) { - const parentId: string = current.parentId; - current = this.treeNodes.find((n) => n.id === parentId); - if (current && ancestorsA.includes(current.id)) { - return current; - } - } - console.warn('No common ancestor found !'); - return null; + getChildren(parentNodeId: string): CurrentTreeNode[] { + return this.treeNodes.filter((n) => n.parentId === parentNodeId); } /** - * Finds the child of the ancestor node that is on the path to the descendant node. - * - * Example tree: - * A - * / \ - * B D - * / / \ - * C E F - * - * Examples: - * - getChildOfAncestorInLineage(A, E) will return D - * - getChildOfAncestorInLineage(D, F) will return F - * - * @param ancestor node, must be an ancestor of descendant node - * @param descendant node, must be a descendant of ancestor - * @returns The child of the ancestor node in the lineage or null if not found. - * @private + * Will reorganize treeNodes and put the children of parentNodeId in the order provided in nodeIds array. + * @param parentNodeId parent ID of the to be reordered children nodes + * @param orderedNodeIds array of children ID in the order we want + * @returns true if the order was changed */ - getChildOfAncestorInLineage(ancestor: CurrentTreeNode, descendant: CurrentTreeNode): CurrentTreeNode | null { - let current: CurrentTreeNode | undefined = descendant; - while (current && current.parentId) { - const parentId: string = current.parentId; - if (parentId === ancestor.id) { - return current; - } - current = this.treeNodes.find((n) => n.id === parentId); + reorderChildrenNodes(parentNodeId: string, orderedNodeIds: string[]) { + // We check if the current position is already correct + const children = this.getChildren(parentNodeId); + if (orderedNodeIds.length !== children.length) { + console.warn('reorderNodes : synchronization error, reorder cancelled'); + return false; } - console.warn('The ancestor and descendant do not share the same branch !'); - return null; - } - - switchBranches(nodeToMove: CurrentTreeNode, destinationNode: CurrentTreeNode) { - // We find the nodes from the two branches that share the same parent - const commonAncestor = this.getCommonAncestor(nodeToMove, destinationNode); - if (commonAncestor) { - const siblingFromNodeToMoveBranch = this.getChildOfAncestorInLineage(commonAncestor, nodeToMove); - const siblingFromDestinationNodeBranch = this.getChildOfAncestorInLineage(commonAncestor, destinationNode); - if (siblingFromNodeToMoveBranch && siblingFromDestinationNodeBranch) { - this.switchSiblingsOrder(siblingFromNodeToMoveBranch, siblingFromDestinationNodeBranch); - } + if (children.map((child) => child.id).join(',') === orderedNodeIds.join(',')) { + // Already in the same order. + return false; } + // Let's reorder the children : + // In orderedNodeIds order, we cut and paste the corresponding number of nodes in treeNodes. + const justAfterParentIndex = 1 + this.treeNodes.findIndex((n) => n.id === parentNodeId); // we add 1 here to set the index just after the parent node + let insertedNodes = 0; + + orderedNodeIds.forEach((nodeId) => { + const nodesToMoveIndex = this.treeNodes.findIndex((n) => n.id === nodeId); + const subTreeSize = 1 + countNodes(this.treeNodes, nodeId as UUID); // We add 1 here to include the current node in its subtree size + + // We remove from treeNodes the nodes that we want to move, (...) + const nodesToMove = this.treeNodes.splice(nodesToMoveIndex, subTreeSize); + + // (...) and now we put them in their new position in the array + this.treeNodes.splice(justAfterParentIndex + insertedNodes, 0, ...nodesToMove); + insertedNodes += subTreeSize; + }); + return true; } addChild( @@ -224,7 +157,7 @@ export default class NetworkModificationTreeModel { }); // overwrite old children nodes parentUuid when inserting new nodes - const nextNodes = this.treeNodes.map((node) => { + this.treeNodes = this.treeNodes.map((node) => { if (newNode.childrenIds.includes(node.id)) { return { ...node, @@ -233,14 +166,13 @@ export default class NetworkModificationTreeModel { } return node; }); - - this.treeNodes = nextNodes; this.treeEdges = filteredEdges; } if (!skipChildren) { // Add children of this node recursively if (newNode.children) { + newNode.children.sort(this.childrenNodeSorter); newNode.children.forEach((child) => { this.addChild(child, newNode.id, undefined, undefined); }); @@ -279,7 +211,7 @@ export default class NetworkModificationTreeModel { if (!nodeToDelete) { return; } - const nextTreeNodes = filteredNodes.map((node) => { + this.treeNodes = filteredNodes.map((node) => { if (node.parentId === nodeId) { return { ...node, @@ -288,8 +220,6 @@ export default class NetworkModificationTreeModel { } return node; }); - - this.treeNodes = nextTreeNodes; }); } @@ -321,6 +251,7 @@ export default class NetworkModificationTreeModel { // handle root node this.treeNodes.push(convertNodetoReactFlowModelNode(elements)); // handle root children + elements.children.sort(this.childrenNodeSorter); elements.children.forEach((child) => { this.addChild(child, elements.id); }); @@ -329,8 +260,7 @@ export default class NetworkModificationTreeModel { newSharedForUpdate() { /* shallow clone of the network https://stackoverflow.com/a/44782052 */ - let newTreeModel = Object.assign(Object.create(Object.getPrototypeOf(this)), this); - return newTreeModel; + return Object.assign(Object.create(Object.getPrototypeOf(this)), this); } setBuildingStatus() { diff --git a/src/components/graph/tree-node.type.ts b/src/components/graph/tree-node.type.ts index bf6f1cdd7f..5ab7831947 100644 --- a/src/components/graph/tree-node.type.ts +++ b/src/components/graph/tree-node.type.ts @@ -21,6 +21,7 @@ export type AbstractNode = { readOnly?: boolean; reportUuid?: UUID; type: NodeType; + columnPosition?: number; }; export interface NodeBuildStatus { diff --git a/src/components/network-modification-tree-pane.jsx b/src/components/network-modification-tree-pane.jsx index 66d701a331..d4069512d7 100644 --- a/src/components/network-modification-tree-pane.jsx +++ b/src/components/network-modification-tree-pane.jsx @@ -15,6 +15,7 @@ import { networkModificationHandleSubtree, setNodeSelectionForCopy, resetLogsFilter, + reorderNetworkModificationTreeNodes, } from '../redux/actions'; import { useDispatch, useSelector } from 'react-redux'; import PropTypes from 'prop-types'; @@ -214,6 +215,14 @@ export const NetworkModificationTreePane = ({ studyUuid, studyMapTreeDisplay }) ); } ); + } else if (studyUpdatedForce.eventData.headers['updateType'] === 'nodesColumnPositionsChanged') { + const orderedChildrenNodeIds = JSON.parse(studyUpdatedForce.eventData.payload); + dispatch( + reorderNetworkModificationTreeNodes( + studyUpdatedForce.eventData.headers['parentNode'], + orderedChildrenNodeIds + ) + ); } else if (studyUpdatedForce.eventData.headers['updateType'] === 'nodeMoved') { fetchNetworkModificationTreeNode(studyUuid, studyUpdatedForce.eventData.headers['movedNode']).then( (node) => { diff --git a/src/components/network-modification-tree.jsx b/src/components/network-modification-tree.jsx index beb1fee819..fa485b196a 100644 --- a/src/components/network-modification-tree.jsx +++ b/src/components/network-modification-tree.jsx @@ -10,7 +10,7 @@ import { ReactFlow, Controls, useStore, useReactFlow, MiniMap, useEdgesState, us import MapIcon from '@mui/icons-material/Map'; import CenterFocusIcon from '@mui/icons-material/CenterFocusStrong'; import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; -import { setModificationsDrawerOpen, setCurrentTreeNode, networkModificationTreeSwitchNodes } from '../redux/actions'; +import { setModificationsDrawerOpen, setCurrentTreeNode } from '../redux/actions'; import { useDispatch, useSelector } from 'react-redux'; import { isSameNode } from './graph/util/model-functions'; import { DRAWER_NODE_EDITOR_WIDTH } from '../utils/UIconstants'; @@ -20,7 +20,6 @@ import { nodeTypes } from './graph/util/model-constants'; import { BUILD_STATUS } from './network/constants'; import { StudyDisplayMode } from './network-modification.type'; import { - findClosestSiblingInRange, getAbsolutePosition, getFirstAncestorWithSibling, getTreeNodesWithUpdatedPositions, @@ -29,6 +28,8 @@ import { snapGrid, } from './graph/layout'; import TreeControlButton from './graph/util/tree-control-button'; +import { updateNodesColumnPositions } from '../services/study/tree-subtree.ts'; +import { useSnackMessage } from '@gridsuite/commons-ui'; const NetworkModificationTree = ({ studyMapTreeDisplay, @@ -38,6 +39,7 @@ const NetworkModificationTree = ({ isStudyDrawerOpen, }) => { const dispatch = useDispatch(); + const { snackError } = useSnackMessage(); const currentNode = useSelector((state) => state.currentTreeNode); @@ -218,29 +220,47 @@ const NetworkModificationTree = ({ } }; + /** + * Saves the new order of parentNode's children in the backend + */ + const saveChildrenColumnPositions = (parentNodeId) => { + const children = treeModel.getChildren(parentNodeId).map((node, index) => ({ + id: node.id, + type: node.type, + columnPosition: index, + })); + updateNodesColumnPositions(studyUuid, parentNodeId, children).catch((error) => { + snackError({ + messageTxt: error.message, + headerId: 'NodeUpdateColumnPositions', + }); + }); + }; + /** * When the user stops dragging a node and releases it to its new position, we check if we need - * to switch the order of the moved branch with a neighboring branch. + * to reorder the nodes */ const handleEndNodeDragging = () => { let movedNode = nodesMap.get(draggedBranchIdRef.current); draggedBranchIdRef.current = null; - if (movedNode) { + if (movedNode?.parentId) { // In the treeModel.treeNodes variable we can find the positions of the nodes before the user started // dragging something, whereas in the movedNode variable (which comes from the nodes variable), we can // find the position of the node which has been updated by ReactFlow's onNodesChanges function as the // user kept on dragging the node. - const movedNodeXPositionBeforeDrag = treeModel.treeNodes.find((n) => n.id === movedNode.id).position.x; - const movedNodeXPositionAfterDrag = movedNode.position.x; - - const nodeToSwitchWith = findClosestSiblingInRange( - nodes, - movedNode, - movedNodeXPositionBeforeDrag, - movedNodeXPositionAfterDrag - ); - if (nodeToSwitchWith) { - dispatch(networkModificationTreeSwitchNodes(movedNode.id, nodeToSwitchWith.id)); + // We want the new positions post-drag, so we get the original positions, remove the moved node from them, + // and add the updated movedNode to the list. + const childrenWithOriginalPositions = treeModel.getChildren(movedNode.parentId); + + const childrenWithUpdatedPositions = childrenWithOriginalPositions.filter((n) => n.id !== movedNode.id); + childrenWithUpdatedPositions.push(movedNode); + // We want the ids in the correct order, so we sort by the nodes' X position. + childrenWithUpdatedPositions.sort((a, b) => a.position.x - b.position.x); + const orderedChildrenIds = childrenWithUpdatedPositions.map((node) => node.id); + + if (treeModel.reorderChildrenNodes(movedNode.parentId, orderedChildrenIds)) { + saveChildrenColumnPositions(movedNode.parentId); } } }; diff --git a/src/redux/actions.ts b/src/redux/actions.ts index 0222063a7c..58ed64eee1 100644 --- a/src/redux/actions.ts +++ b/src/redux/actions.ts @@ -77,7 +77,7 @@ export type AppActions = | NetworkModificationHandleSubtreeAction | NetworkModificationTreeNodesRemovedAction | NetworkModificationTreeNodesUpdatedAction - | NetworkModificationTreeSwitchNodesAction + | NetworkModificationTreeNodesReorderAction | SelectThemeAction | SelectLanguageAction | SelectComputedLanguageAction @@ -292,21 +292,21 @@ export function networkModificationTreeNodeMoved( }; } -export const NETWORK_MODIFICATION_TREE_SWITCH_NODES = 'NETWORK_MODIFICATION_TREE_SWITCH_NODES'; -export type NetworkModificationTreeSwitchNodesAction = Readonly< - Action +export const NETWORK_MODIFICATION_TREE_NODES_REORDER = 'NETWORK_MODIFICATION_TREE_NODES_REORDER'; +export type NetworkModificationTreeNodesReorderAction = Readonly< + Action > & { - nodeToMoveId: string; - destinationNodeId: string; + parentNodeId: string; + nodeIds: string[]; }; -export function networkModificationTreeSwitchNodes( - nodeToMoveId: string, - destinationNodeId: string -): NetworkModificationTreeSwitchNodesAction { +export function reorderNetworkModificationTreeNodes( + parentNodeId: string, + nodeIds: string[] +): NetworkModificationTreeNodesReorderAction { return { - type: NETWORK_MODIFICATION_TREE_SWITCH_NODES, - nodeToMoveId, - destinationNodeId, + type: NETWORK_MODIFICATION_TREE_NODES_REORDER, + parentNodeId, + nodeIds, }; } diff --git a/src/redux/reducer.ts b/src/redux/reducer.ts index ecae780516..0d30781cdc 100644 --- a/src/redux/reducer.ts +++ b/src/redux/reducer.ts @@ -98,14 +98,14 @@ import { NETWORK_MODIFICATION_TREE_NODE_MOVED, NETWORK_MODIFICATION_TREE_NODES_REMOVED, NETWORK_MODIFICATION_TREE_NODES_UPDATED, - NETWORK_MODIFICATION_TREE_SWITCH_NODES, + NETWORK_MODIFICATION_TREE_NODES_REORDER, NetworkAreaDiagramNbVoltageLevelsAction, NetworkModificationHandleSubtreeAction, NetworkModificationTreeNodeAddedAction, NetworkModificationTreeNodeMovedAction, NetworkModificationTreeNodesRemovedAction, NetworkModificationTreeNodesUpdatedAction, - NetworkModificationTreeSwitchNodesAction, + NetworkModificationTreeNodesReorderAction, OPEN_DIAGRAM, OPEN_NAD_LIST, OPEN_STUDY, @@ -888,18 +888,11 @@ export const reducer = createReducer(initialState, (builder) => { ); builder.addCase( - NETWORK_MODIFICATION_TREE_SWITCH_NODES, - (state, action: NetworkModificationTreeSwitchNodesAction) => { + NETWORK_MODIFICATION_TREE_NODES_REORDER, + (state, action: NetworkModificationTreeNodesReorderAction) => { if (state.networkModificationTreeModel) { let newModel = state.networkModificationTreeModel.newSharedForUpdate(); - - const nodeToMove = newModel.treeNodes.find((n: CurrentTreeNode) => n.id === action.nodeToMoveId); - const destinationNode = newModel.treeNodes.find( - (n: CurrentTreeNode) => n.id === action.destinationNodeId - ); - if (nodeToMove && destinationNode) { - newModel.switchBranches(nodeToMove, destinationNode); - } + newModel.reorderChildrenNodes(action.parentNodeId, action.nodeIds); state.networkModificationTreeModel = newModel; } } diff --git a/src/services/study/tree-subtree.ts b/src/services/study/tree-subtree.ts index de090075be..222278becf 100644 --- a/src/services/study/tree-subtree.ts +++ b/src/services/study/tree-subtree.ts @@ -9,7 +9,7 @@ import { getStudyUrl } from './index'; import { backendFetch, backendFetchJson } from '../utils'; import { UUID } from 'crypto'; import { NodeInsertModes } from '../../components/graph/nodes/node-insert-modes'; -import { NodeType } from '../../components/graph/tree-node.type'; +import { AbstractNode, NodeType } from '../../components/graph/tree-node.type'; import { BUILD_STATUS } from '../../components/network/constants'; interface Node { @@ -163,6 +163,20 @@ export function updateTreeNode( }); } +export function updateNodesColumnPositions(studyUuid: UUID, parentNodeId: UUID, nodes: AbstractNode[]) { + const nodeUpdateUrl = + getStudyUrl(studyUuid) + '/tree/nodes/' + encodeURIComponent(parentNodeId) + '/children-column-positions'; + console.debug(nodeUpdateUrl); + return backendFetch(nodeUpdateUrl, { + method: 'put', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + }, + body: JSON.stringify(nodes), + }); +} + export function fetchNetworkModificationTreeNode(studyUuid: UUID, nodeUuid: UUID) { console.info('Fetching network modification tree node : ', nodeUuid); const url = getStudyUrl(studyUuid) + '/tree/nodes/' + encodeURIComponent(nodeUuid); diff --git a/src/translations/en.json b/src/translations/en.json index f4e1c71349..1f7ebadc9b 100644 --- a/src/translations/en.json +++ b/src/translations/en.json @@ -597,6 +597,7 @@ "NodeCreateError": "An error occurred while creating node", "NodeDeleteError": "An error occurred while deleting node", "NodeUpdateError": "An error occurred while updating node", + "NodeUpdateColumnPositions": "An error occured while updating the nodes' positions in the tree", "NodeBuildingError": "An error occurred while building node", "NodeUnbuildingError": "An error occurred while unbuilding node", "NetworkModifications": "Network modifications", diff --git a/src/translations/fr.json b/src/translations/fr.json index f8f50bf4fa..230b9e25f7 100644 --- a/src/translations/fr.json +++ b/src/translations/fr.json @@ -599,6 +599,7 @@ "NodeCreateError": "Une erreur est survenue lors de la création du nœud", "NodeDeleteError": "Une erreur est survenue lors de la suppression du nœud", "NodeUpdateError": "Une erreur est survenue lors de la mise à jour du nœud", + "NodeUpdateColumnPositions": "Une erreur est survenue lors de la mise à jour de la position des nœuds dans l'arbre", "NodeBuildingError": "Une erreur est survenue lors de la réalisation du nœud", "NodeUnbuildingError": "Une erreur est survenue lors de la déréalisation du nœud", "NetworkModifications": "Modifications de réseau",