This is an automated email from the ASF dual-hosted git repository.
mcgilman pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/nifi.git
The following commit(s) were added to refs/heads/main by this push:
new 8c8d0c442d6 NIFI-15654 - Avoid overlapping connections, warn of
existing overlaps (#10943)
8c8d0c442d6 is described below
commit 8c8d0c442d6660fc653a0dba945da896f272465f
Author: Rob Fellows <[email protected]>
AuthorDate: Thu Mar 5 13:00:03 2026 -0500
NIFI-15654 - Avoid overlapping connections, warn of existing overlaps
(#10943)
* NIFI-15654 - Avoid overlapping connections, warn of existing overlaps
* address review feedback - align overlap warning banner with the graph
controls
---
.../behavior/connectable-behavior.service.ts | 139 +-------
.../service/canvas-utils.service.spec.ts | 356 ++++++++++++++++++++-
.../flow-designer/service/canvas-utils.service.ts | 168 ++++++++++
.../service/manager/connection-manager.service.ts | 30 ++
.../pages/flow-designer/state/flow/flow.actions.ts | 6 +
.../pages/flow-designer/state/flow/flow.effects.ts | 22 ++
.../flow-designer/state/flow/flow.selectors.ts | 15 +
.../app/pages/flow-designer/state/flow/index.ts | 5 +
.../flow-designer/ui/canvas/canvas.component.html | 6 +
.../flow-designer/ui/canvas/canvas.component.scss | 13 +
.../ui/canvas/canvas.component.spec.ts | 2 +
.../flow-designer/ui/canvas/canvas.component.ts | 14 +
.../pages/flow-designer/ui/canvas/canvas.module.ts | 4 +-
.../graph-controls/graph-controls.component.scss | 2 +-
.../navigation-control.component.html | 35 +-
.../edit-connection/edit-connection.component.ts | 19 +-
.../app/ui/common/overlap-detection.utils.spec.ts | 250 +++++++++++++++
.../src/app/ui/common/overlap-detection.utils.ts | 131 ++++++++
...apping-connections-banner.component-theme.scss} | 23 +-
.../overlapping-connections-banner.component.html | 36 +++
.../overlapping-connections-banner.component.scss} | 8 +-
...verlapping-connections-banner.component.spec.ts | 103 ++++++
.../overlapping-connections-banner.component.ts} | 22 +-
.../src/main/frontend/apps/nifi/src/styles.scss | 4 +
24 files changed, 1250 insertions(+), 163 deletions(-)
diff --git
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/service/behavior/connectable-behavior.service.ts
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/service/behavior/connectable-behavior.service.ts
index d76d65eca58..68fb1e574dc 100644
---
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/service/behavior/connectable-behavior.service.ts
+++
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/service/behavior/connectable-behavior.service.ts
@@ -24,7 +24,6 @@ import { openNewConnectionDialog, selectComponents } from
'../../state/flow/flow
import { ConnectionManager } from '../manager/connection-manager.service';
import { Position } from '../../state/shared';
import { CreateConnectionRequest } from '../../state/flow';
-import { NiFiCommon } from '@nifi/shared';
@Injectable({
providedIn: 'root'
@@ -32,7 +31,6 @@ import { NiFiCommon } from '@nifi/shared';
export class ConnectableBehavior {
private store = inject<Store<CanvasState>>(Store);
private canvasUtils = inject(CanvasUtils);
- private nifiCommon = inject(NiFiCommon);
private readonly connect: any;
private origin: any;
@@ -254,143 +252,8 @@ export class ConnectableBehavior {
});
}
- /**
- * Calculate bend points for a new Connection if necessary.
- *
- * @param sourceData
- * @param destinationData
- */
private calculateInitialBendPoints(sourceData: any, destinationData: any):
Position[] {
- const bends: Position[] = [];
-
- if (sourceData.id == destinationData.id) {
- const rightCenter: Position = {
- x: sourceData.position.x + sourceData.dimensions.width,
- y: sourceData.position.y + sourceData.dimensions.height / 2
- };
-
- const xOffset = ConnectionManager.SELF_LOOP_X_OFFSET;
- const yOffset = ConnectionManager.SELF_LOOP_Y_OFFSET;
- bends.push({
- x: rightCenter.x + xOffset,
- y: rightCenter.y - yOffset
- });
- bends.push({
- x: rightCenter.x + xOffset,
- y: rightCenter.y + yOffset
- });
- } else {
- const existingConnections: any[] = [];
-
- // get all connections for the source component
- const connectionsForSourceComponent: any[] =
this.canvasUtils.getComponentConnections(sourceData.id);
-
connectionsForSourceComponent.forEach((connectionForSourceComponent) => {
- // get the id for the source/destination component
- const connectionSourceComponentId =
-
this.canvasUtils.getConnectionSourceComponentId(connectionForSourceComponent);
- const connectionDestinationComponentId =
-
this.canvasUtils.getConnectionDestinationComponentId(connectionForSourceComponent);
-
- // if the connection is between these same components,
consider it for collisions
- if (
- (connectionSourceComponentId === sourceData.id &&
- connectionDestinationComponentId ===
destinationData.id) ||
- (connectionDestinationComponentId === sourceData.id &&
- connectionSourceComponentId === destinationData.id)
- ) {
- // record all connections between these two components in
question
- existingConnections.push(connectionForSourceComponent);
- }
- });
-
- // if there are existing connections between these components,
ensure the new connection won't collide
- if (existingConnections) {
- const avoidCollision =
existingConnections.some((existingConnection) => {
- // only consider multiple connections with no bend points
a collision, the existence of
- // bend points suggests that the user has placed the
connection into a desired location
- return this.nifiCommon.isEmpty(existingConnection.bends);
- });
-
- // if we need to avoid a collision
- if (avoidCollision) {
- // determine the middle of the source/destination
components
- const sourceMiddle: Position = {
- x: sourceData.position.x + sourceData.dimensions.width
/ 2,
- y: sourceData.position.y +
sourceData.dimensions.height / 2
- };
- const destinationMiddle: Position = {
- x: destinationData.position.x +
destinationData.dimensions.width / 2,
- y: destinationData.position.y +
destinationData.dimensions.height / 2
- };
-
- // detect if the line is more horizontal or vertical
- const slope = (sourceMiddle.y - destinationMiddle.y) /
(sourceMiddle.x - destinationMiddle.x);
- const isMoreHorizontal = slope <= 1 && slope >= -1;
-
- // find the midpoint on the connection
- const xCandidate = (sourceMiddle.x + destinationMiddle.x)
/ 2;
- const yCandidate = (sourceMiddle.y + destinationMiddle.y)
/ 2;
-
- // attempt to position this connection so it doesn't
collide
- let xStep = isMoreHorizontal ? 0 :
ConnectionManager.CONNECTION_OFFSET_X_INCREMENT;
- let yStep = isMoreHorizontal ?
ConnectionManager.CONNECTION_OFFSET_Y_INCREMENT : 0;
-
- let positioned = false;
- while (!positioned) {
- // consider above and below, then increment and try
again (if necessary)
- if (!this.collides(existingConnections, xCandidate -
xStep, yCandidate - yStep)) {
- bends.push({
- x: xCandidate - xStep,
- y: yCandidate - yStep
- });
- positioned = true;
- } else if (!this.collides(existingConnections,
xCandidate + xStep, yCandidate + yStep)) {
- bends.push({
- x: xCandidate + xStep,
- y: yCandidate + yStep
- });
- positioned = true;
- }
-
- if (isMoreHorizontal) {
- yStep +=
ConnectionManager.CONNECTION_OFFSET_Y_INCREMENT;
- } else {
- xStep +=
ConnectionManager.CONNECTION_OFFSET_X_INCREMENT;
- }
- }
- }
- }
- }
-
- return bends;
- }
-
- /**
- * Determines if the specified coordinate collides with another connection.
- *
- * @param existingConnections
- * @param x
- * @param y
- */
- private collides(existingConnections: any[], x: number, y: number):
boolean {
- return existingConnections.some((existingConnection) => {
- if (!this.nifiCommon.isEmpty(existingConnection.bends)) {
- let labelIndex = existingConnection.labelIndex;
- if (labelIndex >= existingConnection.bends.length) {
- labelIndex = 0;
- }
-
- // determine collision based on y space or x space depending
on whether the connection is more horizontal
- return (
- existingConnection.bends[labelIndex].y - 25 < y &&
- existingConnection.bends[labelIndex].y + 25 > y &&
- existingConnection.bends[labelIndex].x - 100 < x &&
- existingConnection.bends[labelIndex].x + 100 > x
- );
- }
-
- return false;
- });
+ return
this.canvasUtils.calculateBendPointsForCollisionAvoidance(sourceData,
destinationData);
}
/**
diff --git
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/service/canvas-utils.service.spec.ts
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/service/canvas-utils.service.spec.ts
index 7ed80573fcc..f22a537737a 100644
---
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/service/canvas-utils.service.spec.ts
+++
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/service/canvas-utils.service.spec.ts
@@ -23,8 +23,8 @@ import { flowFeatureKey } from '../state/flow';
import * as fromFlow from '../state/flow/flow.reducer';
import { transformFeatureKey } from '../state/transform';
import * as fromTransform from '../state/transform/transform.reducer';
-import { provideMockStore } from '@ngrx/store/testing';
-import { selectFlowState } from '../state/flow/flow.selectors';
+import { MockStore, provideMockStore } from '@ngrx/store/testing';
+import { selectConnections, selectCurrentProcessGroupId, selectFlowState }
from '../state/flow/flow.selectors';
import { controllerServicesFeatureKey } from '../state/controller-services';
import * as fromControllerServices from
'../state/controller-services/controller-services.reducer';
import { selectCurrentUser } from
'../../../state/current-user/current-user.selectors';
@@ -659,4 +659,356 @@ describe('CanvasUtils', () => {
expect(bulletinIcon.classed('info')).toBe(false);
});
});
+
+ describe('calculateBendPointsForCollisionAvoidance', () => {
+ it('should return self-loop bend points when source and destination
are the same', () => {
+ const componentData = {
+ id: 'proc-a',
+ position: { x: 100, y: 100 },
+ dimensions: { width: 200, height: 100 }
+ };
+
+ const bends =
service.calculateBendPointsForCollisionAvoidance(componentData, componentData);
+
+ expect(bends).toHaveLength(2);
+ expect(bends[0].x).toBeGreaterThan(componentData.position.x +
componentData.dimensions.width);
+ expect(bends[1].x).toBeGreaterThan(componentData.position.x +
componentData.dimensions.width);
+ });
+
+ it('should return empty bends when no existing connections between
components', () => {
+ const sourceData = {
+ id: 'proc-a',
+ position: { x: 0, y: 0 },
+ dimensions: { width: 200, height: 100 }
+ };
+ const destData = {
+ id: 'proc-b',
+ position: { x: 400, y: 0 },
+ dimensions: { width: 200, height: 100 }
+ };
+
+ const bends =
service.calculateBendPointsForCollisionAvoidance(sourceData, destData);
+
+ expect(bends).toHaveLength(0);
+ });
+
+ it('should exclude specified connection from collision checks', () => {
+ const sourceData = {
+ id: 'proc-a',
+ position: { x: 0, y: 0 },
+ dimensions: { width: 200, height: 100 }
+ };
+ const destData = {
+ id: 'proc-b',
+ position: { x: 400, y: 0 },
+ dimensions: { width: 200, height: 100 }
+ };
+
+ const bends =
service.calculateBendPointsForCollisionAvoidance(sourceData, destData,
'conn-to-exclude');
+
+ expect(bends).toHaveLength(0);
+ });
+
+ it('should add a bend point when an existing straight-line connection
exists between the same components', () => {
+ const store = TestBed.inject(MockStore);
+
+ const existingConnection = {
+ id: 'existing-conn',
+ sourceId: 'proc-a',
+ sourceGroupId: 'root',
+ destinationId: 'proc-b',
+ destinationGroupId: 'root',
+ bends: [],
+ labelIndex: 0
+ };
+
+ store.overrideSelector(selectConnections, [existingConnection] as
any);
+ store.overrideSelector(selectCurrentProcessGroupId, 'root');
+ store.refreshState();
+
+ const sourceData = {
+ id: 'proc-a',
+ position: { x: 0, y: 0 },
+ dimensions: { width: 200, height: 100 }
+ };
+ const destData = {
+ id: 'proc-b',
+ position: { x: 400, y: 0 },
+ dimensions: { width: 200, height: 100 }
+ };
+
+ const bends =
service.calculateBendPointsForCollisionAvoidance(sourceData, destData);
+
+ expect(bends).toHaveLength(1);
+ expect(bends[0]).toEqual(expect.objectContaining({ x:
expect.any(Number), y: expect.any(Number) }));
+ });
+
+ it('should not add a bend point when existing connection already has
bends', () => {
+ const store = TestBed.inject(MockStore);
+
+ const existingConnection = {
+ id: 'existing-conn',
+ sourceId: 'proc-a',
+ sourceGroupId: 'root',
+ destinationId: 'proc-b',
+ destinationGroupId: 'root',
+ bends: [{ x: 300, y: 100 }],
+ labelIndex: 0
+ };
+
+ store.overrideSelector(selectConnections, [existingConnection] as
any);
+ store.overrideSelector(selectCurrentProcessGroupId, 'root');
+ store.refreshState();
+
+ const sourceData = {
+ id: 'proc-a',
+ position: { x: 0, y: 0 },
+ dimensions: { width: 200, height: 100 }
+ };
+ const destData = {
+ id: 'proc-b',
+ position: { x: 400, y: 0 },
+ dimensions: { width: 200, height: 100 }
+ };
+
+ const bends =
service.calculateBendPointsForCollisionAvoidance(sourceData, destData);
+
+ expect(bends).toHaveLength(0);
+ });
+
+ it('should place bend point offset vertically for horizontal
connections', () => {
+ const store = TestBed.inject(MockStore);
+
+ const existingConnection = {
+ id: 'existing-conn',
+ sourceId: 'proc-a',
+ sourceGroupId: 'root',
+ destinationId: 'proc-b',
+ destinationGroupId: 'root',
+ bends: [],
+ labelIndex: 0
+ };
+
+ store.overrideSelector(selectConnections, [existingConnection] as
any);
+ store.overrideSelector(selectCurrentProcessGroupId, 'root');
+ store.refreshState();
+
+ const sourceData = {
+ id: 'proc-a',
+ position: { x: 0, y: 0 },
+ dimensions: { width: 200, height: 100 }
+ };
+ const destData = {
+ id: 'proc-b',
+ position: { x: 500, y: 0 },
+ dimensions: { width: 200, height: 100 }
+ };
+
+ const bends =
service.calculateBendPointsForCollisionAvoidance(sourceData, destData);
+
+ expect(bends).toHaveLength(1);
+ const midX = (100 + 600) / 2;
+ expect(bends[0].x).toBe(midX);
+ expect(bends[0].y).not.toBe(50);
+ });
+
+ it('should place bend point offset horizontally for vertical
connections', () => {
+ const store = TestBed.inject(MockStore);
+
+ const existingConnection = {
+ id: 'existing-conn',
+ sourceId: 'proc-a',
+ sourceGroupId: 'root',
+ destinationId: 'proc-b',
+ destinationGroupId: 'root',
+ bends: [],
+ labelIndex: 0
+ };
+
+ store.overrideSelector(selectConnections, [existingConnection] as
any);
+ store.overrideSelector(selectCurrentProcessGroupId, 'root');
+ store.refreshState();
+
+ const sourceData = {
+ id: 'proc-a',
+ position: { x: 0, y: 0 },
+ dimensions: { width: 200, height: 100 }
+ };
+ const destData = {
+ id: 'proc-b',
+ position: { x: 0, y: 500 },
+ dimensions: { width: 200, height: 100 }
+ };
+
+ const bends =
service.calculateBendPointsForCollisionAvoidance(sourceData, destData);
+
+ expect(bends).toHaveLength(1);
+ const midY = (50 + 550) / 2;
+ expect(bends[0].y).toBe(midY);
+ expect(bends[0].x).not.toBe(100);
+ });
+
+ it('should detect collision for bidirectional connections (destination
to source)', () => {
+ const store = TestBed.inject(MockStore);
+
+ const existingConnection = {
+ id: 'existing-conn',
+ sourceId: 'proc-b',
+ sourceGroupId: 'root',
+ destinationId: 'proc-a',
+ destinationGroupId: 'root',
+ bends: [],
+ labelIndex: 0
+ };
+
+ store.overrideSelector(selectConnections, [existingConnection] as
any);
+ store.overrideSelector(selectCurrentProcessGroupId, 'root');
+ store.refreshState();
+
+ const sourceData = {
+ id: 'proc-a',
+ position: { x: 0, y: 0 },
+ dimensions: { width: 200, height: 100 }
+ };
+ const destData = {
+ id: 'proc-b',
+ position: { x: 400, y: 0 },
+ dimensions: { width: 200, height: 100 }
+ };
+
+ const bends =
service.calculateBendPointsForCollisionAvoidance(sourceData, destData);
+
+ expect(bends).toHaveLength(1);
+ });
+
+ it('should exclude a specific connection by ID during collision
check', () => {
+ const store = TestBed.inject(MockStore);
+
+ const existingConnection = {
+ id: 'conn-being-updated',
+ sourceId: 'proc-a',
+ sourceGroupId: 'root',
+ destinationId: 'proc-b',
+ destinationGroupId: 'root',
+ bends: [],
+ labelIndex: 0
+ };
+
+ store.overrideSelector(selectConnections, [existingConnection] as
any);
+ store.overrideSelector(selectCurrentProcessGroupId, 'root');
+ store.refreshState();
+
+ const sourceData = {
+ id: 'proc-a',
+ position: { x: 0, y: 0 },
+ dimensions: { width: 200, height: 100 }
+ };
+ const destData = {
+ id: 'proc-b',
+ position: { x: 400, y: 0 },
+ dimensions: { width: 200, height: 100 }
+ };
+
+ const bends =
service.calculateBendPointsForCollisionAvoidance(sourceData, destData,
'conn-being-updated');
+
+ expect(bends).toHaveLength(0);
+ });
+
+ it('should avoid colliding with existing bend points when placing new
bend', () => {
+ const store = TestBed.inject(MockStore);
+
+ const midX = (100 + 500) / 2;
+ const midY = 50;
+
+ const connections = [
+ {
+ id: 'straight-conn',
+ sourceId: 'proc-a',
+ sourceGroupId: 'root',
+ destinationId: 'proc-b',
+ destinationGroupId: 'root',
+ bends: [],
+ labelIndex: 0
+ },
+ {
+ id: 'bent-conn',
+ sourceId: 'proc-a',
+ sourceGroupId: 'root',
+ destinationId: 'proc-b',
+ destinationGroupId: 'root',
+ bends: [{ x: midX, y: midY - 75 }],
+ labelIndex: 0
+ }
+ ];
+
+ store.overrideSelector(selectConnections, connections as any);
+ store.overrideSelector(selectCurrentProcessGroupId, 'root');
+ store.refreshState();
+
+ const sourceData = {
+ id: 'proc-a',
+ position: { x: 0, y: 0 },
+ dimensions: { width: 200, height: 100 }
+ };
+ const destData = {
+ id: 'proc-b',
+ position: { x: 400, y: 0 },
+ dimensions: { width: 200, height: 100 }
+ };
+
+ const bends =
service.calculateBendPointsForCollisionAvoidance(sourceData, destData);
+
+ expect(bends).toHaveLength(1);
+ const existingBendY = midY - 75;
+ expect(Math.abs(bends[0].y - existingBendY) > 25 ||
Math.abs(bends[0].x - midX) > 100).toBe(true);
+ });
+ });
+
+ describe('calculateBendPointsForCollisionAvoidanceByIds', () => {
+ afterEach(() => {
+ document.querySelectorAll('[id^="id-"]').forEach((el) =>
el.remove());
+ });
+
+ function createDomComponent(
+ id: string,
+ position: { x: number; y: number },
+ dimensions: { width: number; height: number }
+ ): void {
+ const el = document.createElementNS('http://www.w3.org/2000/svg',
'g');
+ el.setAttribute('id', 'id-' + id);
+ document.body.appendChild(el);
+ d3.select(el).datum({ id, position, dimensions });
+ }
+
+ it('should return empty array when source element is not in the DOM',
() => {
+ createDomComponent('proc-b', { x: 400, y: 0 }, { width: 200,
height: 100 });
+
+ const bends =
service.calculateBendPointsForCollisionAvoidanceByIds('missing-id', 'proc-b');
+
+ expect(bends).toHaveLength(0);
+ });
+
+ it('should return empty array when destination element is not in the
DOM', () => {
+ createDomComponent('proc-a', { x: 0, y: 0 }, { width: 200, height:
100 });
+
+ const bends =
service.calculateBendPointsForCollisionAvoidanceByIds('proc-a', 'missing-id');
+
+ expect(bends).toHaveLength(0);
+ });
+
+ it('should delegate to calculateBendPointsForCollisionAvoidance with
resolved data', () => {
+ createDomComponent('proc-a', { x: 0, y: 0 }, { width: 200, height:
100 });
+ createDomComponent('proc-b', { x: 400, y: 0 }, { width: 200,
height: 100 });
+
+ const spy = jest.spyOn(service,
'calculateBendPointsForCollisionAvoidance');
+
+ service.calculateBendPointsForCollisionAvoidanceByIds('proc-a',
'proc-b', 'conn-exclude');
+
+ expect(spy).toHaveBeenCalledWith(
+ { id: 'proc-a', position: { x: 0, y: 0 }, dimensions: { width:
200, height: 100 } },
+ { id: 'proc-b', position: { x: 400, y: 0 }, dimensions: {
width: 200, height: 100 } },
+ 'conn-exclude'
+ );
+ });
+ });
});
diff --git
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/service/canvas-utils.service.ts
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/service/canvas-utils.service.ts
index 9131bbd1135..cbd7455986d 100644
---
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/service/canvas-utils.service.ts
+++
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/service/canvas-utils.service.ts
@@ -44,6 +44,17 @@ import { Overlay, OverlayRef, PositionStrategy } from
'@angular/cdk/overlay';
import { ComponentPortal } from '@angular/cdk/portal';
import { initialState as initialTransformState } from
'../state/transform/transform.reducer';
import { selectScale } from '../state/transform/transform.selectors';
+import { ConnectionManager } from './manager/connection-manager.service';
+
+export interface CollisionConnection {
+ id: string;
+ sourceId: string;
+ sourceGroupId: string;
+ destinationId: string;
+ destinationGroupId: string;
+ bends: Position[];
+ labelIndex: number;
+}
@Injectable({
providedIn: 'root'
@@ -2320,4 +2331,161 @@ export class CanvasUtils {
return allProcessors || allLabels;
}
+
+ /**
+ * Calculates bend points for a connection to avoid collision with
existing connections
+ * between the same source and destination components.
+ *
+ * @param sourceData Source component data with id, position, and
dimensions
+ * @param destinationData Destination component data with id,
position, and dimensions
+ * @param connectionIdToExclude Optional connection ID to exclude from
collision checks (for updates)
+ */
+ public calculateBendPointsForCollisionAvoidance(
+ sourceData: { id: string; position: Position; dimensions: { width:
number; height: number } },
+ destinationData: { id: string; position: Position; dimensions: {
width: number; height: number } },
+ connectionIdToExclude?: string
+ ): Position[] {
+ const bends: Position[] = [];
+
+ if (sourceData.id === destinationData.id) {
+ const rightCenter: Position = {
+ x: sourceData.position.x + sourceData.dimensions.width,
+ y: sourceData.position.y + sourceData.dimensions.height / 2
+ };
+
+ bends.push({
+ x: rightCenter.x + ConnectionManager.SELF_LOOP_X_OFFSET,
+ y: rightCenter.y - ConnectionManager.SELF_LOOP_Y_OFFSET
+ });
+ bends.push({
+ x: rightCenter.x + ConnectionManager.SELF_LOOP_X_OFFSET,
+ y: rightCenter.y + ConnectionManager.SELF_LOOP_Y_OFFSET
+ });
+ } else {
+ const existingConnections: CollisionConnection[] = [];
+
+ const connectionsForSourceComponent: any[] =
this.getComponentConnections(sourceData.id);
+ connectionsForSourceComponent.forEach((connection) => {
+ if (connectionIdToExclude && connection.id ===
connectionIdToExclude) {
+ return;
+ }
+
+ const connectionSourceComponentId =
this.getConnectionSourceComponentId(connection);
+ const connectionDestinationComponentId =
this.getConnectionDestinationComponentId(connection);
+
+ if (
+ (connectionSourceComponentId === sourceData.id &&
+ connectionDestinationComponentId ===
destinationData.id) ||
+ (connectionDestinationComponentId === sourceData.id &&
+ connectionSourceComponentId === destinationData.id)
+ ) {
+ existingConnections.push(connection);
+ }
+ });
+
+ if (existingConnections.length > 0) {
+ const avoidCollision =
existingConnections.some((existingConnection) => {
+ return this.nifiCommon.isEmpty(existingConnection.bends);
+ });
+
+ if (avoidCollision) {
+ const sourceMiddle: Position = {
+ x: sourceData.position.x + sourceData.dimensions.width
/ 2,
+ y: sourceData.position.y +
sourceData.dimensions.height / 2
+ };
+ const destinationMiddle: Position = {
+ x: destinationData.position.x +
destinationData.dimensions.width / 2,
+ y: destinationData.position.y +
destinationData.dimensions.height / 2
+ };
+
+ const slope = (sourceMiddle.y - destinationMiddle.y) /
(sourceMiddle.x - destinationMiddle.x);
+ const isMoreHorizontal = slope <= 1 && slope >= -1;
+
+ const xCandidate = (sourceMiddle.x + destinationMiddle.x)
/ 2;
+ const yCandidate = (sourceMiddle.y + destinationMiddle.y)
/ 2;
+
+ let xStep = isMoreHorizontal ? 0 :
ConnectionManager.CONNECTION_OFFSET_X_INCREMENT;
+ let yStep = isMoreHorizontal ?
ConnectionManager.CONNECTION_OFFSET_Y_INCREMENT : 0;
+
+ const MAX_ATTEMPTS = 100;
+ let positioned = false;
+ let attempts = 0;
+ while (!positioned && attempts < MAX_ATTEMPTS) {
+ attempts++;
+ if (!this.collidesWith(existingConnections, xCandidate
- xStep, yCandidate - yStep)) {
+ bends.push({
+ x: xCandidate - xStep,
+ y: yCandidate - yStep
+ });
+ positioned = true;
+ } else if (!this.collidesWith(existingConnections,
xCandidate + xStep, yCandidate + yStep)) {
+ bends.push({
+ x: xCandidate + xStep,
+ y: yCandidate + yStep
+ });
+ positioned = true;
+ }
+
+ if (isMoreHorizontal) {
+ yStep +=
ConnectionManager.CONNECTION_OFFSET_Y_INCREMENT;
+ } else {
+ xStep +=
ConnectionManager.CONNECTION_OFFSET_X_INCREMENT;
+ }
+ }
+
+ if (!positioned) {
+ bends.push({
+ x: xCandidate - xStep,
+ y: yCandidate - yStep
+ });
+ }
+ }
+ }
+ }
+
+ return bends;
+ }
+
+ /**
+ * Convenience wrapper that resolves component data from the canvas DOM by
ID,
+ * then delegates to calculateBendPointsForCollisionAvoidance.
+ */
+ public calculateBendPointsForCollisionAvoidanceByIds(
+ sourceComponentId: string,
+ destinationComponentId: string,
+ connectionIdToExclude?: string
+ ): Position[] {
+ const sourceElement: any = d3.select('#id-' + sourceComponentId);
+ const destinationElement: any = d3.select('#id-' +
destinationComponentId);
+
+ if (sourceElement.empty() || destinationElement.empty()) {
+ return [];
+ }
+
+ return this.calculateBendPointsForCollisionAvoidance(
+ sourceElement.datum(),
+ destinationElement.datum(),
+ connectionIdToExclude
+ );
+ }
+
+ private collidesWith(existingConnections: CollisionConnection[], x:
number, y: number): boolean {
+ return existingConnections.some((existingConnection) => {
+ if (!this.nifiCommon.isEmpty(existingConnection.bends)) {
+ let labelIndex = existingConnection.labelIndex;
+ if (labelIndex >= existingConnection.bends.length) {
+ labelIndex = 0;
+ }
+
+ return (
+ existingConnection.bends[labelIndex].y - 25 < y &&
+ existingConnection.bends[labelIndex].y + 25 > y &&
+ existingConnection.bends[labelIndex].x - 100 < x &&
+ existingConnection.bends[labelIndex].x + 100 > x
+ );
+ }
+
+ return false;
+ });
+ }
}
diff --git
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/service/manager/connection-manager.service.ts
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/service/manager/connection-manager.service.ts
index 82dfc63ea6f..48b662fe418 100644
---
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/service/manager/connection-manager.service.ts
+++
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/service/manager/connection-manager.service.ts
@@ -46,6 +46,7 @@ import { filter, Subject, switchMap, takeUntil } from 'rxjs';
import { ComponentType, NiFiCommon, SelectOption } from '@nifi/shared';
import { QuickSelectBehavior } from
'../behavior/quick-select-behavior.service';
import { ClusterConnectionService } from
'../../../../service/cluster-connection.service';
+import { wouldRemovalCauseOverlap } from
'../../../../ui/common/overlap-detection.utils';
export class ConnectionRenderOptions {
updatePath?: boolean;
@@ -305,6 +306,17 @@ export class ConnectionManager implements OnDestroy {
x: rightCenter.x +
ConnectionManager.SELF_LOOP_X_OFFSET,
y: rightCenter.y +
ConnectionManager.SELF_LOOP_Y_OFFSET
});
+ } else if
(self.nifiCommon.isEmpty(connectionData.bends)) {
+ const sourceComponentId =
+
self.canvasUtils.getConnectionSourceComponentId(connectionData);
+ const collisionBends =
self.canvasUtils.calculateBendPointsForCollisionAvoidanceByIds(
+ sourceComponentId,
+ destinationData.id,
+ connectionData.id
+ );
+ if (collisionBends.length > 0) {
+ payload.component.bends = collisionBends;
+ }
}
self.store.dispatch(
@@ -1172,6 +1184,24 @@ export class ConnectionManager implements OnDestroy {
return;
}
+ if (newBends.length === 0 && sourceComponentId !==
destinationComponentId) {
+ const wouldOverlap = wouldRemovalCauseOverlap(
+ connectionData.id,
+ self.connections,
+ self.currentProcessGroupId
+ );
+ if (wouldOverlap) {
+ self.store.dispatch(
+ showOkDialog({
+ title: 'Connection',
+ message:
+ 'This bend point cannot be
removed because it would cause this connection to overlap with another
connection between the same components.'
+ })
+ );
+ return;
+ }
+ }
+
const connectionRemovedBend: any = {
id: connectionData.id,
bends: newBends
diff --git
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/state/flow/flow.actions.ts
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/state/flow/flow.actions.ts
index 32614579b65..a7fdc481555 100644
---
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/state/flow/flow.actions.ts
+++
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/state/flow/flow.actions.ts
@@ -68,6 +68,7 @@ import {
MoveComponentsRequest,
MoveToFrontRequest,
NavigateToComponentRequest,
+ NavigateToComponentsRequest,
NavigateToControllerServicesRequest,
NavigateToManageComponentPoliciesRequest,
NavigateToParameterContext,
@@ -568,6 +569,11 @@ export const navigateToComponent = createAction(
props<{ request: NavigateToComponentRequest }>()
);
+export const navigateToComponents = createAction(
+ `${CANVAS_PREFIX} Navigate To Components`,
+ props<{ request: NavigateToComponentsRequest }>()
+);
+
export const navigateWithoutTransform = createAction(
`${CANVAS_PREFIX} Navigate Without Transform`,
props<{ url: string[] }>()
diff --git
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/state/flow/flow.effects.ts
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/state/flow/flow.effects.ts
index 94553f29f76..658b630af5d 100644
---
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/state/flow/flow.effects.ts
+++
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/state/flow/flow.effects.ts
@@ -2941,6 +2941,28 @@ export class FlowEffects {
{ dispatch: false }
);
+ navigateToComponents$ = createEffect(
+ () =>
+ this.actions$.pipe(
+ ofType(FlowActions.navigateToComponents),
+ map((action) => action.request),
+ concatLatestFrom(() =>
this.store.select(selectCurrentProcessGroupId)),
+ tap(([request, currentProcessGroupId]) => {
+ if (request.processGroupId) {
+ this.router.navigate([
+ '/process-groups',
+ request.processGroupId,
+ 'bulk',
+ request.ids.join(',')
+ ]);
+ } else {
+ this.router.navigate(['/process-groups',
currentProcessGroupId, 'bulk', request.ids.join(',')]);
+ }
+ })
+ ),
+ { dispatch: false }
+ );
+
navigateWithoutTransform$ = createEffect(
() =>
this.actions$.pipe(
diff --git
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/state/flow/flow.selectors.ts
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/state/flow/flow.selectors.ts
index 18193590c92..27b84bddbb0 100644
---
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/state/flow/flow.selectors.ts
+++
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/state/flow/flow.selectors.ts
@@ -19,6 +19,10 @@ import { flowFeatureKey, FlowState, SelectedComponent } from
'./index';
import { createSelector } from '@ngrx/store';
import { CanvasState, selectCanvasState } from '../index';
import { ComponentType, selectCurrentRoute } from '@nifi/shared';
+import {
+ detectOverlappingConnections,
+ OverlappingConnectionGroup
+} from '../../../../ui/common/overlap-detection.utils';
export const selectFlowState = createSelector(selectCanvasState, (state:
CanvasState) => state[flowFeatureKey]);
@@ -279,3 +283,14 @@ export const selectMaxZIndex = (componentType:
ComponentType.Connection | Compon
};
export const selectFlowAnalysisOpen = createSelector(selectFlowState, (state:
FlowState) => state.flowAnalysisOpen);
+
+export const selectOverlappingConnections = createSelector(
+ selectConnections,
+ selectCurrentProcessGroupId,
+ (connections: any[], processGroupId: string): OverlappingConnectionGroup[]
=> {
+ if (!connections) {
+ return [];
+ }
+ return detectOverlappingConnections(connections, processGroupId);
+ }
+);
diff --git
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/state/flow/index.ts
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/state/flow/index.ts
index 832c608d620..fd9ac29d306 100644
---
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/state/flow/index.ts
+++
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/state/flow/index.ts
@@ -511,6 +511,11 @@ export interface NavigateToComponentRequest {
processGroupId?: string;
}
+export interface NavigateToComponentsRequest {
+ ids: string[];
+ processGroupId?: string;
+}
+
export interface ReplayLastProvenanceEventRequest {
componentId: string;
nodes: string;
diff --git
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/canvas/canvas.component.html
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/canvas/canvas.component.html
index 2f5b308b421..3a5edf53565 100644
---
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/canvas/canvas.component.html
+++
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/canvas/canvas.component.html
@@ -24,6 +24,12 @@
</mat-sidenav>
<mat-sidenav-content>
<graph-controls></graph-controls>
+ <overlapping-connections-banner
+ class="canvas-overlap-banner"
+ [class.palettes-expanded]="!navigationCollapsed() ||
!operationCollapsed()"
+ [overlappingGroups]="(overlappingConnections$ | async) ||
[]"
+
(navigateToGroup)="navigateToOverlappingConnections($event)">
+ </overlapping-connections-banner>
<div
id="canvas-container"
class="canvas-background select-none h-full w-full"
diff --git
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/canvas/canvas.component.scss
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/canvas/canvas.component.scss
index 5fb106d35cf..99cd8242724 100644
---
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/canvas/canvas.component.scss
+++
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/canvas/canvas.component.scss
@@ -15,6 +15,19 @@
* limitations under the License.
*/
+.canvas-overlap-banner {
+ position: absolute;
+ z-index: 99;
+ top: 16px;
+ left: 46px;
+ right: 16px;
+ transition: left 150ms ease;
+
+ &.palettes-expanded {
+ left: 310px;
+ }
+}
+
.canvas-background {
background-size: 14px 14px;
z-index: 1;
diff --git
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/canvas/canvas.component.spec.ts
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/canvas/canvas.component.spec.ts
index 680c66fde1d..b4142e055c7 100644
---
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/canvas/canvas.component.spec.ts
+++
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/canvas/canvas.component.spec.ts
@@ -34,6 +34,7 @@ import { flowFeatureKey } from '../../state/flow';
import { FlowAnalysisDrawerComponent } from
'./header/flow-analysis-drawer/flow-analysis-drawer.component';
import { CanvasActionsService } from '../../service/canvas-actions.service';
import { HttpClientTestingModule } from '@angular/common/http/testing';
+import { OverlappingConnectionsBannerComponent } from
'../../../../ui/common/overlapping-connections-banner/overlapping-connections-banner.component';
import { CopyResponseEntity } from '../../../../state/copy';
import { initialState as initialErrorState } from
'../../../../state/error/error.reducer';
import { errorFeatureKey } from '../../../../state/error';
@@ -70,6 +71,7 @@ describe('Canvas', () => {
MockComponent(GraphControls),
MockComponent(HeaderComponent),
MockComponent(FooterComponent),
+ MockComponent(OverlappingConnectionsBannerComponent),
FlowAnalysisDrawerComponent
],
providers: [
diff --git
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/canvas/canvas.component.ts
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/canvas/canvas.component.ts
index fbd875c5aef..7459bb90580 100644
---
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/canvas/canvas.component.ts
+++
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/canvas/canvas.component.ts
@@ -25,9 +25,11 @@ import {
editComponent,
editCurrentProcessGroup,
loadProcessGroup,
+ navigateToComponents,
paste,
resetFlowState,
selectComponents,
+ setAllowTransition,
setSkipTransform,
startProcessGroupPolling,
stopProcessGroupPolling
@@ -57,6 +59,9 @@ import {
selectRemoteProcessGroup,
selectSingleEditedComponent,
selectSingleSelectedComponent,
+ selectNavigationCollapsed,
+ selectOperationCollapsed,
+ selectOverlappingConnections,
selectSkipTransform,
selectViewStatusHistoryComponent,
selectViewStatusHistoryCurrentProcessGroup
@@ -69,6 +74,7 @@ import { getStatusHistoryAndOpenDialog } from
'../../../../state/status-history/
import { concatLatestFrom } from '@ngrx/operators';
import { ComponentType, isDefinedAndNotNull, NiFiCommon, selectUrl, Storage }
from '@nifi/shared';
import { CanvasUtils } from '../../service/canvas-utils.service';
+import { OverlappingConnectionGroup } from
'../../../../ui/common/overlap-detection.utils';
import { CanvasActionsService } from '../../service/canvas-actions.service';
import { MatDialog } from '@angular/material/dialog';
import { CopyResponseEntity } from '../../../../state/copy';
@@ -97,6 +103,14 @@ export class Canvas implements OnInit, OnDestroy {
private canvasClicked = false;
flowAnalysisOpen = this.store.selectSignal(selectFlowAnalysisOpen);
+ navigationCollapsed = this.store.selectSignal(selectNavigationCollapsed);
+ operationCollapsed = this.store.selectSignal(selectOperationCollapsed);
+ overlappingConnections$ = this.store.select(selectOverlappingConnections);
+
+ navigateToOverlappingConnections(group: OverlappingConnectionGroup): void {
+ this.store.dispatch(setAllowTransition({ allowTransition: true }));
+ this.store.dispatch(navigateToComponents({ request: { ids:
group.connectionIds } }));
+ }
constructor() {
this.store
diff --git
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/canvas/canvas.module.ts
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/canvas/canvas.module.ts
index b459f1e6653..e23d0e122a6 100644
---
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/canvas/canvas.module.ts
+++
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/canvas/canvas.module.ts
@@ -26,6 +26,7 @@ import { CanvasRoutingModule } from './canvas-routing.module';
import { HeaderComponent } from './header/header.component';
import { FooterComponent } from './footer/footer.component';
import { FlowAnalysisDrawerComponent } from
'./header/flow-analysis-drawer/flow-analysis-drawer.component';
+import { OverlappingConnectionsBannerComponent } from
'../../../../ui/common/overlapping-connections-banner/overlapping-connections-banner.component';
@NgModule({
declarations: [Canvas],
@@ -42,7 +43,8 @@ import { FlowAnalysisDrawerComponent } from
'./header/flow-analysis-drawer/flow-
HeaderComponent,
FooterComponent,
MatSidenavModule,
- FlowAnalysisDrawerComponent
+ FlowAnalysisDrawerComponent,
+ OverlappingConnectionsBannerComponent
]
})
export class CanvasModule {}
diff --git
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/canvas/graph-controls/graph-controls.component.scss
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/canvas/graph-controls/graph-controls.component.scss
index e400df89e17..4726e261aa9 100644
---
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/canvas/graph-controls/graph-controls.component.scss
+++
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/canvas/graph-controls/graph-controls.component.scss
@@ -18,6 +18,6 @@
div.graph-controls {
position: absolute;
left: 0;
- top: 22px;
+ top: 16px;
z-index: 2;
}
diff --git
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/canvas/graph-controls/navigation-control/navigation-control.component.html
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/canvas/graph-controls/navigation-control/navigation-control.component.html
index 1fbf087a6f5..b3e586bb58e 100644
---
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/canvas/graph-controls/navigation-control/navigation-control.component.html
+++
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/canvas/graph-controls/navigation-control/navigation-control.component.html
@@ -34,21 +34,46 @@
<div class="w-72 px-2.5 pb-2.5 flex flex-col gap-y-2">
<div class="flex justify-between">
<div class="flex gap-x-1">
- <button mat-icon-button class="primary-icon-button"
type="button" matTooltip="Zoom In" (click)="zoomIn()">
+ <button
+ mat-icon-button
+ class="primary-icon-button"
+ type="button"
+ matTooltip="Zoom In"
+ (click)="zoomIn()">
<i class="fa fa-search-plus"></i>
</button>
- <button mat-icon-button class="primary-icon-button mr-2"
type="button" matTooltip="Zoom Out" (click)="zoomOut()">
+ <button
+ mat-icon-button
+ class="primary-icon-button mr-2"
+ type="button"
+ matTooltip="Zoom Out"
+ (click)="zoomOut()">
<i class="fa fa-search-minus"></i>
</button>
- <button mat-icon-button class="primary-icon-button"
type="button" matTooltip="Fit to Screen" (click)="zoomFit()">
+ <button
+ mat-icon-button
+ class="primary-icon-button"
+ type="button"
+ matTooltip="Fit to Screen"
+ (click)="zoomFit()">
<i class="ml-1 icon icon-zoom-fit"></i>
</button>
- <button mat-icon-button class="primary-icon-button"
type="button" matTooltip="Zoom to Actual Size" (click)="zoomActual()">
+ <button
+ mat-icon-button
+ class="primary-icon-button"
+ type="button"
+ matTooltip="Zoom to Actual Size"
+ (click)="zoomActual()">
<i class="ml-1 icon icon-zoom-actual"></i>
</button>
</div>
@if (isNotRootGroup()) {
- <button mat-icon-button class="primary-icon-button"
type="button" matTooltip="Leave Group" (click)="leaveProcessGroup()">
+ <button
+ mat-icon-button
+ class="primary-icon-button"
+ type="button"
+ matTooltip="Leave Group"
+ (click)="leaveProcessGroup()">
<i class="fa fa-level-up"></i>
</button>
}
diff --git
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/canvas/items/connection/edit-connection/edit-connection.component.ts
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/canvas/items/connection/edit-connection/edit-connection.component.ts
index ff9511676e8..66eb8ce8bb6 100644
---
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/canvas/items/connection/edit-connection/edit-connection.component.ts
+++
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/canvas/items/connection/edit-connection/edit-connection.component.ts
@@ -32,7 +32,7 @@ import { MatInputModule } from '@angular/material/input';
import { MatOptionModule } from '@angular/material/core';
import { MatSelectModule } from '@angular/material/select';
import { NifiSpinnerDirective } from
'../../../../../../../ui/common/spinner/nifi-spinner.directive';
-import { ComponentType, CopyDirective, NifiTooltipDirective, TextTip } from
'@nifi/shared';
+import { ComponentType, CopyDirective, NiFiCommon, NifiTooltipDirective,
TextTip } from '@nifi/shared';
import { MatTabsModule } from '@angular/material/tabs';
import { NiFiState } from '../../../../../../../state';
import { selectPrioritizerTypes } from
'../../../../../../../state/extension-types/extension-types.selectors';
@@ -100,6 +100,7 @@ export class EditConnectionComponent extends TabbedDialog {
private store = inject<Store<NiFiState>>(Store);
private canvasUtils = inject(CanvasUtils);
private client = inject(Client);
+ private nifiCommon = inject(NiFiCommon);
@Input() set getChildOutputPorts(getChildOutputPorts: (groupId: string) =>
Observable<any>) {
if (this.sourceType == ComponentType.ProcessGroup) {
@@ -419,6 +420,22 @@ export class EditConnectionComponent extends TabbedDialog {
payload.component.loadBalanceCompression = 'DO_NOT_COMPRESS';
}
+ if (this.previousDestination && this.nifiCommon.isEmpty(d.bends)) {
+ const sourceComponentId =
this.canvasUtils.getConnectionSourceComponentId(d);
+ const newDestinationComponentId =
+ payload.component.destination?.groupId ??
payload.component.destination?.id;
+ if (newDestinationComponentId && newDestinationComponentId !==
sourceComponentId) {
+ const collisionBends =
this.canvasUtils.calculateBendPointsForCollisionAvoidanceByIds(
+ sourceComponentId,
+ newDestinationComponentId,
+ d.id
+ );
+ if (collisionBends.length > 0) {
+ payload.component.bends = collisionBends;
+ }
+ }
+ }
+
this.store.dispatch(
updateConnection({
request: {
diff --git
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/overlap-detection.utils.spec.ts
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/overlap-detection.utils.spec.ts
new file mode 100644
index 00000000000..8d762500b66
--- /dev/null
+++
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/overlap-detection.utils.spec.ts
@@ -0,0 +1,250 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import {
+ detectOverlappingConnections,
+ OverlapDetectionConnection,
+ wouldRemovalCauseOverlap
+} from './overlap-detection.utils';
+
+function createConnection(overrides: Partial<OverlapDetectionConnection> =
{}): OverlapDetectionConnection {
+ return {
+ id: 'conn-1',
+ sourceId: 'proc-a',
+ destinationId: 'proc-b',
+ sourceGroupId: 'root',
+ destinationGroupId: 'root',
+ bends: [],
+ ...overrides
+ };
+}
+
+describe('detectOverlappingConnections', () => {
+ const processGroupId = 'root';
+
+ it('should return empty array when no connections are provided', () => {
+ expect(detectOverlappingConnections([], processGroupId)).toEqual([]);
+ });
+
+ it('should return empty array for null/undefined connections', () => {
+ expect(detectOverlappingConnections(null as any,
processGroupId)).toEqual([]);
+ expect(detectOverlappingConnections(undefined as any,
processGroupId)).toEqual([]);
+ });
+
+ it('should return empty array when only one connection exists between a
pair', () => {
+ const connections = [createConnection({ id: 'conn-1' })];
+ expect(detectOverlappingConnections(connections,
processGroupId)).toEqual([]);
+ });
+
+ it('should detect two straight-line connections between the same pair', ()
=> {
+ const connections = [
+ createConnection({ id: 'conn-1', sourceId: 'proc-a',
destinationId: 'proc-b' }),
+ createConnection({ id: 'conn-2', sourceId: 'proc-a',
destinationId: 'proc-b' })
+ ];
+
+ const result = detectOverlappingConnections(connections,
processGroupId);
+ expect(result).toHaveLength(1);
+ expect(result[0].connectionIds).toContain('conn-1');
+ expect(result[0].connectionIds).toContain('conn-2');
+ });
+
+ it('should not flag connections with bend points as overlapping', () => {
+ const connections = [
+ createConnection({ id: 'conn-1', sourceId: 'proc-a',
destinationId: 'proc-b', bends: [] }),
+ createConnection({
+ id: 'conn-2',
+ sourceId: 'proc-a',
+ destinationId: 'proc-b',
+ bends: [{ x: 100, y: 200 }]
+ })
+ ];
+
+ const result = detectOverlappingConnections(connections,
processGroupId);
+ expect(result).toEqual([]);
+ });
+
+ it('should treat bidirectional connections as the same visual pair', () =>
{
+ const connections = [
+ createConnection({ id: 'conn-1', sourceId: 'proc-a',
destinationId: 'proc-b' }),
+ createConnection({ id: 'conn-2', sourceId: 'proc-b',
destinationId: 'proc-a' })
+ ];
+
+ const result = detectOverlappingConnections(connections,
processGroupId);
+ expect(result).toHaveLength(1);
+ expect(result[0].connectionIds).toHaveLength(2);
+ });
+
+ it('should detect multiple overlap groups independently', () => {
+ const connections = [
+ createConnection({ id: 'conn-1', sourceId: 'proc-a',
destinationId: 'proc-b' }),
+ createConnection({ id: 'conn-2', sourceId: 'proc-a',
destinationId: 'proc-b' }),
+ createConnection({ id: 'conn-3', sourceId: 'proc-c',
destinationId: 'proc-d' }),
+ createConnection({ id: 'conn-4', sourceId: 'proc-c',
destinationId: 'proc-d' })
+ ];
+
+ const result = detectOverlappingConnections(connections,
processGroupId);
+ expect(result).toHaveLength(2);
+ });
+
+ it('should resolve group IDs when source/destination are in different
groups', () => {
+ const connections = [
+ createConnection({
+ id: 'conn-1',
+ sourceId: 'port-1',
+ destinationId: 'proc-b',
+ sourceGroupId: 'child-group'
+ }),
+ createConnection({
+ id: 'conn-2',
+ sourceId: 'port-2',
+ destinationId: 'proc-b',
+ sourceGroupId: 'child-group'
+ })
+ ];
+
+ const result = detectOverlappingConnections(connections,
processGroupId);
+ expect(result).toHaveLength(1);
+ expect(result[0].sourceComponentId).toBeDefined();
+ expect(result[0].destinationComponentId).toBeDefined();
+ });
+
+ it('should not flag connections between different component pairs', () => {
+ const connections = [
+ createConnection({ id: 'conn-1', sourceId: 'proc-a',
destinationId: 'proc-b' }),
+ createConnection({ id: 'conn-2', sourceId: 'proc-a',
destinationId: 'proc-c' })
+ ];
+
+ const result = detectOverlappingConnections(connections,
processGroupId);
+ expect(result).toEqual([]);
+ });
+
+ it('should handle three or more overlapping connections in the same
group', () => {
+ const connections = [
+ createConnection({ id: 'conn-1', sourceId: 'proc-a',
destinationId: 'proc-b' }),
+ createConnection({ id: 'conn-2', sourceId: 'proc-a',
destinationId: 'proc-b' }),
+ createConnection({ id: 'conn-3', sourceId: 'proc-a',
destinationId: 'proc-b' })
+ ];
+
+ const result = detectOverlappingConnections(connections,
processGroupId);
+ expect(result).toHaveLength(1);
+ expect(result[0].connectionIds).toHaveLength(3);
+ });
+
+ it('should not flag a single connection with no bends', () => {
+ const connections = [createConnection({ id: 'conn-1', sourceId:
'proc-a', destinationId: 'proc-b' })];
+
+ const result = detectOverlappingConnections(connections,
processGroupId);
+ expect(result).toEqual([]);
+ });
+
+ it('should ignore connections where both have bend points', () => {
+ const connections = [
+ createConnection({
+ id: 'conn-1',
+ sourceId: 'proc-a',
+ destinationId: 'proc-b',
+ bends: [{ x: 100, y: 50 }]
+ }),
+ createConnection({
+ id: 'conn-2',
+ sourceId: 'proc-a',
+ destinationId: 'proc-b',
+ bends: [{ x: 100, y: 50 }]
+ })
+ ];
+
+ const result = detectOverlappingConnections(connections,
processGroupId);
+ expect(result).toEqual([]);
+ });
+});
+
+describe('wouldRemovalCauseOverlap', () => {
+ const processGroupId = 'root';
+
+ it('should return false when no other connections exist', () => {
+ const connections = [createConnection({ id: 'conn-1', sourceId:
'proc-a', destinationId: 'proc-b' })];
+
+ expect(wouldRemovalCauseOverlap('conn-1', connections,
processGroupId)).toBe(false);
+ });
+
+ it('should return true when another straight-line connection exists
between same pair', () => {
+ const connections = [
+ createConnection({
+ id: 'conn-1',
+ sourceId: 'proc-a',
+ destinationId: 'proc-b',
+ bends: [{ x: 100, y: 50 }]
+ }),
+ createConnection({ id: 'conn-2', sourceId: 'proc-a',
destinationId: 'proc-b' })
+ ];
+
+ expect(wouldRemovalCauseOverlap('conn-1', connections,
processGroupId)).toBe(true);
+ });
+
+ it('should return false when other connection has bend points', () => {
+ const connections = [
+ createConnection({
+ id: 'conn-1',
+ sourceId: 'proc-a',
+ destinationId: 'proc-b',
+ bends: [{ x: 100, y: 50 }]
+ }),
+ createConnection({
+ id: 'conn-2',
+ sourceId: 'proc-a',
+ destinationId: 'proc-b',
+ bends: [{ x: 200, y: 75 }]
+ })
+ ];
+
+ expect(wouldRemovalCauseOverlap('conn-1', connections,
processGroupId)).toBe(false);
+ });
+
+ it('should return false when other connection is between different
components', () => {
+ const connections = [
+ createConnection({
+ id: 'conn-1',
+ sourceId: 'proc-a',
+ destinationId: 'proc-b',
+ bends: [{ x: 100, y: 50 }]
+ }),
+ createConnection({ id: 'conn-2', sourceId: 'proc-a',
destinationId: 'proc-c' })
+ ];
+
+ expect(wouldRemovalCauseOverlap('conn-1', connections,
processGroupId)).toBe(false);
+ });
+
+ it('should return false for unknown connection ID', () => {
+ const connections = [createConnection({ id: 'conn-1', sourceId:
'proc-a', destinationId: 'proc-b' })];
+
+ expect(wouldRemovalCauseOverlap('unknown', connections,
processGroupId)).toBe(false);
+ });
+
+ it('should handle bidirectional connections', () => {
+ const connections = [
+ createConnection({
+ id: 'conn-1',
+ sourceId: 'proc-a',
+ destinationId: 'proc-b',
+ bends: [{ x: 100, y: 50 }]
+ }),
+ createConnection({ id: 'conn-2', sourceId: 'proc-b',
destinationId: 'proc-a' })
+ ];
+
+ expect(wouldRemovalCauseOverlap('conn-1', connections,
processGroupId)).toBe(true);
+ });
+});
diff --git
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/overlap-detection.utils.ts
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/overlap-detection.utils.ts
new file mode 100644
index 00000000000..7139af3258c
--- /dev/null
+++
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/overlap-detection.utils.ts
@@ -0,0 +1,131 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+export interface OverlappingConnectionGroup {
+ sourceComponentId: string;
+ destinationComponentId: string;
+ connectionIds: string[];
+}
+
+export interface OverlapDetectionConnection {
+ id: string;
+ sourceId: string;
+ destinationId: string;
+ sourceGroupId: string;
+ destinationGroupId: string;
+ bends: Array<{ x: number; y: number }>;
+}
+
+/**
+ * Pure function to detect groups of overlapping connections.
+ *
+ * Two connections overlap when they share the same effective source and
destination
+ * components and both have no bend points (straight-line paths).
+ *
+ * @param connections Array of connection entities
+ * @param currentProcessGroupId The current process group context for
resolving component IDs
+ * @returns Array of overlap groups, each containing the affected connection
IDs
+ */
+export function detectOverlappingConnections(
+ connections: OverlapDetectionConnection[],
+ currentProcessGroupId: string
+): OverlappingConnectionGroup[] {
+ if (!connections || connections.length === 0) {
+ return [];
+ }
+
+ const pairMap = new Map<string, string[]>();
+
+ connections.forEach((connection) => {
+ if (connection.bends && connection.bends.length > 0) {
+ return;
+ }
+
+ const effectiveSourceId =
+ connection.sourceGroupId !== currentProcessGroupId ?
connection.sourceGroupId : connection.sourceId;
+ const effectiveDestinationId =
+ connection.destinationGroupId !== currentProcessGroupId
+ ? connection.destinationGroupId
+ : connection.destinationId;
+
+ const pairKey = [effectiveSourceId,
effectiveDestinationId].sort().join('::');
+
+ if (!pairMap.has(pairKey)) {
+ pairMap.set(pairKey, []);
+ }
+ pairMap.get(pairKey)!.push(connection.id);
+ });
+
+ const overlappingGroups: OverlappingConnectionGroup[] = [];
+
+ pairMap.forEach((connectionIds, pairKey) => {
+ if (connectionIds.length >= 2) {
+ const [componentA, componentB] = pairKey.split('::');
+ overlappingGroups.push({
+ sourceComponentId: componentA,
+ destinationComponentId: componentB,
+ connectionIds
+ });
+ }
+ });
+
+ return overlappingGroups;
+}
+
+/**
+ * Checks whether removing all bend points from a connection would cause it to
+ * visually overlap with another straight-line connection between the same
components.
+ *
+ * @param connectionId The connection whose bend points would be
removed
+ * @param connections All connections in the current view
+ * @param currentProcessGroupId The current process group context
+ * @returns true if removal would cause a visual overlap
+ */
+export function wouldRemovalCauseOverlap(
+ connectionId: string,
+ connections: OverlapDetectionConnection[],
+ currentProcessGroupId: string
+): boolean {
+ if (!connections || connections.length === 0) {
+ return false;
+ }
+
+ const target = connections.find((c) => c.id === connectionId);
+ if (!target) {
+ return false;
+ }
+
+ const effectiveSourceId = target.sourceGroupId !== currentProcessGroupId ?
target.sourceGroupId : target.sourceId;
+ const effectiveDestinationId =
+ target.destinationGroupId !== currentProcessGroupId ?
target.destinationGroupId : target.destinationId;
+ const pairKey = [effectiveSourceId,
effectiveDestinationId].sort().join('::');
+
+ return connections.some((c) => {
+ if (c.id === connectionId) {
+ return false;
+ }
+ if (c.bends && c.bends.length > 0) {
+ return false;
+ }
+
+ const srcId = c.sourceGroupId !== currentProcessGroupId ?
c.sourceGroupId : c.sourceId;
+ const dstId = c.destinationGroupId !== currentProcessGroupId ?
c.destinationGroupId : c.destinationId;
+ const otherKey = [srcId, dstId].sort().join('::');
+
+ return otherKey === pairKey;
+ });
+}
diff --git
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/canvas/graph-controls/graph-controls.component.scss
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/overlapping-connections-banner/_overlapping-connections-banner.component-theme.scss
similarity index 56%
copy from
nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/canvas/graph-controls/graph-controls.component.scss
copy to
nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/overlapping-connections-banner/_overlapping-connections-banner.component-theme.scss
index e400df89e17..5dc84a16eb8 100644
---
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/canvas/graph-controls/graph-controls.component.scss
+++
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/overlapping-connections-banner/_overlapping-connections-banner.component-theme.scss
@@ -15,9 +15,22 @@
* limitations under the License.
*/
-div.graph-controls {
- position: absolute;
- left: 0;
- top: 22px;
- z-index: 2;
+@mixin generate-theme() {
+ .overlapping-connections-banner {
+ background-color: var(--mat-sys-surface-container);
+ border: 1px solid var(--mat-sys-outline-variant);
+ // !important needed to override the global *:not([class^=mat-])...
border-color reset in _app.scss
+ border-left: 4px solid var(--nf-caution-default) !important;
+ color: var(--mat-sys-on-surface);
+
+ .overlap-link {
+ color: var(--mat-sys-primary);
+ cursor: pointer;
+ text-decoration: underline;
+
+ &:hover {
+ opacity: 0.6;
+ }
+ }
+ }
}
diff --git
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/overlapping-connections-banner/overlapping-connections-banner.component.html
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/overlapping-connections-banner/overlapping-connections-banner.component.html
new file mode 100644
index 00000000000..a3d7df76781
--- /dev/null
+++
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/overlapping-connections-banner/overlapping-connections-banner.component.html
@@ -0,0 +1,36 @@
+<!--
+ ~ Licensed to the Apache Software Foundation (ASF) under one or more
+ ~ contributor license agreements. See the NOTICE file distributed with
+ ~ this work for additional information regarding copyright ownership.
+ ~ The ASF licenses this file to You under the Apache License, Version 2.0
+ ~ (the "License"); you may not use this file except in compliance with
+ ~ the License. You may obtain a copy of the License at
+ ~
+ ~ http://www.apache.org/licenses/LICENSE-2.0
+ ~
+ ~ Unless required by applicable law or agreed to in writing, software
+ ~ distributed under the License is distributed on an "AS IS" BASIS,
+ ~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ ~ See the License for the specific language governing permissions and
+ ~ limitations under the License.
+ -->
+
+@if (overlappingGroups().length > 0) {
+ <div class="overlapping-connections-banner flex flex-col gap-1.5 px-4 py-3
rounded text-sm">
+ <div class="flex items-center gap-2">
+ <i class="fa fa-warning caution-color"></i>
+ <span class="font-medium"> {{ overlappingGroups().length }}
overlapping connection group(s) detected </span>
+ </div>
+ <span>
+ Overlapping connections with no bend points appear as a single
connection. Add a bend point to separate
+ them.
+ </span>
+ <div class="flex flex-wrap gap-x-3">
+ @for (overlap of overlappingGroups(); track
overlap.connectionIds[0]; let i = $index) {
+ <a class="overlap-link" (click)="showOverlap(overlap)">
+ Overlap {{ i + 1 }} ({{ overlap.connectionIds.length }}
connections)
+ </a>
+ }
+ </div>
+ </div>
+}
diff --git
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/canvas/graph-controls/graph-controls.component.scss
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/overlapping-connections-banner/overlapping-connections-banner.component.scss
similarity index 90%
copy from
nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/canvas/graph-controls/graph-controls.component.scss
copy to
nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/overlapping-connections-banner/overlapping-connections-banner.component.scss
index e400df89e17..8305259f1a3 100644
---
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/canvas/graph-controls/graph-controls.component.scss
+++
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/overlapping-connections-banner/overlapping-connections-banner.component.scss
@@ -15,9 +15,7 @@
* limitations under the License.
*/
-div.graph-controls {
- position: absolute;
- left: 0;
- top: 22px;
- z-index: 2;
+:host {
+ display: block;
+ font-size: 13px;
}
diff --git
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/overlapping-connections-banner/overlapping-connections-banner.component.spec.ts
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/overlapping-connections-banner/overlapping-connections-banner.component.spec.ts
new file mode 100644
index 00000000000..887019d9ebe
--- /dev/null
+++
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/overlapping-connections-banner/overlapping-connections-banner.component.spec.ts
@@ -0,0 +1,103 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+import { OverlappingConnectionsBannerComponent } from
'./overlapping-connections-banner.component';
+import { OverlappingConnectionGroup } from '../overlap-detection.utils';
+
+interface SetupOptions {
+ overlappingGroups?: OverlappingConnectionGroup[];
+}
+
+async function setup(options: SetupOptions = {}) {
+ await TestBed.configureTestingModule({
+ imports: [OverlappingConnectionsBannerComponent]
+ }).compileComponents();
+
+ const fixture: ComponentFixture<OverlappingConnectionsBannerComponent> =
TestBed.createComponent(
+ OverlappingConnectionsBannerComponent
+ );
+ const component = fixture.componentInstance;
+
+ if (options.overlappingGroups) {
+ fixture.componentRef.setInput('overlappingGroups',
options.overlappingGroups);
+ }
+
+ fixture.detectChanges();
+
+ return { fixture, component };
+}
+
+describe('OverlappingConnectionsBannerComponent', () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ });
+
+ describe('rendering', () => {
+ it('should not render when there are no overlapping groups', async ()
=> {
+ const { fixture } = await setup({ overlappingGroups: [] });
+ const banner =
fixture.nativeElement.querySelector('.overlapping-connections-banner');
+ expect(banner).toBeNull();
+ });
+
+ it('should render when there are overlapping groups', async () => {
+ const groups: OverlappingConnectionGroup[] = [
+ { sourceComponentId: 'a', destinationComponentId: 'b',
connectionIds: ['c1', 'c2'] }
+ ];
+ const { fixture } = await setup({ overlappingGroups: groups });
+ const banner =
fixture.nativeElement.querySelector('.overlapping-connections-banner');
+ expect(banner).toBeTruthy();
+ });
+
+ it('should display the correct count of overlap groups', async () => {
+ const groups: OverlappingConnectionGroup[] = [
+ { sourceComponentId: 'a', destinationComponentId: 'b',
connectionIds: ['c1', 'c2'] },
+ { sourceComponentId: 'c', destinationComponentId: 'd',
connectionIds: ['c3', 'c4'] }
+ ];
+ const { fixture } = await setup({ overlappingGroups: groups });
+ const banner =
fixture.nativeElement.querySelector('.overlapping-connections-banner');
+ expect(banner.textContent).toContain('2 overlapping connection
group(s) detected');
+ });
+
+ it('should render a link for each overlap group', async () => {
+ const groups: OverlappingConnectionGroup[] = [
+ { sourceComponentId: 'a', destinationComponentId: 'b',
connectionIds: ['c1', 'c2'] },
+ { sourceComponentId: 'c', destinationComponentId: 'd',
connectionIds: ['c3', 'c4', 'c5'] }
+ ];
+ const { fixture } = await setup({ overlappingGroups: groups });
+ const links =
fixture.nativeElement.querySelectorAll('.overlap-link');
+ expect(links.length).toBe(2);
+ expect(links[0].textContent).toContain('Overlap 1 (2
connections)');
+ expect(links[1].textContent).toContain('Overlap 2 (3
connections)');
+ });
+ });
+
+ describe('interaction', () => {
+ it('should emit navigateToGroup when a link is clicked', async () => {
+ const groups: OverlappingConnectionGroup[] = [
+ { sourceComponentId: 'a', destinationComponentId: 'b',
connectionIds: ['c1', 'c2'] }
+ ];
+ const { fixture, component } = await setup({ overlappingGroups:
groups });
+
+ const emitSpy = jest.spyOn(component.navigateToGroup, 'emit');
+ const link = fixture.nativeElement.querySelector('.overlap-link');
+ link.click();
+
+ expect(emitSpy).toHaveBeenCalledWith(groups[0]);
+ });
+ });
+});
diff --git
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/canvas/graph-controls/graph-controls.component.scss
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/overlapping-connections-banner/overlapping-connections-banner.component.ts
similarity index 54%
copy from
nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/canvas/graph-controls/graph-controls.component.scss
copy to
nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/overlapping-connections-banner/overlapping-connections-banner.component.ts
index e400df89e17..ca7cae1d36d 100644
---
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/canvas/graph-controls/graph-controls.component.scss
+++
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/overlapping-connections-banner/overlapping-connections-banner.component.ts
@@ -15,9 +15,21 @@
* limitations under the License.
*/
-div.graph-controls {
- position: absolute;
- left: 0;
- top: 22px;
- z-index: 2;
+import { Component, input, output } from '@angular/core';
+import { OverlappingConnectionGroup } from '../overlap-detection.utils';
+
+@Component({
+ selector: 'overlapping-connections-banner',
+ standalone: true,
+ imports: [],
+ templateUrl: './overlapping-connections-banner.component.html',
+ styleUrls: ['./overlapping-connections-banner.component.scss']
+})
+export class OverlappingConnectionsBannerComponent {
+ overlappingGroups = input<OverlappingConnectionGroup[]>([]);
+ navigateToGroup = output<OverlappingConnectionGroup>();
+
+ showOverlap(overlap: OverlappingConnectionGroup): void {
+ this.navigateToGroup.emit(overlap);
+ }
}
diff --git a/nifi-frontend/src/main/frontend/apps/nifi/src/styles.scss
b/nifi-frontend/src/main/frontend/apps/nifi/src/styles.scss
index fc277527522..45641f82aa7 100644
--- a/nifi-frontend/src/main/frontend/apps/nifi/src/styles.scss
+++ b/nifi-frontend/src/main/frontend/apps/nifi/src/styles.scss
@@ -55,6 +55,8 @@
processor-status-table;
@use
'app/pages/flow-designer/ui/canvas/change-color-dialog/change-color-dialog.component-theme'
as change-color-dialog;
@use
'libs/shared/src/components/map-table/editors/text-editor/text-editor.component-theme'
as text-editor;
+@use
'app/ui/common/overlapping-connections-banner/overlapping-connections-banner.component-theme'
as
+ overlapping-connections-banner;
// Plus imports for other components in your app.
@use 'libs/shared/src/assets/fonts/flowfont/flowfont.css';
@@ -101,6 +103,7 @@ html {
@include change-color-dialog.generate-theme();
@include text-editor.generate-theme();
@include resizable.generate-theme();
+ @include overlapping-connections-banner.generate-theme();
.darkMode {
@include app.generate-material-theme();
@@ -133,5 +136,6 @@ html {
@include change-color-dialog.generate-theme();
@include text-editor.generate-theme();
@include resizable.generate-theme();
+ @include overlapping-connections-banner.generate-theme();
}
}