rfellows commented on code in PR #11582: URL: https://github.com/apache/nifi/pull/11582#discussion_r4028687684
########## 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 @@ +<!-- + ~ 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. + --> + +<h2 mat-dialog-title>{{ title }}</h2> +<mat-dialog-content> +<!-- <div class="flex flex-col gap-y-4">--> + <div class="dialog-content flex flex-col h-full"> + <div class="tertiary-color font-medium">Selected Component<br> + <i class="icon component-type-icon" [class]="componentIcon(componentType)"></i> + {{ componentName }} + </div> Review Comment: The “Selected Component” header (icon + name) is a good place to reuse the shared `component-context` widget instead of a one-off icon/name block. That widget already maps `ComponentType` to the canvas icon (including `icon-group-remote`), shows the type label, and offers a copyable id. Cluster Summary uses it the same way at the top of a dialog: ```html <component-context [type]="componentType" [name]="componentName" [id]="componentId"></component-context> ``` (`cluster-summary-dialog.component.html`; also the Operation panel.) Import `ComponentContext` from `@nifi/shared` and add it to this component’s `imports`. `ViewComponentConnectionsRequest` already has `id` / `name` / `type`. Those just need to be forwarded on `ComponentConnectionsDialogRequest` so the header can bind `[id]` as well. The name fallback you already have in `requestComponentConnections` (readable name, else the component id) is the right `[name]` — `component-context` does not itself handle `canRead`. ########## nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/service/canvas-context-menu.service.ts: ########## @@ -287,25 +289,23 @@ export class CanvasContextMenu implements ContextMenuDefinitionProvider { id: 'upstream-downstream', menuItems: [ { - condition: () => { - // TODO - hasUpstream - return false; + condition: (selection: d3.Selection<any, any, any, any>) => { + return this.canvasUtils.hasUpstream(selection); }, Review Comment: View Connections never appears on empty-canvas right-click. `hasUpstream` / `hasDownstream` require `selection.size() === 1`, so a click on the canvas (no component selected) yields an empty submenu and the parent item is hidden (`context-menu.component.ts` keeps a submenu only when it has visible children). Empty canvas is the current process group — the Operation panel already treats `selection.size() === 0` that way (`getContextType` → `ProcessGroup`, name from breadcrumbs). Several canvas actions do the same, for example Enable/Disable All Controller Services: ```typescript condition: (selection) => { return this.canvasUtils.isProcessGroup(selection) || this.canvasUtils.emptySelection(selection); } ``` If the intent is to restore 1.x “view connections for the group I’m in,” allow empty selection here and, when `selection.empty()`, use the current process group id (`canvasUtils.getProcessGroupId()`) instead of `selection.datum()`. That matches how those other current-PG actions are wired. ########## 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 @@ +<!-- + ~ 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. + --> + +<h2 mat-dialog-title>{{ title }}</h2> +<mat-dialog-content> +<!-- <div class="flex flex-col gap-y-4">--> + <div class="dialog-content flex flex-col h-full"> + <div class="tertiary-color font-medium">Selected Component<br> + <i class="icon component-type-icon" [class]="componentIcon(componentType)"></i> + {{ componentName }} + </div> + @if (rows.length === 0) { + <div class="neutral-contrast">{{ emptyMessage }}</div> + } @else { + <div class="listing-table component-connections-table flex-1 relative"> + <div class="absolute inset-0 overflow-y-auto overflow-x-hidden"> + <table mat-table [dataSource]="rows"> + <ng-container matColumnDef="sourceProcessGroup"> + <th mat-header-cell *matHeaderCellDef>Source <br>Process Group</th> + <td mat-cell *matCellDef="let row"> + @if (isCurrentProcessGroup(row.source.groupId)) { + <span class="component-connection-cell neutral-contrast" [matTooltip]="resolveGroupName(row.source.groupId)"> + <i class="icon component-type-icon" [class]="componentIcon(processGroupType)"></i> + {{ resolveGroupName(row.source.groupId) }} + </span> + } @else { + <a + class="component-connection-cell neutral-contrast" + [matTooltip]="resolveGroupName(row.source.groupId)" + (click)="navigateTo(row.source.groupId, dialogRequestGroupId, processGroupType)"> + <i class="icon component-type-icon flex-none" [class]="componentIcon(processGroupType)"></i> + {{ resolveGroupName(row.source.groupId) }} + </a> Review Comment: Remote ports are correctly non-clickable now, but the Source/Destination Process Group cells still always call `navigateTo(..., processGroupType)`: ```html (click)="navigateTo(row.source.groupId, dialogRequestGroupId, processGroupType)" ``` For `REMOTE_INPUT_PORT` / `REMOTE_OUTPUT_PORT`, `groupId` is the remote process group id, so this still routes to `/process-groups/{current}/ProcessGroup/{rpgId}` instead of `RemoteProcessGroup/{rpgId}` — the same class of bug as navigating the port itself. The icon is also `icon-group` rather than `icon-group-remote`. Go To Source already uses `ComponentType.RemoteProcessGroup` when the connectable is remote (`canvas-context-menu.service.ts`). `remoteProcessGroupIds` is already on the dialog request and never read; wire that (or `endpoint.type === RemoteProcessGroup`) for the group-cell type and icon, and add a click test for that cell. The destination process-group link (lines 103–109) has the same issue. ########## 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 @@ +<!-- + ~ 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. + --> + +<h2 mat-dialog-title>{{ title }}</h2> +<mat-dialog-content> +<!-- <div class="flex flex-col gap-y-4">--> + <div class="dialog-content flex flex-col h-full"> + <div class="tertiary-color font-medium">Selected Component<br> + <i class="icon component-type-icon" [class]="componentIcon(componentType)"></i> + {{ componentName }} + </div> + @if (rows.length === 0) { + <div class="neutral-contrast">{{ emptyMessage }}</div> + } @else { + <div class="listing-table component-connections-table flex-1 relative"> + <div class="absolute inset-0 overflow-y-auto overflow-x-hidden"> + <table mat-table [dataSource]="rows"> Review Comment: This listing table is not sortable. Other dialog tables (Change Version, Local Changes) use Angular Material sort on the `mat-table`. A compact version of that pattern: ```html <table mat-table [dataSource]="dataSource" matSort matSortDisableClear (matSortChange)="sortData($event)" [matSortActive]="initialSortColumn" [matSortDirection]="initialSortDirection"> ... <th mat-header-cell *matHeaderCellDef mat-sort-header>Connection</th> ``` See `change-version-dialog.html` (73–80, 99–100) and `local-changes-table.html` / `local-changes-table.ts` (`sortData` + `nifiCommon.compareString`). Practical defaults for this dialog: - Use `MatTableDataSource` (or keep the array and re-assign on sort). - Import `MatSortModule`. - Default `[matSortActive]="'connection'"` and `[matSortDirection]="'asc'"`. - Sort each column by the visible label (`resolveGroupName(...)`, endpoint name, connection name), with `nifiCommon.compareString`, so unauthorized / unnamed rows still have a stable key. ########## 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'; Review Comment: Unused `BreadcrumbEntity` import (`@typescript-eslint/no-unused-vars`), leftover from moving the name map onto the dialog request. Please drop it (same lint pass as the trailing comma in `flow.effects.ts`). ########## nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/state/flow/flow.effects.ts: ########## @@ -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), Review Comment: `npx nx run nifi:lint` fails on this change set: 1. Here — prettier wants the trailing comma after `remoteProcessGroupIds: this.buildRemoteProcessGroupIdSet(flowEntity)` removed. 2. `flow.selectors.ts:22` — unused `BreadcrumbEntity` import (`@typescript-eslint/no-unused-vars`), leftover from moving the name map onto the dialog request. Please fix both so CI lint stays green. ########## 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 @@ +<!-- + ~ 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. + --> + +<h2 mat-dialog-title>{{ title }}</h2> +<mat-dialog-content> +<!-- <div class="flex flex-col gap-y-4">--> + <div class="dialog-content flex flex-col h-full"> + <div class="tertiary-color font-medium">Selected Component<br> + <i class="icon component-type-icon" [class]="componentIcon(componentType)"></i> + {{ componentName }} + </div> + @if (rows.length === 0) { + <div class="neutral-contrast">{{ emptyMessage }}</div> + } @else { + <div class="listing-table component-connections-table flex-1 relative"> + <div class="absolute inset-0 overflow-y-auto overflow-x-hidden"> + <table mat-table [dataSource]="rows"> + <ng-container matColumnDef="sourceProcessGroup"> + <th mat-header-cell *matHeaderCellDef>Source <br>Process Group</th> + <td mat-cell *matCellDef="let row"> + @if (isCurrentProcessGroup(row.source.groupId)) { + <span class="component-connection-cell neutral-contrast" [matTooltip]="resolveGroupName(row.source.groupId)"> + <i class="icon component-type-icon" [class]="componentIcon(processGroupType)"></i> + {{ resolveGroupName(row.source.groupId) }} + </span> + } @else { + <a + class="component-connection-cell neutral-contrast" + [matTooltip]="resolveGroupName(row.source.groupId)" + (click)="navigateTo(row.source.groupId, dialogRequestGroupId, processGroupType)"> + <i class="icon component-type-icon flex-none" [class]="componentIcon(processGroupType)"></i> + {{ resolveGroupName(row.source.groupId) }} + </a> + } + </td> + </ng-container> + + <ng-container matColumnDef="sourceComponent"> + <th mat-header-cell *matHeaderCellDef>Source <br>Component</th> + <td mat-cell *matCellDef="let row"> + @if (row.source.name === null) { + <span class="component-connection-cell neutral-contrast" [matTooltip]="row.source.id"> + <i class="icon component-type-icon" [class]="componentIcon(row.source.type)"></i> + Unauthorized + </span> + } @else if (isRemoteProcessGroupPort(row.source)) { + <span class="component-connection-cell neutral-contrast" [matTooltip]="row.source.name"> + <i class="icon component-type-icon" [class]="componentIcon(row.source.type)"></i> + {{ row.source.name }} + </span> + } @else { + <a + class="component-connection-cell neutral-contrast" + [matTooltip]="row.source.name" + (click)="navigateTo(row.source.id, row.source.groupId, row.source.type)"> + <i class="icon component-type-icon" [class]="componentIcon(row.source.type)"></i> + {{ row.source.name }} + </a> Review Comment: The underline on these links starts a character before the icon/text. That comes from whitespace in the template becoming text nodes inside the `<a>`. For example: ```html <a class="component-connection-cell neutral-contrast" ...> <i class="icon component-type-icon" [class]="componentIcon(row.source.type)"></i> {{ row.source.name }} </a> ``` The newline/indent between `>` / `<i>` / `{{ ... }}` is rendered as spaces, so the underline is longer than the visible content. Icon-to-label spacing is already handled in SCSS (`.component-type-icon { margin-right: 0.25rem; }`), so the extra DOM spaces are not needed. Please keep the opening tag, icon, interpolation, and closing tag flush (all five link/span cells): ```html <a class="component-connection-cell neutral-contrast" [matTooltip]="row.source.name" (click)="navigateTo(row.source.id, row.source.groupId, row.source.type)" ><i class="icon component-type-icon" [class]="componentIcon(row.source.type)"></i>{{ row.source.name }}</a> ``` ########## 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 @@ +<!-- + ~ 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. + --> + +<h2 mat-dialog-title>{{ title }}</h2> +<mat-dialog-content> +<!-- <div class="flex flex-col gap-y-4">--> Review Comment: A couple of leftovers from the iteration: 1. This commented-out wrapper: `<!-- <div class="flex flex-col gap-y-4">-->`. Please delete it rather than leaving it in the shipped template. 2. `component-connections-dialog.component.spec.ts` 674–697 and 699–722 are the same test (`navigates to the connection in the group that defines the dialog request`). Please keep one. ########## 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<ComponentConnectionsDialog>; + store: MockStore; + dialogRef: { + close: ReturnType<typeof vi.fn>; + keydownEvents: () => ReturnType<typeof of>; + }; +} + +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<ComponentConnectionsDialogRequest> = {} +): 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<ComponentConnectionsDialog>): string { + return (fixture.nativeElement.textContent as string).replace(/\s+/g, ' ').trim(); +} + +function getCells(fixture: ComponentFixture<ComponentConnectionsDialog>, 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<ComponentConnectionRow[]>([ + { + 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<ComponentConnectionRow[]>([ + { + 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(); + }); Review Comment: This test is a duplicate of the one immediately above (same name and assertions). Please keep one. -- This is an automated message from the Apache Git Service. To respond to the message, please log on to GitHub and use the URL above to go to the specific comment. To unsubscribe, e-mail: [email protected] For queries about this service, please contact Infrastructure at: [email protected]
