Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 1 addition & 20 deletions src/store/connection/ConnectionList.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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);
}
Expand Down
47 changes: 31 additions & 16 deletions src/store/connection/ConnectionState.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,24 @@ export type TConnection = {
} & (TConnectionBlockPoint | TConnectionPortPoint);

export class ConnectionState<T extends TConnection = TConnection> {
public $state = signal<T>(undefined);
protected $rawState = signal<T>(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;

Expand Down Expand Up @@ -156,14 +173,6 @@ export class ConnectionState<T extends TConnection = TConnection> {
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) {
Expand All @@ -180,7 +189,7 @@ export class ConnectionState<T extends TConnection = TConnection> {
private readonly connectionSelectionBucket: ISelectionBucket<string | number>
) {
const id = ConnectionState.getConnectionId(connectionState);
this.$state.value = { ...connectionState, id };
this.$rawState.value = { ...connectionState, id } as T;
}

/**
Expand All @@ -206,8 +215,8 @@ export class ConnectionState<T extends TConnection = TConnection> {
* 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) {
Expand All @@ -219,15 +228,21 @@ export class ConnectionState<T extends TConnection = TConnection> {
* @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,
});
}

/**
* Converts the connection state to a plain JSON object
* @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,
});
}

/**
Expand All @@ -238,9 +253,9 @@ export class ConnectionState<T extends TConnection = TConnection> {
public updateConnection(connection: Partial<TConnection>): 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 });
}

/**
Expand Down
215 changes: 215 additions & 0 deletions src/stories/api/connectionSelection/connectionSelection.stories.tsx
Original file line number Diff line number Diff line change
@@ -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 <BlockStory graph={graphObject} block={block} />;
});

return (
<ThemeProvider theme="dark">
<div style={{ padding: "16px 16px 8px", fontFamily: "sans-serif", fontSize: "14px" }}>
<strong>Connection Selection via Selection Bucket API</strong>
<p style={{ margin: "4px 0 12px", opacity: 0.7 }}>
Click a block to select it — adjacent connections will highlight. Click on empty canvas to deselect —
connections deselect properly.
</p>
</div>
<div style={{ height: "500px" }}>
<GraphCanvas className="graph" graph={graph} renderBlock={renderBlock} />
</div>
</ThemeProvider>
);
};

/**
* 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<string[]>([]);

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 <BlockStory graph={graphObject} block={block} />;
});

return (
<ThemeProvider theme="dark">
<div style={{ padding: "16px 16px 8px", fontFamily: "sans-serif", fontSize: "14px" }}>
<strong>Connection Selection via React State + Selection API</strong>
<p style={{ margin: "4px 0 12px", opacity: 0.7 }}>
Click a block — connections linked to it highlight. Click empty canvas to deselect all. Selected blocks: [
{selectedBlocks.join(", ") || "none"}]
</p>
</div>
<div style={{ height: "500px" }}>
<GraphCanvas className="graph" graph={graph} renderBlock={renderBlock} />
</div>
</ThemeProvider>
);
};

const meta: Meta = {
title: "Api/Connection Selection",
component: ConnectionSelectionDemo,
};

export default meta;

export const ViaSelectionBucket: StoryFn = () => <ConnectionSelectionDemo />;
ViaSelectionBucket.storyName = "Selection Bucket API";

export const ViaReactState: StoryFn = () => <ConnectionSelectionViaSetEntitiesDemo />;
ViaReactState.storyName = "React State + Selection API";
Loading