diff --git a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/service/canvas-context-menu.service.ts b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/service/canvas-context-menu.service.ts index 6267e71468fc..2ef24774e26f 100644 --- a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/service/canvas-context-menu.service.ts +++ b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/service/canvas-context-menu.service.ts @@ -50,11 +50,13 @@ import { stopSources, stopVersionControlRequest, terminateThreads, - updatePositions + updatePositions, + viewComponentConnections } from '../state/flow/flow.actions'; import { ComponentType } from '@nifi/shared'; import { ConfirmStopVersionControlRequest, + ConnectionDirection, MoveComponentRequest, OpenChangeVersionDialogRequest, OpenLocalChangesDialogRequest @@ -287,25 +289,23 @@ export class CanvasContextMenu implements ContextMenuDefinitionProvider { id: 'upstream-downstream', menuItems: [ { - condition: () => { - // TODO - hasUpstream - return false; + condition: (selection: d3.Selection) => { + return this.canvasUtils.hasUpstream(selection); }, - clazz: 'icon', + clazz: 'fa fa-long-arrow-up fa-rotate-45', text: 'Upstream', - action: () => { - // TODO - showUpstream + action: (selection: d3.Selection) => { + this.requestComponentConnections(selection, 'upstream'); } }, { - condition: () => { - // TODO - hasDownstream - return false; + condition: (selection: d3.Selection) => { + return this.canvasUtils.hasDownstream(selection); }, - clazz: 'icon', + clazz: 'fa fa-long-arrow-down fa-rotate-45', text: 'Downstream', - action: () => { - // TODO - showDownstream + action: (selection: d3.Selection) => { + this.requestComponentConnections(selection, 'downstream'); } } ] @@ -1465,4 +1465,48 @@ export class CanvasContextMenu implements ContextMenuDefinitionProvider { menuItem.action(selection, event); } } + + /** + * Requests the connections attached to the specified component in the specified direction. + * + * A component's connections are defined in the group that encloses it, which is the group currently + * on the canvas. The exception is a port whose connections cross that group's own boundary — the + * upstream side of an Input Port and the downstream side of an Output Port — which are defined one + * level up and are not rendered alongside the port at all. Those are the two cases that + * hasUpstream/hasDownstream gate on the presence of a parent group. + */ + private requestComponentConnections( + selection: d3.Selection, + direction: ConnectionDirection + ): void { + const crossesParentBoundary: boolean = + (direction === 'upstream' && this.canvasUtils.isInputPort(selection)) || + (direction === 'downstream' && this.canvasUtils.isOutputPort(selection)); + + let groupId: string | null = this.canvasUtils.getProcessGroupId(); + if (crossesParentBoundary) { + groupId = this.canvasUtils.getParentProcessGroupId(); + + // hasUpstream/hasDownstream do not offer these directions without a parent group + if (groupId === null) { + return; + } + } + + const selectionData = selection.datum(); + this.store.dispatch( + viewComponentConnections({ + request: { + id: selectionData.id, + // funnels have no name, and an unreadable component has no name to read + name: selectionData.permissions.canRead + ? (selectionData.component.name ?? selectionData.id) + : selectionData.id, + type: selectionData.type, + groupId, + direction + } + }) + ); + } } 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 4c48a436d42d..25826b44da41 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 @@ -1010,4 +1010,73 @@ describe('CanvasUtils', () => { ); }); }); + + // These resolve a connection endpoint to the component as it is rendered in the group currently + // being viewed: a port inside a child group resolves to that group, since that is what the canvas + // draws and what the user can select. "View Connections" matches on the result, so every component + // type it offers relies on these. + describe('connection endpoint resolution', () => { + const GROUP_ID = 'group-being-viewed'; + const CHILD_GROUP_ID = 'child-group'; + const PARENT_GROUP_ID = 'parent-group'; + + function viewing(groupId: string): void { + const store = TestBed.inject(MockStore); + store.overrideSelector(selectCurrentProcessGroupId, groupId); + store.refreshState(); + } + + function connection( + sourceId: string, + sourceGroupId: string, + destinationId: string, + destinationGroupId: string + ): any { + return { id: 'conn', sourceId, sourceGroupId, destinationId, destinationGroupId }; + } + + it('resolves a component in the group being viewed to the component itself', () => { + viewing(GROUP_ID); + + // a processor wired to a funnel, both drawn in the group being viewed + const conn = connection('proc-a', GROUP_ID, 'funnel-a', GROUP_ID); + + expect(service.getConnectionSourceComponentId(conn)).toBe('proc-a'); + expect(service.getConnectionDestinationComponentId(conn)).toBe('funnel-a'); + }); + + it('resolves a port inside a child group to that child group', () => { + viewing(GROUP_ID); + + // a processor in the group being viewed feeding an Input Port of a child group, and an + // Output Port of that same child group feeding back in + const into = connection('proc-a', GROUP_ID, 'inner-input-port', CHILD_GROUP_ID); + const outOf = connection('inner-output-port', CHILD_GROUP_ID, 'proc-a', GROUP_ID); + + expect(service.getConnectionDestinationComponentId(into)).toBe(CHILD_GROUP_ID); + expect(service.getConnectionSourceComponentId(outOf)).toBe(CHILD_GROUP_ID); + }); + + it('resolves a port of the group being viewed to the port when the parent group is searched', () => { + // an Input Port's upstream connections are defined in the parent group, so that is the + // group whose flow gets searched — but the endpoint is still reported as belonging to the + // group being viewed, which is what makes it resolve to the port rather than to the group + viewing(GROUP_ID); + + const upstreamOfInputPort = connection('parent-proc', PARENT_GROUP_ID, 'input-port', GROUP_ID); + const downstreamOfOutputPort = connection('output-port', GROUP_ID, 'parent-proc', PARENT_GROUP_ID); + + expect(service.getConnectionDestinationComponentId(upstreamOfInputPort)).toBe('input-port'); + expect(service.getConnectionSourceComponentId(downstreamOfOutputPort)).toBe('output-port'); + }); + + it('resolves both ends of a self loop to the same component', () => { + viewing(GROUP_ID); + + const retry = connection('proc-a', GROUP_ID, 'proc-a', GROUP_ID); + + expect(service.getConnectionSourceComponentId(retry)).toBe('proc-a'); + expect(service.getConnectionDestinationComponentId(retry)).toBe('proc-a'); + }); + }); }); 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 441f5b5c5e8d..ac76762e2be9 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 @@ -24,6 +24,7 @@ import { ClearBulletinsForGroupResponse, ComponentEntity, ConfirmStopVersionControlRequest, + ComponentConnectionsDialogRequest, CreateComponentRequest, CreateComponentResponse, CreateConnection, @@ -98,7 +99,8 @@ import { TerminateThreadsRequest, UpdatePositionsRequest, UploadProcessGroupRequest, - VersionControlInformationEntity + VersionControlInformationEntity, + ViewComponentConnectionsRequest } from './index'; import { StatusHistoryRequest } from '../../../../state/status-history'; import { @@ -629,6 +631,16 @@ export const replayLastProvenanceEvent = createAction( props<{ request: ReplayLastProvenanceEventRequest }>() ); +export const viewComponentConnections = createAction( + `${CANVAS_PREFIX} View Component Connections`, + props<{ request: ViewComponentConnectionsRequest }>() +); + +export const openComponentConnectionsDialog = createAction( + `${CANVAS_PREFIX} Open Component Connections Dialog`, + props<{ request: ComponentConnectionsDialogRequest }>() +); + export const enableComponent = createAction( `${CANVAS_PREFIX} Enable Component`, props<{ request: EnableComponentRequest | EnableProcessGroupRequest }>() 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 5d9220f4699a..de4025a8bfaf 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 @@ -53,6 +53,7 @@ import { } from 'rxjs'; import { ComponentEntity, + ConnectionEntity, CreateConnectionDialogRequest, CreateProcessGroupDialogRequest, DeleteComponentResponse, @@ -101,6 +102,7 @@ import { CreatePort } from '../../ui/canvas/items/port/create-port/create-port.c import { EditPort } from '../../../../ui/common/component-dialogs/edit-port/edit-port.component'; import { BranchEntity, + BreadcrumbEntity, BucketEntity, DisableComponentRequest, EnableComponentRequest, @@ -162,6 +164,7 @@ import { ChangeVersionDialog } from '../../ui/canvas/items/flow/change-version-d import { ChangeVersionProgressDialog } from '../../ui/canvas/items/flow/change-version-progress-dialog/change-version-progress-dialog'; import { LocalChangesDialog } from '../../ui/canvas/items/flow/local-changes-dialog/local-changes-dialog'; import { ProcessorBacklogDialog } from '../../ui/canvas/items/processor/backlog-dialog/backlog-dialog.component'; +import { ComponentConnectionsDialog } from '../../ui/canvas/component-connections-dialog/component-connections-dialog.component'; import { ClusterConnectionService } from '../../../../service/cluster-connection.service'; import { ExtensionTypesService } from '../../../../service/extension-types.service'; import { ChangeComponentVersionDialog } from '../../../../ui/common/change-component-version-dialog/change-component-version-dialog'; @@ -3180,6 +3183,96 @@ export class FlowEffects { { dispatch: false } ); + /** + * Loads the flow of the group that defines the requested component's connections and retains only + * the connections attached to that component in the requested direction. The group is the one on + * the canvas for most components, and the parent group for the two port cases whose connections + * cross the enclosing group's boundary. + * + * Matching goes through the canvas' own endpoint resolvers rather than comparing the raw ids. A + * connection drawn to a Process Group or Remote Process Group actually terminates at a port inside + * it, and getConnectionSourceComponentId/getConnectionDestinationComponentId are what collapse that + * port back to the group the user sees and selects. They compare against the group currently on the + * canvas, which is the right frame of reference for the parent-group searches too: a connection + * into an Input Port carries that port's own group as its destination group, so it resolves to the + * port rather than to the group. + * + * + * Both resolvers read the ids on the connection entity rather than on its component, so a + * connection the current user cannot read — the kind most worth reporting — is still matched. + */ + viewComponentConnections$ = createEffect(() => + this.actions$.pipe( + ofType(FlowActions.viewComponentConnections), + map((action) => action.request), + switchMap((request) => { + const attachedTo = (connection: ConnectionEntity): boolean => + request.direction === 'upstream' + ? this.canvasUtils.getConnectionDestinationComponentId(connection) === request.id + : this.canvasUtils.getConnectionSourceComponentId(connection) === request.id; + + return from(this.flowService.getFlow(request.groupId)).pipe( + map((flowEntity: ProcessGroupFlowEntity) => + FlowActions.openComponentConnectionsDialog({ + request: { + componentName: request.name, + componentType: request.type, + groupId: request.groupId, + direction: request.direction, + connections: flowEntity.processGroupFlow.flow.connections.filter(attachedTo), + groupIdToName: this.buildProcessGroupIdToNameMap(flowEntity), + remoteProcessGroupIds: this.buildRemoteProcessGroupIdSet(flowEntity), + } + }) + ), + catchError((errorResponse: HttpErrorResponse) => of(this.snackBarOrFullScreenError(errorResponse))) + ); + }) + ) + ); + + openComponentConnectionsDialog$ = createEffect( + () => + this.actions$.pipe( + ofType(FlowActions.openComponentConnectionsDialog), + map((action) => action.request), + tap((request) => { + this.dialog.open(ComponentConnectionsDialog, { + ...XL_DIALOG, + data: request + }); + }) + ), + { dispatch: false } + ); + + private buildProcessGroupIdToNameMap(flowEntity: ProcessGroupFlowEntity): Map { + const idToName = new Map(); + const processGroupFlow = flowEntity.processGroupFlow; + + let breadcrumbEntity: BreadcrumbEntity | undefined = processGroupFlow.breadcrumb; + while (breadcrumbEntity) { + if (breadcrumbEntity.permissions.canRead) { + idToName.set(breadcrumbEntity.id, breadcrumbEntity.breadcrumb.name); + } + breadcrumbEntity = breadcrumbEntity.parentBreadcrumb; + } + + [...(processGroupFlow.flow.processGroups ?? []), ...(processGroupFlow.flow.remoteProcessGroups ?? [])].forEach( + (group) => { + if (group.permissions.canRead) { + idToName.set(group.id, group.component.name); + } + } + ); + + return idToName; + } + + private buildRemoteProcessGroupIdSet(flowEntity: ProcessGroupFlowEntity): Set { + return new Set((flowEntity.processGroupFlow.flow.remoteProcessGroups ?? []).map((group) => group.id)); + } + showOkDialog$ = createEffect( () => this.actions$.pipe( @@ -4945,7 +5038,7 @@ export class FlowEffects { warnedIds: this.warnedPositionIds }) }); - const sanitizeConnection = (entity: ComponentEntity): ComponentEntity => ({ + const sanitizeConnection = (entity: ConnectionEntity): ConnectionEntity => ({ ...entity, position: sanitizePosition(entity.position, { componentId: entity.id, diff --git a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/state/flow/flow.reducer.ts b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/state/flow/flow.reducer.ts index 6e6f602ef428..d68881828e09 100644 --- a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/state/flow/flow.reducer.ts +++ b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/state/flow/flow.reducer.ts @@ -717,23 +717,23 @@ function getComponentCollection(draftState: FlowState, componentType: ComponentT return collection; } -function processComponentCollection( - proposedComponents: ComponentEntity[], - currentComponents: ComponentEntity[], +function processComponentCollection( + proposedComponents: T[], + currentComponents: T[], addedCache: string[], removedCache: string[], overrideRevisionCheck: boolean -): ComponentEntity[] { +): T[] { // components in the proposed collection but not the current collection - const addedComponents: ComponentEntity[] = proposedComponents.filter((proposedComponent) => { + const addedComponents: T[] = proposedComponents.filter((proposedComponent) => { return !currentComponents.some((currentComponent) => currentComponent.id === proposedComponent.id); }); // components in the current collection that are no longer in the proposed collection - const removedComponents: ComponentEntity[] = currentComponents.filter((currentComponent) => { + const removedComponents: T[] = currentComponents.filter((currentComponent) => { return !proposedComponents.some((proposedComponent) => proposedComponent.id === currentComponent.id); }); // components that are in both the proposed collection and the current collection - const updatedComponents: ComponentEntity[] = currentComponents.filter((currentComponent) => { + const updatedComponents: T[] = currentComponents.filter((currentComponent) => { return proposedComponents.some((proposedComponents) => proposedComponents.id === currentComponent.id); }); 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 a699a24460a9..99291d38bc64 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,7 @@ import { flowFeatureKey, FlowState, SelectedComponent } from './index'; import { createSelector } from '@ngrx/store'; import { CanvasState, selectCanvasState } from '../index'; import { ComponentType, selectCurrentRoute } from '@nifi/shared'; +import { BreadcrumbEntity } from '../../../../state/shared'; import { detectOverlappingConnections, OverlappingConnectionGroup 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 1b7b830ec22b..5d059e95b925 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 @@ -400,6 +400,34 @@ export interface ReplayLastProvenanceEventRequest { nodes: string; } +export type ConnectionDirection = 'upstream' | 'downstream'; + +export interface ViewComponentConnectionsRequest { + // the id of the component whose connections are being requested + id: string; + // the name of the component, or its id when the current user cannot read it + name: string; + // the type of the component + type: ComponentType; + // the id of the group that defines the connections. this is the current group for every component + // except an Input Port searched upstream or an Output Port searched downstream, whose connections + // cross the enclosing group's boundary and are defined in its parent + groupId: string; + direction: ConnectionDirection; +} + +export interface ComponentConnectionsDialogRequest { + componentName: string; + componentType: ComponentType; + // the group the connections belong to, used when navigating to one of them + groupId: string; + direction: ConnectionDirection; + connections: ConnectionEntity[]; + // names resolved from the same flow entity that supplied the connections + groupIdToName: Map; + remoteProcessGroupIds: Set; +} + /* Snippets */ @@ -454,6 +482,21 @@ export interface ComponentEntityWithDimensions extends ComponentEntity { dimensions: Dimensions; } +/** + * A connection as returned by the flow endpoints. The source and destination are duplicated outside + * of the permission gated `component` so that a connection the current user cannot read can still be + * placed on the canvas. Prefer these fields over `component.source`/`component.destination` when the + * connection may be unauthorized. + */ +export interface ConnectionEntity extends ComponentEntity { + sourceId: string; + sourceGroupId: string; + sourceType: string; + destinationId: string; + destinationGroupId: string; + destinationType: string; +} + export interface Dimensions { width: number; height: number; @@ -465,7 +508,7 @@ export interface Flow { processors: ComponentEntity[]; inputPorts: ComponentEntity[]; outputPorts: ComponentEntity[]; - connections: ComponentEntity[]; + connections: ConnectionEntity[]; labels: ComponentEntity[]; funnels: ComponentEntity[]; } diff --git a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/canvas/component-connections-dialog/component-connections-dialog.component.html b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/canvas/component-connections-dialog/component-connections-dialog.component.html new file mode 100644 index 000000000000..c941eb4b1ab1 --- /dev/null +++ b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/canvas/component-connections-dialog/component-connections-dialog.component.html @@ -0,0 +1,149 @@ + + +

{{ title }}

+ + +
+
Selected Component
+ + {{ componentName }} +
+ @if (rows.length === 0) { +
{{ emptyMessage }}
+ } @else { +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Source
Process Group
+ @if (isCurrentProcessGroup(row.source.groupId)) { + + + {{ resolveGroupName(row.source.groupId) }} + + } @else { + + + {{ resolveGroupName(row.source.groupId) }} + + } + Source
Component
+ @if (row.source.name === null) { + + + Unauthorized + + } @else if (isRemoteProcessGroupPort(row.source)) { + + + {{ row.source.name }} + + } @else { + + + {{ row.source.name }} + + } + Connection + + + @if (row.name === null) { + Connection + } @else { + {{ row.name }} + } + + Destination
Process Group
+ @if (isCurrentProcessGroup(row.destination.groupId)) { + + + {{ resolveGroupName(row.destination.groupId) }} + + } @else { + + + {{ resolveGroupName(row.destination.groupId) }} + + } + Destination
Component
+ @if (row.destination.name === null) { + + + Unauthorized + + } @else if (isRemoteProcessGroupPort(row.destination)) { + + + {{ row.destination.name }} + + } @else { + + + {{ row.destination.name }} + + } +
+
+
+ } +
+
+ + + diff --git a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/canvas/component-connections-dialog/component-connections-dialog.component.scss b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/canvas/component-connections-dialog/component-connections-dialog.component.scss new file mode 100644 index 000000000000..d120342b3623 --- /dev/null +++ b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/canvas/component-connections-dialog/component-connections-dialog.component.scss @@ -0,0 +1,49 @@ +/* + * 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. + */ + +.component-connections-table { + width: 100%; + + table { + table-layout: fixed; + width: 100%; + } + + .mat-column-sourceProcessGroup, + .mat-column-sourceComponent, + .mat-column-connection, + .mat-column-destinationProcessGroup, + .mat-column-destinationComponent { + max-width: 0; + } + + .component-connection-cell { + display: block; + width: 100%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .component-type-icon { + display: inline-block; + font-size: 1.15em; + line-height: 1; + vertical-align: baseline; + margin-right: 0.25rem; + } +} diff --git a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/canvas/component-connections-dialog/component-connections-dialog.component.spec.ts b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/canvas/component-connections-dialog/component-connections-dialog.component.spec.ts new file mode 100644 index 000000000000..6f09a4b3ffb1 --- /dev/null +++ b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/canvas/component-connections-dialog/component-connections-dialog.component.spec.ts @@ -0,0 +1,744 @@ +/* + * 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 { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; +import { MockStore, provideMockStore } from '@ngrx/store/testing'; +import { By } from '@angular/platform-browser'; +import { of } from 'rxjs'; +import { ComponentType } from '@nifi/shared'; + +import { ComponentConnectionsDialog, ComponentConnectionRow } from './component-connections-dialog.component'; +import { ComponentConnectionsDialogRequest, ConnectionDirection, ConnectionEntity } from '../../../state/flow'; +import { navigateToComponent } from '../../../state/flow/flow.actions'; +import { CanvasUtils } from '../../../service/canvas-utils.service'; + +const REQUEST_GROUP_ID = 'request-group-id'; +const SOURCE_GROUP_ID = 'source-group-id'; +const DESTINATION_GROUP_ID = 'destination-group-id'; +const UNKNOWN_GROUP_ID = 'unknown-group-id'; + +const SOURCE_ID = 'source-id'; +const DESTINATION_ID = 'destination-id'; +const CONNECTION_ID = 'connection-id'; + +interface ConnectableStub { + id: string; + name: string; +} + +interface ConnectionOptions { + id?: string; + source?: ConnectableStub; + destination?: ConnectableStub; + sourceGroupId?: string; + destinationGroupId?: string; + sourceType?: string; + destinationType?: string; + canRead?: boolean; + name?: string; + selectedRelationships?: string[]; + component?: any | null; +} + +interface CreatedDialog { + component: ComponentConnectionsDialog; + fixture: ComponentFixture; + store: MockStore; + dialogRef: { + close: ReturnType; + keydownEvents: () => ReturnType; + }; +} + +function readableConnection(options: ConnectionOptions = {}): ConnectionEntity { + const source = options.source ?? { id: SOURCE_ID, name: 'GenerateFlowFile' }; + const destination = options.destination ?? { id: DESTINATION_ID, name: 'LogAttribute' }; + + return { + id: options.id ?? CONNECTION_ID, + permissions: { canRead: options.canRead ?? true, canWrite: true }, + position: { x: 0, y: 0 }, + revision: { version: 0 }, + sourceId: source.id, + sourceGroupId: options.sourceGroupId ?? SOURCE_GROUP_ID, + sourceType: options.sourceType ?? 'PROCESSOR', + destinationId: destination.id, + destinationGroupId: options.destinationGroupId ?? DESTINATION_GROUP_ID, + destinationType: options.destinationType ?? 'INPUT_PORT', + component: + options.component === undefined + ? { + id: options.id ?? CONNECTION_ID, + source, + destination, + name: options.name, + selectedRelationships: options.selectedRelationships + } + : options.component + }; +} + +function unreadableConnection(options: ConnectionOptions = {}): ConnectionEntity { + return { + id: options.id ?? CONNECTION_ID, + permissions: { canRead: false, canWrite: false }, + position: { x: 0, y: 0 }, + revision: { version: 0 }, + sourceId: options.source?.id ?? SOURCE_ID, + sourceGroupId: options.sourceGroupId ?? SOURCE_GROUP_ID, + sourceType: options.sourceType ?? 'PROCESSOR', + destinationId: options.destination?.id ?? DESTINATION_ID, + destinationGroupId: options.destinationGroupId ?? DESTINATION_GROUP_ID, + destinationType: options.destinationType ?? 'INPUT_PORT', + component: null + }; +} + +function createDialog( + direction: ConnectionDirection, + connections: ConnectionEntity[], + overrides: Partial = {} +): CreatedDialog { + const dialogRequest: ComponentConnectionsDialogRequest = { + componentName: 'Selected Component', + componentType: ComponentType.InputPort, + groupId: REQUEST_GROUP_ID, + direction, + connections, + groupIdToName: new Map([ + [REQUEST_GROUP_ID, 'Current Process Group'], + [SOURCE_GROUP_ID, 'Source Process Group'], + [DESTINATION_GROUP_ID, 'Destination Process Group'] + ]), + remoteProcessGroupIds: new Set(), + ...overrides + }; + + const dialogRef = { + close: vi.fn(), + keydownEvents: () => of() + }; + + const canvasUtils = { + formatConnectionName: (component: any): string => { + if (component?.name) { + return component.name; + } + if (component?.selectedRelationships) { + return component.selectedRelationships.join(', '); + } + return ''; + } + }; + + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + imports: [ComponentConnectionsDialog], + providers: [ + { provide: MAT_DIALOG_DATA, useValue: dialogRequest }, + { provide: MatDialogRef, useValue: dialogRef }, + { provide: CanvasUtils, useValue: canvasUtils }, + provideMockStore({}) + ] + }); + + const fixture = TestBed.createComponent(ComponentConnectionsDialog); + fixture.detectChanges(); + + return { + component: fixture.componentInstance, + fixture, + store: TestBed.inject(MockStore), + dialogRef + }; +} + +function textContent(fixture: ComponentFixture): string { + return (fixture.nativeElement.textContent as string).replace(/\s+/g, ' ').trim(); +} + +function getCells(fixture: ComponentFixture, columnClass: string): HTMLElement[] { + return fixture.debugElement.queryAll(By.css(`td.${columnClass}`)).map((debugElement) => debugElement.nativeElement); +} + +describe('ComponentConnectionsDialog', () => { + it('creates the dialog', () => { + const { component } = createDialog('upstream', []); + + expect(component).toBeTruthy(); + }); + + describe('dialog metadata', () => { + it('sets upstream title and empty message', () => { + const { component, fixture } = createDialog('upstream', []); + + expect(component.title).toBe('Upstream Connections'); + expect(component.emptyMessage).toBe('No upstream connections were found.'); + expect(textContent(fixture)).toContain('Upstream Connections'); + expect(textContent(fixture)).toContain('No upstream connections were found.'); + }); + + it('sets downstream title and empty message', () => { + const { component, fixture } = createDialog('downstream', []); + + expect(component.title).toBe('Downstream Connections'); + expect(component.emptyMessage).toBe('No downstream connections were found.'); + expect(textContent(fixture)).toContain('Downstream Connections'); + expect(textContent(fixture)).toContain('No downstream connections were found.'); + }); + + it('renders the selected component name and icon', () => { + const { fixture } = createDialog('upstream', [], { + componentName: 'Input Port A', + componentType: ComponentType.InputPort + }); + + expect(textContent(fixture)).toContain('Selected Component'); + expect(textContent(fixture)).toContain('Input Port A'); + expect(fixture.debugElement.query(By.css('.icon-port-in'))).not.toBeNull(); + }); + }); + + describe('row construction', () => { + it('builds a row for a readable connection with a connection name', () => { + const connection = readableConnection({ + id: 'named-connection-id', + name: 'Named Connection', + source: { id: 'processor-id', name: 'GenerateFlowFile' }, + destination: { id: 'input-port-id', name: 'Input Port' } + }); + + const { component } = createDialog('upstream', [connection]); + + expect(component.rows).toEqual([ + { + id: 'named-connection-id', + name: 'Named Connection', + source: { + id: 'processor-id', + groupId: SOURCE_GROUP_ID, + type: ComponentType.Processor, + name: 'GenerateFlowFile' + }, + destination: { + id: 'input-port-id', + groupId: DESTINATION_GROUP_ID, + type: ComponentType.InputPort, + name: 'Input Port' + } + } + ]); + }); + + it('uses selected relationships as the connection name when no explicit connection name is present', () => { + const connection = readableConnection({ + selectedRelationships: ['success', 'retry'] + }); + + const { component, fixture } = createDialog('upstream', [connection]); + + expect(component.rows[0].name).toBe('success, retry'); + expect(textContent(fixture)).toContain('success, retry'); + }); + + it('uses a null connection name when the formatted name is empty', () => { + const connection = readableConnection(); + + const { component } = createDialog('upstream', [connection]); + + expect(component.rows[0].name).toBeNull(); + }); + + it('keeps unreadable connections using top-level endpoint identifiers and null endpoint names', () => { + const connection = unreadableConnection({ + id: 'unreadable-connection-id', + source: { id: 'hidden-source-id', name: 'Hidden Source' }, + destination: { id: 'hidden-destination-id', name: 'Hidden Destination' } + }); + + const { component, fixture } = createDialog('upstream', [connection]); + + expect(component.rows).toEqual([ + { + id: 'unreadable-connection-id', + name: null, + source: { + id: 'hidden-source-id', + groupId: SOURCE_GROUP_ID, + type: ComponentType.Processor, + name: null + }, + destination: { + id: 'hidden-destination-id', + groupId: DESTINATION_GROUP_ID, + type: ComponentType.InputPort, + name: null + } + } + ]); + + expect(textContent(fixture)).toContain('Unauthorized'); + }); + + it('maps remote input and output port endpoint types to RemoteProcessGroup', () => { + const remoteInputConnection = readableConnection({ + id: 'remote-input-connection-id', + sourceType: 'REMOTE_INPUT_PORT', + destinationType: 'REMOTE_OUTPUT_PORT' + }); + + const { component } = createDialog('downstream', [remoteInputConnection]); + + expect(component.rows[0].source.type).toBe(ComponentType.RemoteProcessGroup); + expect(component.rows[0].destination.type).toBe(ComponentType.RemoteProcessGroup); + }); + + it('maps unknown endpoint types to Connector', () => { + const unknownTypeConnection = readableConnection({ + sourceType: 'UNKNOWN_SOURCE_TYPE', + destinationType: 'UNKNOWN_DESTINATION_TYPE' + }); + + const { component } = createDialog('downstream', [unknownTypeConnection]); + + expect(component.rows[0].source.type).toBe(ComponentType.Connector); + expect(component.rows[0].destination.type).toBe(ComponentType.Connector); + }); + }); + + describe('rendering', () => { + it('renders the expected table columns', () => { + const { component, fixture } = createDialog('upstream', [readableConnection()]); + + expect(component.displayedColumns).toEqual([ + 'sourceProcessGroup', + 'sourceComponent', + 'connection', + 'destinationProcessGroup', + 'destinationComponent' + ]); + + const renderedText = textContent(fixture); + expect(renderedText).toContain('Source Process Group'); + expect(renderedText).toContain('Source Component'); + expect(renderedText).toContain('Connection'); + expect(renderedText).toContain('Destination Process Group'); + expect(renderedText).toContain('Destination Component'); + }); + + it('renders process group names resolved from the request map', () => { + const { fixture } = createDialog('upstream', [readableConnection()]); + + expect(textContent(fixture)).toContain('Source Process Group'); + expect(textContent(fixture)).toContain('Destination Process Group'); + }); + + it('renders unknown process group ids when no name is available', () => { + const connection = readableConnection({ + sourceGroupId: UNKNOWN_GROUP_ID, + destinationGroupId: UNKNOWN_GROUP_ID + }); + + const { fixture } = createDialog('upstream', [connection]); + + expect(textContent(fixture)).toContain(UNKNOWN_GROUP_ID); + }); + + it('renders component names and the formatted connection name', () => { + const connection = readableConnection({ + name: 'Connection Name', + source: { id: 'source-component-id', name: 'Source Component Name' }, + destination: { id: 'destination-component-id', name: 'Destination Component Name' } + }); + + const { fixture } = createDialog('upstream', [connection]); + + const renderedText = textContent(fixture); + expect(renderedText).toContain('Source Component Name'); + expect(renderedText).toContain('Connection Name'); + expect(renderedText).toContain('Destination Component Name'); + }); + + it('renders "Connection" for an unnamed connection', () => { + const { component, fixture } = createDialog('upstream', [readableConnection()]); + + expect(component.rows[0].name).toBeNull(); + expect(textContent(fixture)).toContain('Connection'); + }); + + it('marks the header as sticky and applies striped row classes', () => { + const { fixture } = createDialog('upstream', [ + readableConnection({ id: 'connection-1' }), + readableConnection({ id: 'connection-2' }) + ]); + + expect(fixture.debugElement.query(By.css('tr.mat-mdc-header-row'))).not.toBeNull(); + + const rows = fixture.debugElement.queryAll(By.css('tr.mat-mdc-row')); + expect(rows.length).toBe(2); + expect(rows[0].nativeElement.classList.contains('even')).toBeTruthy(); + expect(rows[1].nativeElement.classList.contains('even')).toBeFalsy(); + }); + + it('renders table cells using component-connection-cell wrappers for truncation styling', () => { + const { fixture } = createDialog('upstream', [readableConnection({ name: 'Named Connection' })]); + + expect(fixture.debugElement.queryAll(By.css('.component-connection-cell')).length).toBeGreaterThan(0); + }); + }); + + describe('process group name resolution', () => { + it('resolves process group names from the dialog request map', () => { + const { component } = createDialog('upstream', []); + + expect(component.resolveGroupName(REQUEST_GROUP_ID)).toBe('Current Process Group'); + expect(component.resolveGroupName(SOURCE_GROUP_ID)).toBe('Source Process Group'); + expect(component.resolveGroupName(DESTINATION_GROUP_ID)).toBe('Destination Process Group'); + }); + + it('falls back to the group id when no process group name is available', () => { + const { component } = createDialog('upstream', []); + + expect(component.resolveGroupName(UNKNOWN_GROUP_ID)).toBe(UNKNOWN_GROUP_ID); + }); + + it('identifies the current process group from the dialog request group id', () => { + const { component } = createDialog('upstream', []); + + expect(component.isCurrentProcessGroup(REQUEST_GROUP_ID)).toBeTruthy(); + expect(component.isCurrentProcessGroup(SOURCE_GROUP_ID)).toBeFalsy(); + }); + }); + + describe('navigation', () => { + it('dispatches navigation and closes the dialog when navigateTo is called', () => { + const { component, store, dialogRef } = createDialog('upstream', []); + const dispatch = vi.spyOn(store, 'dispatch'); + + component.navigateTo('target-id', 'target-group-id', ComponentType.Processor); + + expect(dispatch).toHaveBeenCalledWith( + navigateToComponent({ + request: { + id: 'target-id', + processGroupId: 'target-group-id', + type: ComponentType.Processor + } + }) + ); + expect(dialogRef.close).toHaveBeenCalled(); + }); + + it('renders the current source process group as non-clickable', () => { + const connection = readableConnection({ + sourceGroupId: REQUEST_GROUP_ID, + destinationGroupId: DESTINATION_GROUP_ID + }); + + const { fixture } = createDialog('upstream', [connection]); + + const sourceProcessGroupCell = getCells(fixture, 'mat-column-sourceProcessGroup')[0]; + expect(sourceProcessGroupCell.querySelector('span')).not.toBeNull(); + expect(sourceProcessGroupCell.querySelector('a')).toBeNull(); + }); + + it('renders a non-current source process group as clickable and navigates to it', () => { + const connection = readableConnection({ + sourceGroupId: SOURCE_GROUP_ID + }); + + const { fixture, store, dialogRef } = createDialog('upstream', [connection]); + const dispatch = vi.spyOn(store, 'dispatch'); + + const sourceProcessGroupCell = getCells(fixture, 'mat-column-sourceProcessGroup')[0]; + const link = sourceProcessGroupCell.querySelector('a') as HTMLAnchorElement; + link.click(); + + expect(dispatch).toHaveBeenCalledWith( + navigateToComponent({ + request: { + id: SOURCE_GROUP_ID, + processGroupId: REQUEST_GROUP_ID, + type: ComponentType.ProcessGroup + } + }) + ); + expect(dialogRef.close).toHaveBeenCalled(); + }); + + it('renders the current destination process group as non-clickable', () => { + const connection = readableConnection({ + sourceGroupId: SOURCE_GROUP_ID, + destinationGroupId: REQUEST_GROUP_ID + }); + + const { fixture } = createDialog('upstream', [connection]); + + const destinationProcessGroupCell = getCells(fixture, 'mat-column-destinationProcessGroup')[0]; + expect(destinationProcessGroupCell.querySelector('span')).not.toBeNull(); + expect(destinationProcessGroupCell.querySelector('a')).toBeNull(); + }); + + it('navigates to the readable source component using the source component group id', () => { + const connection = readableConnection({ + source: { id: 'source-component-id', name: 'Source Component' }, + sourceGroupId: SOURCE_GROUP_ID, + sourceType: 'PROCESSOR' + }); + + const { fixture, store, dialogRef } = createDialog('upstream', [connection]); + const dispatch = vi.spyOn(store, 'dispatch'); + + const sourceComponentCell = getCells(fixture, 'mat-column-sourceComponent')[0]; + const link = sourceComponentCell.querySelector('a') as HTMLAnchorElement; + link.click(); + + expect(dispatch).toHaveBeenCalledWith( + navigateToComponent({ + request: { + id: 'source-component-id', + processGroupId: SOURCE_GROUP_ID, + type: ComponentType.Processor + } + }) + ); + expect(dialogRef.close).toHaveBeenCalled(); + }); + + it('navigates to the readable destination component using the destination component group id', () => { + const connection = readableConnection({ + destination: { id: 'destination-component-id', name: 'Destination Component' }, + destinationGroupId: DESTINATION_GROUP_ID, + destinationType: 'OUTPUT_PORT' + }); + + const { fixture, store, dialogRef } = createDialog('downstream', [connection]); + const dispatch = vi.spyOn(store, 'dispatch'); + + const destinationComponentCell = getCells(fixture, 'mat-column-destinationComponent')[0]; + const link = destinationComponentCell.querySelector('a') as HTMLAnchorElement; + link.click(); + + expect(dispatch).toHaveBeenCalledWith( + navigateToComponent({ + request: { + id: 'destination-component-id', + processGroupId: DESTINATION_GROUP_ID, + type: ComponentType.OutputPort + } + }) + ); + expect(dialogRef.close).toHaveBeenCalled(); + }); + + it('does not render unreadable components as clickable', () => { + const { fixture } = createDialog('upstream', [unreadableConnection()]); + + const sourceComponentCell = getCells(fixture, 'mat-column-sourceComponent')[0]; + const destinationComponentCell = getCells(fixture, 'mat-column-destinationComponent')[0]; + + expect(sourceComponentCell.querySelector('a')).toBeNull(); + expect(destinationComponentCell.querySelector('a')).toBeNull(); + expect(sourceComponentCell.textContent).toContain('Unauthorized'); + expect(destinationComponentCell.textContent).toContain('Unauthorized'); + }); + + it('renders a remote input port source component as non-clickable', () => { + const connection = readableConnection({ + source: { id: 'remote-input-port-id', name: 'Remote Input Port' }, + sourceGroupId: 'remote-process-group-id', + sourceType: 'REMOTE_INPUT_PORT', + destination: { id: 'processor-id', name: 'Processor' }, + destinationType: 'PROCESSOR' + }); + + const { fixture, store, dialogRef } = createDialog('downstream', [connection], { + remoteProcessGroupIds: new Set(['remote-process-group-id']) + }); + const dispatch = vi.spyOn(store, 'dispatch'); + + const sourceComponentCell = getCells(fixture, 'mat-column-sourceComponent')[0]; + + expect(sourceComponentCell.textContent).toContain('Remote Input Port'); + expect(sourceComponentCell.querySelector('span')).not.toBeNull(); + expect(sourceComponentCell.querySelector('a')).toBeNull(); + expect(dispatch).not.toHaveBeenCalled(); + expect(dialogRef.close).not.toHaveBeenCalled(); + }); + + it('renders a remote output port source component as non-clickable', () => { + const connection = readableConnection({ + source: { id: 'remote-output-port-id', name: 'Remote Output Port' }, + sourceGroupId: 'remote-process-group-id', + sourceType: 'REMOTE_OUTPUT_PORT', + destination: { id: 'processor-id', name: 'Processor' }, + destinationType: 'PROCESSOR' + }); + + const { fixture, store, dialogRef } = createDialog('downstream', [connection], { + remoteProcessGroupIds: new Set(['remote-process-group-id']) + }); + const dispatch = vi.spyOn(store, 'dispatch'); + + const sourceComponentCell = getCells(fixture, 'mat-column-sourceComponent')[0]; + + expect(sourceComponentCell.textContent).toContain('Remote Output Port'); + expect(sourceComponentCell.querySelector('span')).not.toBeNull(); + expect(sourceComponentCell.querySelector('a')).toBeNull(); + expect(dispatch).not.toHaveBeenCalled(); + expect(dialogRef.close).not.toHaveBeenCalled(); + }); + + it('renders a remote input port destination component as non-clickable', () => { + const connection = readableConnection({ + source: { id: 'processor-id', name: 'Processor' }, + sourceType: 'PROCESSOR', + destination: { id: 'remote-input-port-id', name: 'Remote Input Port' }, + destinationGroupId: 'remote-process-group-id', + destinationType: 'REMOTE_INPUT_PORT' + }); + + const { fixture, store, dialogRef } = createDialog('upstream', [connection], { + remoteProcessGroupIds: new Set(['remote-process-group-id']) + }); + const dispatch = vi.spyOn(store, 'dispatch'); + + const destinationComponentCell = getCells(fixture, 'mat-column-destinationComponent')[0]; + + expect(destinationComponentCell.textContent).toContain('Remote Input Port'); + expect(destinationComponentCell.querySelector('span')).not.toBeNull(); + expect(destinationComponentCell.querySelector('a')).toBeNull(); + expect(dispatch).not.toHaveBeenCalled(); + expect(dialogRef.close).not.toHaveBeenCalled(); + }); + + it('renders a remote output port destination component as non-clickable', () => { + const connection = readableConnection({ + source: { id: 'processor-id', name: 'Processor' }, + sourceType: 'PROCESSOR', + destination: { id: 'remote-output-port-id', name: 'Remote Output Port' }, + destinationGroupId: 'remote-process-group-id', + destinationType: 'REMOTE_OUTPUT_PORT' + }); + + const { fixture, store, dialogRef } = createDialog('upstream', [connection], { + remoteProcessGroupIds: new Set(['remote-process-group-id']) + }); + const dispatch = vi.spyOn(store, 'dispatch'); + + const destinationComponentCell = getCells(fixture, 'mat-column-destinationComponent')[0]; + + expect(destinationComponentCell.textContent).toContain('Remote Output Port'); + expect(destinationComponentCell.querySelector('span')).not.toBeNull(); + expect(destinationComponentCell.querySelector('a')).toBeNull(); + expect(dispatch).not.toHaveBeenCalled(); + expect(dialogRef.close).not.toHaveBeenCalled(); + }); + + it('continues rendering standard input and output port components as clickable', () => { + const connection = readableConnection({ + source: { id: 'output-port-id', name: 'Output Port' }, + sourceGroupId: SOURCE_GROUP_ID, + sourceType: 'OUTPUT_PORT', + destination: { id: 'input-port-id', name: 'Input Port' }, + destinationGroupId: DESTINATION_GROUP_ID, + destinationType: 'INPUT_PORT' + }); + + const { fixture } = createDialog('downstream', [connection]); + + const sourceComponentCell = getCells(fixture, 'mat-column-sourceComponent')[0]; + const destinationComponentCell = getCells(fixture, 'mat-column-destinationComponent')[0]; + + expect(sourceComponentCell.querySelector('a')).not.toBeNull(); + expect(sourceComponentCell.querySelector('span')).toBeNull(); + expect(destinationComponentCell.querySelector('a')).not.toBeNull(); + expect(destinationComponentCell.querySelector('span')).toBeNull(); + }); + + it('navigates to the connection in the group that defines the dialog request', () => { + const connection = readableConnection({ + id: 'connection-to-navigate-to', + name: 'Connection To Navigate To' + }); + + const { fixture, store, dialogRef } = createDialog('upstream', [connection]); + const dispatch = vi.spyOn(store, 'dispatch'); + + const connectionCell = getCells(fixture, 'mat-column-connection')[0]; + const link = connectionCell.querySelector('a') as HTMLAnchorElement; + link.click(); + + expect(dispatch).toHaveBeenCalledWith( + navigateToComponent({ + request: { + id: 'connection-to-navigate-to', + processGroupId: REQUEST_GROUP_ID, + type: ComponentType.Connection + } + }) + ); + expect(dialogRef.close).toHaveBeenCalled(); + }); + + it('navigates to the connection in the group that defines the dialog request', () => { + const connection = readableConnection({ + id: 'connection-to-navigate-to', + name: 'Connection To Navigate To' + }); + + const { fixture, store, dialogRef } = createDialog('upstream', [connection]); + const dispatch = vi.spyOn(store, 'dispatch'); + + const connectionCell = getCells(fixture, 'mat-column-connection')[0]; + const link = connectionCell.querySelector('a') as HTMLAnchorElement; + link.click(); + + expect(dispatch).toHaveBeenCalledWith( + navigateToComponent({ + request: { + id: 'connection-to-navigate-to', + processGroupId: REQUEST_GROUP_ID, + type: ComponentType.Connection + } + }) + ); + expect(dialogRef.close).toHaveBeenCalled(); + }); + }); + + describe('icons', () => { + it('returns the expected icon class for supported component types', () => { + const { component } = createDialog('upstream', []); + + expect(component.componentIcon(ComponentType.Processor)).toBe('icon-processor'); + expect(component.componentIcon(ComponentType.InputPort)).toBe('icon-port-in'); + expect(component.componentIcon(ComponentType.OutputPort)).toBe('icon-port-out'); + expect(component.componentIcon(ComponentType.Funnel)).toBe('icon-funnel'); + expect(component.componentIcon(ComponentType.ProcessGroup)).toBe('icon-group'); + expect(component.componentIcon(ComponentType.RemoteProcessGroup)).toBe('icon-group-remote'); + expect(component.componentIcon(ComponentType.Connection)).toBe('icon-connect'); + }); + + it('returns the drop icon for unsupported component types', () => { + const { component } = createDialog('upstream', []); + + expect(component.componentIcon(ComponentType.ControllerService)).toBe('icon-drop'); + }); + }); +}); diff --git a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/canvas/component-connections-dialog/component-connections-dialog.component.ts b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/canvas/component-connections-dialog/component-connections-dialog.component.ts new file mode 100644 index 000000000000..c3c65e5ccb15 --- /dev/null +++ b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/canvas/component-connections-dialog/component-connections-dialog.component.ts @@ -0,0 +1,222 @@ +/* + * 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 { Component, inject } from '@angular/core'; +import { MAT_DIALOG_DATA, MatDialogModule, MatDialogRef } from '@angular/material/dialog'; +import { MatButtonModule } from '@angular/material/button'; +import { MatTableModule } from '@angular/material/table'; +import { MatTooltipModule } from '@angular/material/tooltip'; +import { Store } from '@ngrx/store'; +import { CloseOnEscapeDialog, ComponentType } from '@nifi/shared'; +import { CanvasState } from '../../../state'; +import { ComponentConnectionsDialogRequest, ConnectionEntity } from '../../../state/flow'; +import { CanvasUtils } from '../../../service/canvas-utils.service'; +import { navigateToComponent } from '../../../state/flow/flow.actions'; + +/** + * One end of a connection, with enough information to render a cell and navigate to it. + * - {@code id}: the component's own id. + * - {@code groupId}: the id of the process group that directly contains the component. + * - {@code type}: the component type, used to tell {@code navigateToComponent} what it's looking at. + * - {@code name}: the component name, or {@code null} when the current user cannot read the + * connection, in which case the cell renders an "Unauthorized" placeholder and is not clickable. + */ +export interface ConnectionEndpoint { + id: string; + groupId: string; + type: ComponentType; + name: string | null; +} + +/** + * Row in the connections table. + * - {@code id}: the connection id. + * - {@code name}: the connection name, or the relationships it carries when it has no name. + * {@code null} when it has neither, so the cell renders an "Unnamed" placeholder. + * + * Both ends are listed, along with each end's process group, rather than only the far end. When the + * selected component is a Process Group or Remote Process Group the connection actually terminates at + * a port inside it, and which group and port that is matters as much as the component on the other side. + */ +export interface ComponentConnectionRow { + id: string; + name: string | null; + source: ConnectionEndpoint; + destination: ConnectionEndpoint; +} + +/** + * Lists the connections attached to a component in one direction. For most components those + * connections are already drawn on the canvas, so this is a way to reach one whose other end sits + * somewhere else entirely. For an Input Port's upstream connections and an Output Port's downstream + * connections it is the only way, since those are defined in the parent process group and are not drawn + * alongside the port at all. Each of the 5 cells in a row is independently clickable and navigates + * to the process group, component, or connection it represents. + */ +@Component({ + selector: 'component-connections-dialog', + imports: [MatButtonModule, MatDialogModule, MatTableModule, MatTooltipModule], + templateUrl: './component-connections-dialog.component.html', + styleUrls: ['./component-connections-dialog.component.scss'] +}) +export class ComponentConnectionsDialog extends CloseOnEscapeDialog { + private dialogRequest = inject(MAT_DIALOG_DATA); + private componentConnectionsDialogRef = inject>(MatDialogRef); + private store = inject>(Store); + private canvasUtils = inject(CanvasUtils); + + // Maps the string type returned by the NiFi API to the ComponentType enum used for navigation. + private static readonly TYPE_MAP: Record = { + PROCESSOR: ComponentType.Processor, + INPUT_PORT: ComponentType.InputPort, + OUTPUT_PORT: ComponentType.OutputPort, + REMOTE_INPUT_PORT: ComponentType.RemoteProcessGroup, + REMOTE_OUTPUT_PORT: ComponentType.RemoteProcessGroup, + FUNNEL: ComponentType.Funnel + }; + + readonly displayedColumns: string[] = [ + 'sourceProcessGroup', + 'sourceComponent', + 'connection', + 'destinationProcessGroup', + 'destinationComponent' + ]; + readonly componentName: string; + readonly componentType: ComponentType = this.dialogRequest.componentType; + readonly title: string; + readonly emptyMessage: string; + readonly rows: ComponentConnectionRow[]; + readonly dialogRequestGroupId: string = this.dialogRequest.groupId; + readonly processGroupType = ComponentType.ProcessGroup; + readonly connectionType = ComponentType.Connection; + + constructor() { + super(); + + const upstream = this.dialogRequest.direction === 'upstream'; + this.componentName = this.dialogRequest.componentName; + this.title = upstream ? 'Upstream Connections' : 'Downstream Connections'; + this.emptyMessage = upstream ? 'No upstream connections were found.' : 'No downstream connections were found.'; + this.rows = this.dialogRequest.connections.map((connection: ConnectionEntity) => this.buildRow(connection)); + } + + /** + * Navigates to and selects the given component, then closes the dialog. + * + * @param id the id of the component to navigate to + * @param processGroupId the id of the process group that should be entered to find the component + * @param type the type of component being navigated to + */ + navigateTo(id: string, processGroupId: string, type: ComponentType): void { + this.store.dispatch( + navigateToComponent({ + request: { + id, + processGroupId, + type + } + }) + ); + this.componentConnectionsDialogRef.close(); + } + + /** + * Determines whether the given process group is the group currently shown on the canvas. + * + * @param groupId the process group id to check + * @returns whether the group is the current canvas group + */ + isCurrentProcessGroup(groupId: string): boolean { + return groupId === this.dialogRequestGroupId; + } + + /** + * Maps a Process Group ID value to its name. + * + * @param groupId the uuid of the process group + * @returns string name of the process group + */ + resolveGroupName(groupId: string): string { + return this.dialogRequest.groupIdToName.get(groupId) ?? groupId; + } + + /** + * Determines whether the endpoint is a port inside a Remote Process Group. Remote ports + * are not rendered as separate selectable elements on the current graph, so they should + * not be linked from the connections table. + * + * @param endpoint the source or destination endpoint to check + * @returns whether the endpoint is a remote port in a Remote Process Group + */ + isRemoteProcessGroupPort(endpoint: ConnectionEndpoint): boolean { + return endpoint.type === ComponentType.RemoteProcessGroup; + } + + /** + * Resolves the flowfont icon class that represents the given component type, matching the icons + * used for the same components on the canvas. + * + * @param type the type of the component + * @returns the icon class to render ahead of the component name + */ + componentIcon(type: ComponentType): string { + switch (type) { + case ComponentType.Processor: + return 'icon-processor'; + case ComponentType.InputPort: + return 'icon-port-in'; + case ComponentType.OutputPort: + return 'icon-port-out'; + case ComponentType.Funnel: + return 'icon-funnel'; + case ComponentType.ProcessGroup: + return 'icon-group'; + case ComponentType.RemoteProcessGroup: + return 'icon-group-remote'; + case ComponentType.Connection: + return 'icon-connect'; + default: + return 'icon-drop'; + } + } + + private buildRow(connection: ConnectionEntity): ComponentConnectionRow { + const name = connection.component ? this.canvasUtils.formatConnectionName(connection.component) : ''; + + return { + id: connection.id, + name: name === '' ? null : name, + source: { + id: connection.sourceId, + groupId: connection.sourceGroupId, + type: this.mapComponentType(connection.sourceType), + name: connection.component?.source?.name ?? null + }, + destination: { + id: connection.destinationId, + groupId: connection.destinationGroupId, + type: this.mapComponentType(connection.destinationType), + name: connection.component?.destination?.name ?? null + } + }; + } + + private mapComponentType(type: string): ComponentType { + return ComponentConnectionsDialog.TYPE_MAP[type] ?? ComponentType.Connector; + } +}