From 3509fe0d276127aa2ae5695545ce4f92a9ca2b73 Mon Sep 17 00:00:00 2001 From: Liliia Makarova Date: Wed, 3 Jun 2026 10:35:09 +0300 Subject: [PATCH 1/2] fix(ConnectionState): fix connection selection reset --- src/store/connection/ConnectionList.ts | 21 +---------- src/store/connection/ConnectionState.ts | 47 ++++++++++++++++--------- 2 files changed, 32 insertions(+), 36 deletions(-) diff --git a/src/store/connection/ConnectionList.ts b/src/store/connection/ConnectionList.ts index 2cba2048..7be48831 100644 --- a/src/store/connection/ConnectionList.ts +++ b/src/store/connection/ConnectionList.ts @@ -164,25 +164,6 @@ export class ConnectionsStore { this.ports.deletePorts(ports); } - protected setSelection( - connection: ConnectionState | TConnectionId, - selected: boolean, - params?: { ignoreChange: boolean } - ) { - const state = connection instanceof ConnectionState ? connection : this.$connectionsMap.value.get(connection); - if (state) { - if (selected !== Boolean(state.$state.value.selected)) { - if (!params?.ignoreChange) { - state.updateConnection({ - selected, - }); - } - return true; - } - } - return false; - } - public updateConnections(connections: TConnection[]) { this.$connectionsMap.value = connections.reduce((acc, connection) => { const c = this.getOrCreateConnection(connection); @@ -231,7 +212,7 @@ export class ConnectionsStore { public deleteSelectedConnections() { this.$connections.value.forEach((c) => { - if (c.$state.value.selected) { + if (c.$selected.value) { c.destroy(); // Clean up port observers this.$connectionsMap.value.delete(c.id); } diff --git a/src/store/connection/ConnectionState.ts b/src/store/connection/ConnectionState.ts index 927b6e50..7c81ebf5 100644 --- a/src/store/connection/ConnectionState.ts +++ b/src/store/connection/ConnectionState.ts @@ -42,7 +42,24 @@ export type TConnection = { } & (TConnectionBlockPoint | TConnectionPortPoint); export class ConnectionState { - public $state = signal(undefined); + protected $rawState = signal(undefined); + + /** + * Computed signal that reactively determines if this connection is selected + * by checking if its ID exists in the selection bucket + */ + public readonly $selected = computed(() => { + return this.connectionSelectionBucket.$selected.value.has(this.$rawState.value.id); + }); + + /** + * Connection state signal. + * Derives `selected` from the selection bucket, consistent with BlockState pattern. + */ + public $state = computed(() => ({ + ...this.$rawState.value, + selected: this.$selected.value, + })); private isDestroyed = false; @@ -156,14 +173,6 @@ export class ConnectionState { return undefined; }); - /** - * Computed signal that reactively determines if this connection is selected - * by checking if its ID exists in the selection bucket - */ - public readonly $selected = computed(() => { - return this.connectionSelectionBucket.$selected.value.has(this.id); - }); - public static getConnectionId(connection: TConnection) { if (connection.id) return connection.id; if (connection.sourceAnchorId && connection.targetAnchorId) { @@ -180,7 +189,7 @@ export class ConnectionState { private readonly connectionSelectionBucket: ISelectionBucket ) { const id = ConnectionState.getConnectionId(connectionState); - this.$state.value = { ...connectionState, id }; + this.$rawState.value = { ...connectionState, id } as T; } /** @@ -206,8 +215,8 @@ export class ConnectionState { * Checks if the connection is currently selected. * @returns True if the connection is selected, false otherwise. */ - public isSelected() { - return this.$state.value.selected; + public isSelected(): boolean { + return this.$selected.value; } public setSelection(selected: boolean, strategy: ESelectionStrategy = ESelectionStrategy.REPLACE) { @@ -219,7 +228,10 @@ export class ConnectionState { * @returns {TConnection} A deep copy of the connection data */ public asTConnection(): TConnection { - return cloneDeep(this.$state.toJSON()); + return cloneDeep({ + ...this.$rawState.toJSON(), + selected: this.$selected.value, + }); } /** @@ -227,7 +239,10 @@ export class ConnectionState { * @returns {TConnection} A deep copy of the connection data */ public toJSON(): TConnection { - return cloneDeep(this.$state.toJSON()); + return cloneDeep({ + ...this.$rawState.toJSON(), + selected: this.$selected.value, + }); } /** @@ -238,9 +253,9 @@ export class ConnectionState { public updateConnection(connection: Partial): void { const { styles, ...newProps } = connection; - const newStyles = Object.assign({}, this.$state.value.styles, styles); + const newStyles = Object.assign({}, this.$rawState.value.styles, styles); - this.$state.value = Object.assign({}, this.$state.value, newProps, { styles: newStyles }); + this.$rawState.value = Object.assign({}, this.$rawState.value, newProps, { styles: newStyles }); } /** From 2fef5d628e1be390328e0baa07d704fc508ee1a3 Mon Sep 17 00:00:00 2001 From: Liliia Makarova Date: Mon, 15 Jun 2026 09:29:59 +0300 Subject: [PATCH 2/2] stories for new cases --- .../connectionSelection.stories.tsx | 215 ++++++++++++++++++ 1 file changed, 215 insertions(+) create mode 100644 src/stories/api/connectionSelection/connectionSelection.stories.tsx diff --git a/src/stories/api/connectionSelection/connectionSelection.stories.tsx b/src/stories/api/connectionSelection/connectionSelection.stories.tsx new file mode 100644 index 00000000..745deeed --- /dev/null +++ b/src/stories/api/connectionSelection/connectionSelection.stories.tsx @@ -0,0 +1,215 @@ +import React, { useLayoutEffect, useState } from "react"; + +import { ThemeProvider } from "@gravity-ui/uikit"; +import type { Meta, StoryFn } from "@storybook/react-webpack5"; + +import { TBlock } from "../../../components/canvas/blocks/Block"; +import { Graph, GraphState, TGraphConfig } from "../../../graph"; +import { GraphCanvas, useGraph, useGraphEvent } from "../../../react-components"; +import { useFn } from "../../../react-components/utils/hooks/useFn"; +import { ESelectionStrategy } from "../../../services/selection/types"; +import { TConnection } from "../../../store/connection/ConnectionState"; +import { storiesSettings } from "../../configurations/definitions"; +import { BlockStory } from "../../main/Block"; + +import "@gravity-ui/uikit/styles/styles.css"; + +const blocks: TBlock[] = [ + { + x: 100, + y: 200, + width: 200, + height: 100, + id: "block-A", + is: "Block", + selected: false, + name: "Block A", + anchors: [], + }, + { + x: 450, + y: 100, + width: 200, + height: 100, + id: "block-B", + is: "Block", + selected: false, + name: "Block B", + anchors: [], + }, + { + x: 450, + y: 300, + width: 200, + height: 100, + id: "block-C", + is: "Block", + selected: false, + name: "Block C", + anchors: [], + }, + { + x: 800, + y: 200, + width: 200, + height: 100, + id: "block-D", + is: "Block", + selected: false, + name: "Block D", + anchors: [], + }, +]; + +const connections: TConnection[] = [ + { sourceBlockId: "block-A", targetBlockId: "block-B" }, + { sourceBlockId: "block-A", targetBlockId: "block-C" }, + { sourceBlockId: "block-B", targetBlockId: "block-D" }, + { sourceBlockId: "block-C", targetBlockId: "block-D" }, +]; + +const graphSettings: TGraphConfig["settings"] = { + ...storiesSettings, + useBezierConnections: true, +}; + +/** + * Demonstrates connection selection controlled via the selection bucket API. + * When a block is selected, its adjacent connections get highlighted. + * When the block is deselected, connections deselect properly without needing + * to pass `selected: false` explicitly in the connection data. + */ +const ConnectionSelectionDemo = () => { + const { graph, setEntities, start } = useGraph({ + settings: graphSettings, + }); + + useLayoutEffect(() => { + setEntities({ blocks, connections }); + }, [setEntities]); + + useGraphEvent(graph, "state-change", ({ state }) => { + if (state === GraphState.ATTACHED) { + start(); + graph.zoomTo("center", { padding: 300 }); + } + }); + + useGraphEvent(graph, "blocks-selection-change", ({ list }) => { + const selectedBlockIds = new Set(list); + + if (selectedBlockIds.size === 0) { + graph.rootStore.connectionsList.connectionSelectionBucket.updateSelection([], true, ESelectionStrategy.REPLACE); + return; + } + + const connectionIds = graph.rootStore.connectionsList.$connections.value + .filter((c) => selectedBlockIds.has(c.sourceBlockId) || selectedBlockIds.has(c.targetBlockId)) + .map((c) => c.id); + + graph.rootStore.connectionsList.connectionSelectionBucket.updateSelection( + connectionIds, + true, + ESelectionStrategy.REPLACE + ); + }); + + const renderBlock = useFn((graphObject: Graph, block: TBlock) => { + return ; + }); + + return ( + +
+ Connection Selection via Selection Bucket API +

+ Click a block to select it — adjacent connections will highlight. Click on empty canvas to deselect — + connections deselect properly. +

+
+
+ +
+
+ ); +}; + +/** + * Demonstrates the OLD approach (setEntities with `selected` field on connections). + * Previously this approach had a bug where omitting `selected` didn't reset selection. + * Now it works because ConnectionState derives `selected` from the selection bucket, + * so the `selected` field in connection data is effectively managed by the bucket. + */ +const ConnectionSelectionViaSetEntitiesDemo = () => { + const [selectedBlocks, setSelectedBlocks] = useState([]); + + const { graph, setEntities, start } = useGraph({ + settings: graphSettings, + }); + + useLayoutEffect(() => { + setEntities({ blocks, connections }); + }, [setEntities]); + + useGraphEvent(graph, "state-change", ({ state }) => { + if (state === GraphState.ATTACHED) { + start(); + graph.zoomTo("center", { padding: 300 }); + } + }); + + useGraphEvent(graph, "blocks-selection-change", ({ list }) => { + setSelectedBlocks(list as string[]); + }); + + useLayoutEffect(() => { + if (!graph) return; + const selectionIds = new Set(selectedBlocks); + + if (selectedBlocks.length === 0) { + graph.rootStore.connectionsList.connectionSelectionBucket.updateSelection([], true, ESelectionStrategy.REPLACE); + } else { + const connectionIds = graph.rootStore.connectionsList.$connections.value + .filter((c) => selectionIds.has(c.sourceBlockId as string) || selectionIds.has(c.targetBlockId as string)) + .map((c) => c.id); + + graph.rootStore.connectionsList.connectionSelectionBucket.updateSelection( + connectionIds, + true, + ESelectionStrategy.REPLACE + ); + } + }, [selectedBlocks, graph]); + + const renderBlock = useFn((graphObject: Graph, block: TBlock) => { + return ; + }); + + return ( + +
+ Connection Selection via React State + Selection API +

+ Click a block — connections linked to it highlight. Click empty canvas to deselect all. Selected blocks: [ + {selectedBlocks.join(", ") || "none"}] +

+
+
+ +
+
+ ); +}; + +const meta: Meta = { + title: "Api/Connection Selection", + component: ConnectionSelectionDemo, +}; + +export default meta; + +export const ViaSelectionBucket: StoryFn = () => ; +ViaSelectionBucket.storyName = "Selection Bucket API"; + +export const ViaReactState: StoryFn = () => ; +ViaReactState.storyName = "React State + Selection API";