diff --git a/CHANGELOG.adoc b/CHANGELOG.adoc index 977411b38f..596b96f552 100644 --- a/CHANGELOG.adoc +++ b/CHANGELOG.adoc @@ -90,6 +90,9 @@ Specifiers are also encouraged to implement their own `IRestDataVersionPayloadSe - https://github.com/eclipse-sirius/sirius-web/issues/4210[#4210] [table] Add the ability to fork the studio used by a table representation - https://github.com/eclipse-sirius/sirius-web/issues/4273[#4273] [table] Add support to column filtering in table +- https://github.com/eclipse-sirius/sirius-web/issues/3980[#3980] Add the ability to select newly created nodes. +The backend part (the ability to define an _Elements to Select Expression_ on diagram tools) was added in Sirius Web 2024.11.0 but the frontend did not apply the requested selection. +This is now fixed. === Improvements diff --git a/packages/diagrams/frontend/sirius-components-diagrams/src/renderer/DiagramRenderer.tsx b/packages/diagrams/frontend/sirius-components-diagrams/src/renderer/DiagramRenderer.tsx index 142e036946..cae3cedcd4 100644 --- a/packages/diagrams/frontend/sirius-components-diagrams/src/renderer/DiagramRenderer.tsx +++ b/packages/diagrams/frontend/sirius-components-diagrams/src/renderer/DiagramRenderer.tsx @@ -27,6 +27,7 @@ import { ReactFlow, ReactFlowProps, applyNodeChanges, + useStoreApi, } from '@xyflow/react'; import React, { MouseEvent as ReactMouseEvent, memo, useCallback, useContext, useEffect, useMemo, useRef } from 'react'; import { DiagramContext } from '../contexts/DiagramContext'; @@ -121,24 +122,76 @@ export const DiagramRenderer = memo(({ diagramRefreshedEventPayload }: DiagramRe const { nodeConverters } = useContext(NodeTypeContext); - const { setSelection } = useSelection(); + const { selection, setSelection } = useSelection(); const { edgeType, setEdgeType } = useEdgeType(); useInitialFitToScreen(); + const store = useStoreApi, Edge>(); useEffect(() => { const { diagram, cause } = diagramRefreshedEventPayload; const convertedDiagram: Diagram = convertDiagram(diagram, nodeConverters, diagramDescription, edgeType); - const selectedNodeIds = nodes.filter((node) => node.selected).map((node) => node.id); - const selectedEdgeIds = edges.filter((edge) => edge.selected).map((edge) => edge.id); if (cause === 'layout') { - convertedDiagram.nodes - .filter((node) => selectedNodeIds.includes(node.id)) - .forEach((node) => (node.selected = true)); - convertedDiagram.edges - .filter((edge) => selectedEdgeIds.includes(edge.id)) - .forEach((edge) => (edge.selected = true)); + const diagramElementIds: string[] = [ + ...getNodes().map((node) => node.data.targetObjectId), + ...getEdges().map((edge) => edge.data?.targetObjectId ?? ''), + ]; + + const selectionDiagramEntryIds = selection.entries + .map((entry) => entry.id) + .filter((id) => diagramElementIds.includes(id)) + .sort((id1: string, id2: string) => id1.localeCompare(id2)); + const selectedDiagramElementIds = [ + ...new Set( + [...getNodes(), ...getEdges()] + .filter((element) => element.selected) + .map((element) => element.data?.targetObjectId ?? '') + ), + ]; + selectedDiagramElementIds.sort((id1: string, id2: string) => id1.localeCompare(id2)); + + const semanticElementsViews: Map = new Map(); + [...getNodes(), ...getEdges()].forEach((element) => { + const viewId = element.id; + const semanticElementId = element.data?.targetObjectId ?? ''; + if (!semanticElementsViews.has(semanticElementId)) { + semanticElementsViews.set(semanticElementId, [viewId]); + } else { + semanticElementsViews.get(semanticElementId)?.push(viewId); + } + }); + + // For each selected semantic element which appears on the diagram, + // determine which of its views should be selected. + const viewsToSelect: Map = new Map(); + const previouslySelectedViews = [...getNodes(), ...getEdges()].filter((element) => element.selected); + for (var semanticElementId of selectionDiagramEntryIds) { + const allRelatedViews = semanticElementsViews.get(semanticElementId) || []; + const alreadySelectedViews = allRelatedViews.filter( + (viewId) => !!previouslySelectedViews.find((view: Node | Edge) => view.id === viewId) + ); + if (alreadySelectedViews.length > 0) { + // Keep the previous graphical selection if there was one that is still valid + viewsToSelect.set(semanticElementId, alreadySelectedViews); + } else if (allRelatedViews.length > 0 && allRelatedViews[0]) { + // Otherwise select a single view among the candidates. + // Given the order we receive the views from the backend, if there + // are multiple candidates in the same view hierarchy, the parent + // will appear first, and it's the "main" view we want to select. + viewsToSelect.set(semanticElementId, [allRelatedViews[0]]); + } + } + + // Apply the new graphical selection + convertedDiagram.nodes = convertedDiagram.nodes.map((node) => ({ + ...node, + selected: viewsToSelect.get(node.data?.targetObjectId)?.includes(node.id), + })); + convertedDiagram.edges = convertedDiagram.edges.map((edge) => ({ + ...edge, + selected: !!(edge.data?.targetObjectId && viewsToSelect.get(edge.data?.targetObjectId)?.includes(edge.id)), + })); setEdges(convertedDiagram.edges); setNodes(convertedDiagram.nodes); @@ -148,12 +201,27 @@ export const DiagramRenderer = memo(({ diagramRefreshedEventPayload }: DiagramRe edges, }; layout(previousDiagram, convertedDiagram, diagramRefreshedEventPayload.referencePosition, (laidOutDiagram) => { - laidOutDiagram.nodes - .filter((node) => selectedNodeIds.includes(node.id)) - .forEach((node) => (node.selected = true)); - laidOutDiagram.edges - .filter((edge) => selectedEdgeIds.includes(edge.id)) - .forEach((edge) => (edge.selected = true)); + const { nodeLookup, edgeLookup } = store.getState(); + + laidOutDiagram.nodes = laidOutDiagram.nodes.map((node) => { + if (nodeLookup.get(node.id)) { + return { + ...node, + selected: !!nodeLookup.get(node.id)?.selected, + }; + } + return node; + }); + + laidOutDiagram.edges = laidOutDiagram.edges.map((edge) => { + if (edgeLookup.get(edge.id)) { + return { + ...edge, + selected: !!edgeLookup.get(edge.id)?.selected, + }; + } + return edge; + }); setEdges(laidOutDiagram.edges); setNodes(laidOutDiagram.nodes); diff --git a/packages/diagrams/frontend/sirius-components-diagrams/src/renderer/connector/ConnectorContextualMenu.tsx b/packages/diagrams/frontend/sirius-components-diagrams/src/renderer/connector/ConnectorContextualMenu.tsx index 13720a7d5c..cd8d3f926e 100644 --- a/packages/diagrams/frontend/sirius-components-diagrams/src/renderer/connector/ConnectorContextualMenu.tsx +++ b/packages/diagrams/frontend/sirius-components-diagrams/src/renderer/connector/ConnectorContextualMenu.tsx @@ -12,7 +12,7 @@ *******************************************************************************/ import { gql, useMutation, useQuery } from '@apollo/client'; -import { IconOverlay, useMultiToast } from '@eclipse-sirius/sirius-components-core'; +import { IconOverlay, useMultiToast, useSelection } from '@eclipse-sirius/sirius-components-core'; import ListItemIcon from '@mui/material/ListItemIcon'; import Menu from '@mui/material/Menu'; import MenuItem from '@mui/material/MenuItem'; @@ -118,6 +118,7 @@ const ConnectorContextualMenuComponent = memo(({}: ConnectorContextualMenuProps) const { connection, position, onConnectorContextualMenuClose, addTempConnectionLine, removeTempConnectionLine } = useConnector(); const { addMessages, addErrorMessage } = useMultiToast(); + const { setSelection } = useSelection(); const { showDialog, isOpened } = useDialog(); @@ -226,6 +227,10 @@ const ConnectorContextualMenuComponent = memo(({}: ConnectorContextualMenuProps) addMessages(payload.messages); } if (isSuccessPayload(payload)) { + const { newSelection } = payload; + if (newSelection?.entries.length ?? 0 > 0) { + setSelection(newSelection); + } addMessages(payload.messages); onShouldConnectorContextualMenuClose(); } diff --git a/packages/diagrams/frontend/sirius-components-diagrams/src/renderer/connector/ConnectorContextualMenu.types.ts b/packages/diagrams/frontend/sirius-components-diagrams/src/renderer/connector/ConnectorContextualMenu.types.ts index c16d075907..75a1e371df 100644 --- a/packages/diagrams/frontend/sirius-components-diagrams/src/renderer/connector/ConnectorContextualMenu.types.ts +++ b/packages/diagrams/frontend/sirius-components-diagrams/src/renderer/connector/ConnectorContextualMenu.types.ts @@ -97,7 +97,6 @@ export interface GQLWorkbenchSelection { export interface GQLWorkbenchSelectionEntry { id: string; - label: string; kind: string; } diff --git a/packages/diagrams/frontend/sirius-components-diagrams/src/renderer/palette/usePalette.tsx b/packages/diagrams/frontend/sirius-components-diagrams/src/renderer/palette/usePalette.tsx index 03298fcc5a..4f519e6a65 100644 --- a/packages/diagrams/frontend/sirius-components-diagrams/src/renderer/palette/usePalette.tsx +++ b/packages/diagrams/frontend/sirius-components-diagrams/src/renderer/palette/usePalette.tsx @@ -12,7 +12,7 @@ *******************************************************************************/ import { gql, useMutation, useQuery } from '@apollo/client'; -import { useDeletionConfirmationDialog, useMultiToast } from '@eclipse-sirius/sirius-components-core'; +import { useDeletionConfirmationDialog, useMultiToast, useSelection } from '@eclipse-sirius/sirius-components-core'; import { Edge, Node, useStoreApi } from '@xyflow/react'; import { useCallback, useContext, useEffect } from 'react'; import { DiagramContext } from '../../contexts/DiagramContext'; @@ -98,6 +98,12 @@ const invokeSingleClickOnDiagramElementToolMutation = gql` invokeSingleClickOnDiagramElementTool(input: $input) { __typename ... on InvokeSingleClickOnDiagramElementToolSuccessPayload { + newSelection { + entries { + id + kind + } + } messages { body level @@ -177,6 +183,7 @@ export const usePalette = ({ const { addErrorMessage, addMessages } = useMultiToast(); const { showDeletionConfirmation } = useDeletionConfirmationDialog(); const { showDialog } = useDialog(); + const { setSelection } = useSelection(); const { data: paletteData, error: paletteError } = useQuery( getPaletteQuery, @@ -228,6 +235,10 @@ export const usePalette = ({ if (data) { const { invokeSingleClickOnDiagramElementTool } = data; if (isInvokeSingleClickSuccessPayload(invokeSingleClickOnDiagramElementTool)) { + const { newSelection } = invokeSingleClickOnDiagramElementTool; + if (newSelection?.entries.length ?? 0 > 0) { + setSelection(newSelection); + } addMessages(invokeSingleClickOnDiagramElementTool.messages); } if (isErrorPayload(invokeSingleClickOnDiagramElementTool)) { diff --git a/packages/diagrams/frontend/sirius-components-diagrams/src/renderer/palette/usePalette.types.ts b/packages/diagrams/frontend/sirius-components-diagrams/src/renderer/palette/usePalette.types.ts index 9e12fe503a..efa2cd84ad 100644 --- a/packages/diagrams/frontend/sirius-components-diagrams/src/renderer/palette/usePalette.types.ts +++ b/packages/diagrams/frontend/sirius-components-diagrams/src/renderer/palette/usePalette.types.ts @@ -115,7 +115,6 @@ export interface GQLWorkbenchSelection { export interface GQLWorkbenchSelectionEntry { id: string; - label: string; kind: string; } diff --git a/packages/diagrams/frontend/sirius-components-diagrams/src/renderer/selection/useDiagramSelection.ts b/packages/diagrams/frontend/sirius-components-diagrams/src/renderer/selection/useDiagramSelection.ts index 04e67f25f9..354de45178 100644 --- a/packages/diagrams/frontend/sirius-components-diagrams/src/renderer/selection/useDiagramSelection.ts +++ b/packages/diagrams/frontend/sirius-components-diagrams/src/renderer/selection/useDiagramSelection.ts @@ -12,11 +12,18 @@ *******************************************************************************/ import { SelectionEntry, useSelection } from '@eclipse-sirius/sirius-components-core'; -import { Edge, Node, useOnSelectionChange, useReactFlow } from '@xyflow/react'; +import { Edge, Node, useOnSelectionChange, useReactFlow, useStoreApi } from '@xyflow/react'; import { useCallback, useEffect, useState } from 'react'; import { useStore } from '../../representation/useStore'; import { EdgeData, NodeData } from '../DiagramRenderer.types'; +// Compute a deterministic key from a selection +const selectionKey = (entries: SelectionEntry[]) => { + return JSON.stringify( + entries.map((selectionEntry) => selectionEntry.id).sort((id1: string, id2: string) => id1.localeCompare(id2)) + ); +}; + export const useDiagramSelection = (onShiftSelection: boolean): void => { const { selection, setSelection } = useSelection(); const [shiftSelection, setShiftSelection] = useState([]); @@ -24,86 +31,112 @@ export const useDiagramSelection = (onShiftSelection: boolean): void => { const { fitView } = useReactFlow, Edge>(); const { getNodes, setNodes, getEdges, setEdges } = useStore(); + // Called when the worbench-level selection is changed. + // Apply it on our diagram by selecting exactly the diagram elements + // present which correspond to the workbench-selected semantic elements. useEffect(() => { - const diagramElementIds: string[] = [ + const allDiagramElements = [...getNodes(), ...getEdges()]; + const displayedSemanticElements: Set = new Set([ ...getNodes().map((node) => node.data.targetObjectId), ...getEdges().map((edge) => edge.data?.targetObjectId ?? ''), - ]; - - const selectionDiagramEntryIds = selection.entries + ]); + const displayedSemanticElementsToSelect = selection.entries .map((entry) => entry.id) - .filter((id) => diagramElementIds.includes(id)) + .filter((id) => displayedSemanticElements.has(id)) + .sort((id1: string, id2: string) => id1.localeCompare(id2)); + + const semanticElementsAlreadySelectedOnDiagram = allDiagramElements + .filter((element) => element.selected) + .map((element) => element.data?.targetObjectId ?? '') .sort((id1: string, id2: string) => id1.localeCompare(id2)); - const selectedDiagramElementIds = [ - ...new Set( - [...getNodes(), ...getEdges()] - .filter((element) => element.selected) - .map((element) => element.data?.targetObjectId ?? '') - ), - ]; - selectedDiagramElementIds.sort((id1: string, id2: string) => id1.localeCompare(id2)); - if (JSON.stringify(selectionDiagramEntryIds) !== JSON.stringify(selectedDiagramElementIds)) { - const newNodeSelection = getNodes().map((node) => { - return { ...node, selected: selectionDiagramEntryIds.includes(node.data.targetObjectId) }; + + if ( + JSON.stringify(displayedSemanticElementsToSelect) !== JSON.stringify(semanticElementsAlreadySelectedOnDiagram) + ) { + const nodesToReveal: Set = new Set(); + const newNodes = getNodes().map((node) => { + const selected = displayedSemanticElementsToSelect.includes(node.data.targetObjectId); + const newNode = { ...node, selected }; + if (selected) { + nodesToReveal.add(newNode.id); + } + return newNode; }); - const newEdgeSelection = getEdges().map((edge) => { - return { ...edge, selected: selectionDiagramEntryIds.includes(edge.data ? edge.data.targetObjectId : '') }; + const newEdges = getEdges().map((edge) => { + const selected = displayedSemanticElementsToSelect.includes(edge.data ? edge.data.targetObjectId : ''); + const newEdge = { ...edge, selected }; + if (selected) { + // React Flow does not support "fit on edge", so include its source & target nodes + // to ensure the edge is visible and in context + nodesToReveal.add(newEdge.source); + nodesToReveal.add(newEdge.target); + } + return newEdge; }); - setEdges(newEdgeSelection); - setNodes(newNodeSelection); - - const fitViewNodes = newNodeSelection.filter((node) => { - // React Flow does not support "fit on edge", so fit on its source & target nodes to ensure it is visible and in context - return ( - node.selected || - newEdgeSelection - .filter((edge) => edge.selected) - .flatMap((edge) => [edge.source, edge.target]) - .includes(node.id) - ); - }); - fitView({ nodes: fitViewNodes, maxZoom: 1.5, duration: 1000 }); + setEdges(newEdges); + setNodes(newNodes); + + fitView({ nodes: getNodes().filter((node) => nodesToReveal.has(node.id)), maxZoom: 1.5, duration: 1000 }); } }, [selection]); + const store = useStoreApi, Edge>(); const onChange = useCallback( ({ nodes, edges }) => { - const diagramElementIds: string[] = [ - ...getNodes().map((node) => node.data.targetObjectId), - ...getEdges().map((edge) => edge.data?.targetObjectId ?? ''), - ]; - const selectionEntries: SelectionEntry[] = [...nodes, ...edges].reduce((uniqueIds, node) => { - const { targetObjectId, targetObjectKind, targetObjectLabel } = node.data; - const existingEntry = uniqueIds.find((entry: SelectionEntry) => entry.id === targetObjectId); - if (!existingEntry) { - uniqueIds.push({ - id: targetObjectId, - kind: targetObjectKind, - label: targetObjectLabel, - }); - } - return uniqueIds; - }, []); + const semanticElementsDisplayedOnDiagram: Set = new Set([ + ...store.getState().nodes.map((node) => node.data.targetObjectId), + ...store.getState().edges.map((edge) => edge.data?.targetObjectId ?? ''), + ]); + + const semanticElementsSelectedOnDiagram: Set = new Set([ + ...nodes.map((node) => node.data.targetObjectId), + ...edges.map((edge) => edge.data?.targetObjectId ?? ''), + ]); + + const semanticElementsUnselectedOnDiagram: Set = new Set( + [...semanticElementsDisplayedOnDiagram].filter((id) => !semanticElementsSelectedOnDiagram.has(id)) + ); + + const semanticElementsSelectedInWorkbench: Set = new Set( + selection.entries + .filter((entry) => entry.kind.startsWith('siriusComponents://semantic?')) + .map((entry) => entry.id) + ); - const selectionDiagramEntryIds = selection.entries - .map((selectionEntry) => selectionEntry.id) - .filter((id) => diagramElementIds.includes(id)) - .sort((id1: string, id2: string) => id1.localeCompare(id2)); + const nextSemanticElementsToSelect: Set = new Set( + [...semanticElementsSelectedOnDiagram, ...semanticElementsSelectedInWorkbench].filter( + (id) => !semanticElementsUnselectedOnDiagram.has(id) + ) + ); - const selectedDiagramElementIds = selectionEntries - .map((entry) => entry.id) - .sort((id1: string, id2: string) => id1.localeCompare(id2)); + const selectionEntriesFromDiagram: SelectionEntry[] = [...nodes, ...edges].map((node) => { + const { targetObjectId, targetObjectKind } = node.data; + return { + id: targetObjectId, + kind: targetObjectKind, + }; + }); + const selectionEntriesFromWorkbench: SelectionEntry[] = selection.entries.filter( + (entry) => entry.kind.startsWith('siriusComponents://semantic?') && nextSemanticElementsToSelect.has(entry.id) + ); + + const nextSelectionEntries = [...selectionEntriesFromDiagram]; + selectionEntriesFromWorkbench.forEach((candidate) => { + if (!nextSelectionEntries.find((entry) => entry.id === candidate.id)) { + nextSelectionEntries.push(candidate); + } + }); - if (JSON.stringify(selectedDiagramElementIds) !== JSON.stringify(selectionDiagramEntryIds)) { + if (selectionKey(nextSelectionEntries) !== selectionKey(selection.entries)) { if (onShiftSelection) { - setShiftSelection(selectionEntries); + setShiftSelection(nextSelectionEntries); } else { - setSelection({ entries: selectionEntries }); + setSelection({ entries: nextSelectionEntries }); } } }, - [selection, onShiftSelection] + [selection, onShiftSelection, store] ); useOnSelectionChange({ onChange });