This is an automated email from the ASF dual-hosted git repository.
rfellows pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/nifi.git
The following commit(s) were added to refs/heads/main by this push:
new cdf1b214a3e NIFI-14777 Restore capability from NiFi 1.x to view
upstream/downstre… (#11582)
cdf1b214a3e is described below
commit cdf1b214a3e48619b1d509de229780748c21683e
Author: markobean <[email protected]>
AuthorDate: Tue Sep 22 13:35:15 2026 -0400
NIFI-14777 Restore capability from NiFi 1.x to view upstream/downstre…
(#11582)
* NIFI-14777 Restore capability from NiFi 1.x to view upstream/downstream
connections. View connections > upstream/downstream is available in the context
menu from a variety of components: input/output ports, processors, process
groups, remote process groups and funnels.
* NIFI-14777 update arrow direction on upstream/downstream context menu
items
* fix 1: remove <button> in favor of <a>
* make current process group non-navigable
* NIFI-14777 refactor PG ID/name selection to include possible peer PG in
the parent group to support port-to-port connection in sibling PGs
Update table formatting with fixed header and scrollable
* NIFI-14777 refactored component-connections-dialog.copmonent.spec.ts to
match current state of upstream/downstream dialog functionality; original was
from a older implementation
* NIFI-14777 updated functionality in dialog to remove the clickable
feature of remote port components
* NIFI-14777 fine tune presentation format of text in table
* NIFI-14777 updated link for remote process group vs. process group
* NIFI-14777 Added functionality for context referring to the current
process group, e.g. right-click on empty space on canvas, including test specs
* NIFI-14777 removed extra whitespace before link text
* NIFI-14777 make dialog table columns sortable including test spec
* NIFI-14777 implemented suggestion to re-use existing component-context
widget
* NIFI-14777 update to handle permissions properly
* NIFI-14777 updated a couple UI display/formatting options
This closes #11582
---
.../service/canvas-context-menu.service.ts | 102 +-
.../service/canvas-utils.service.spec.ts | 69 +
.../flow-designer/service/canvas-utils.service.ts | 8 +
.../state/flow/component-connections.utils.spec.ts | 170 +++
.../state/flow/component-connections.utils.ts | 80 ++
.../pages/flow-designer/state/flow/flow.actions.ts | 14 +-
.../pages/flow-designer/state/flow/flow.effects.ts | 140 +-
.../pages/flow-designer/state/flow/flow.reducer.ts | 14 +-
.../app/pages/flow-designer/state/flow/index.ts | 49 +-
.../component-connections-dialog.component.html | 146 ++
.../component-connections-dialog.component.scss | 49 +
.../component-connections-dialog.component.spec.ts | 1430 ++++++++++++++++++++
.../component-connections-dialog.component.ts | 400 ++++++
13 files changed, 2647 insertions(+), 24 deletions(-)
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 6267e71468f..99b213f9c4e 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,16 +50,18 @@ import {
stopSources,
stopVersionControlRequest,
terminateThreads,
- updatePositions
+ updatePositions,
+ viewComponentConnections
} from '../state/flow/flow.actions';
import { ComponentType } from '@nifi/shared';
import {
ConfirmStopVersionControlRequest,
+ ConnectionDirection,
MoveComponentRequest,
OpenChangeVersionDialogRequest,
OpenLocalChangesDialogRequest
} from '../state/flow';
-import { UpdateComponentRequest } from '../../../state/shared';
+import { BreadcrumbEntity, UpdateComponentRequest } from
'../../../state/shared';
import {
ContextMenuDefinition,
ContextMenuDefinitionProvider,
@@ -287,25 +289,31 @@ export class CanvasContextMenu implements
ContextMenuDefinitionProvider {
id: 'upstream-downstream',
menuItems: [
{
- condition: () => {
- // TODO - hasUpstream
- return false;
+ condition: (selection: d3.Selection<any, any, any, any>) => {
+ // an empty selection targets the current process group,
whose connections are defined in its
+ // parent, so there is nothing to report from the root
process group since it has no parent
+ return (
+ this.canvasUtils.hasUpstream(selection) ||
+
this.canvasUtils.isNotRootGroupAndEmptySelection(selection)
+ );
},
- clazz: 'icon',
+ clazz: 'fa fa-long-arrow-up fa-rotate-45',
text: 'Upstream',
- action: () => {
- // TODO - showUpstream
+ action: (selection: d3.Selection<any, any, any, any>) => {
+ this.requestComponentConnections(selection, 'upstream');
}
},
{
- condition: () => {
- // TODO - hasDownstream
- return false;
+ condition: (selection: d3.Selection<any, any, any, any>) => {
+ return (
+ this.canvasUtils.hasDownstream(selection) ||
+
this.canvasUtils.isNotRootGroupAndEmptySelection(selection)
+ );
},
- clazz: 'icon',
+ clazz: 'fa fa-long-arrow-down fa-rotate-45',
text: 'Downstream',
- action: () => {
- // TODO - showDownstream
+ action: (selection: d3.Selection<any, any, any, any>) => {
+ this.requestComponentConnections(selection, 'downstream');
}
}
]
@@ -1465,4 +1473,70 @@ 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 component whose connections cross
that group's own boundary and
+ * are defined one level up: the upstream side of an Input Port, the
downstream side of an Output
+ * Port, and either side of the current group itself. Those are the cases
that
+ * hasUpstream/hasDownstream gate on the presence of a parent group.
+ *
+ * An empty selection means the user did not select a component, which
implicitly targets the current
+ * group.
+ */
+ private requestComponentConnections(
+ selection: d3.Selection<any, any, any, any>,
+ direction: ConnectionDirection
+ ): void {
+ const currentProcessGroupTargeted: boolean =
this.canvasUtils.emptySelection(selection);
+
+ const crossesParentBoundary: boolean =
+ currentProcessGroupTargeted ||
+ (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 = currentProcessGroupTargeted ?
this.currentProcessGroupDatum() : 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
+ }
+ })
+ );
+ }
+
+ /**
+ * Returns the current group shaped like the datum of a component rendered
on the canvas, so that it
+ * can be reported the same way a selected component is. The current group
draws the canvas itself
+ * rather than a node on it, so there is no selection to read this from.
+ */
+ private currentProcessGroupDatum(): any {
+ const breadcrumb: BreadcrumbEntity | null =
this.canvasUtils.getCurrentProcessGroupBreadcrumb();
+
+ return {
+ id: this.canvasUtils.getProcessGroupId(),
+ type: ComponentType.ProcessGroup,
+ permissions: breadcrumb ? breadcrumb.permissions : { canRead:
false, canWrite: false },
+ component: { name: breadcrumb?.breadcrumb.name }
+ };
+ }
}
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 4c48a436d42..25826b44da4 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/service/canvas-utils.service.ts
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/service/canvas-utils.service.ts
index 8da73babd8e..a7bb912d656 100644
---
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/service/canvas-utils.service.ts
+++
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/service/canvas-utils.service.ts
@@ -255,6 +255,14 @@ export class CanvasUtils {
return this.parentProcessGroupId;
}
+ /**
+ * Returns the breadcrumb of the current group, which carries its name and
permissions. The current
+ * group is not rendered on its own canvas, so this is the only source for
those details.
+ */
+ public getCurrentProcessGroupBreadcrumb(): BreadcrumbEntity | null {
+ return this.breadcrumbs;
+ }
+
/**
* Returns the current parameter context id or null if there is no bound
parameter context.
*/
diff --git
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/state/flow/component-connections.utils.spec.ts
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/state/flow/component-connections.utils.spec.ts
new file mode 100644
index 00000000000..a99d2e8dc11
--- /dev/null
+++
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/state/flow/component-connections.utils.spec.ts
@@ -0,0 +1,170 @@
+/*
+ * 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 { buildComponentIdToNameMap, collectEndpointGroupIds } from
'./component-connections.utils';
+import { ComponentEntity, ConnectionEntity, ProcessGroupFlowEntity } from
'./index';
+
+const DEFINING_GROUP_ID = 'defining-group-id';
+const CHILD_GROUP_ID = 'child-group-id';
+const OTHER_CHILD_GROUP_ID = 'other-child-group-id';
+const REMOTE_GROUP_ID = 'remote-process-group-id';
+
+interface EndpointOptions {
+ sourceGroupId?: string;
+ sourceType?: string;
+ destinationGroupId?: string;
+ destinationType?: string;
+}
+
+function connection(options: EndpointOptions = {}): ConnectionEntity {
+ return {
+ id: 'connection-id',
+ permissions: { canRead: false, canWrite: false },
+ position: { x: 0, y: 0 },
+ revision: { version: 0 },
+ sourceId: 'source-id',
+ sourceGroupId: options.sourceGroupId ?? DEFINING_GROUP_ID,
+ sourceType: options.sourceType ?? 'PROCESSOR',
+ destinationId: 'destination-id',
+ destinationGroupId: options.destinationGroupId ?? DEFINING_GROUP_ID,
+ destinationType: options.destinationType ?? 'PROCESSOR',
+ component: null
+ };
+}
+
+function component(id: string, name: string | undefined, canRead: boolean):
ComponentEntity {
+ return {
+ id,
+ permissions: { canRead, canWrite: false },
+ position: { x: 0, y: 0 },
+ revision: { version: 0 },
+ component: { id, name }
+ };
+}
+
+function flowEntity(flow:
Partial<ProcessGroupFlowEntity['processGroupFlow']['flow']>):
ProcessGroupFlowEntity {
+ return {
+ permissions: { canRead: true, canWrite: true },
+ processGroupFlow: {
+ id: DEFINING_GROUP_ID,
+ uri: '',
+ parentGroupId: null,
+ breadcrumb: {
+ id: DEFINING_GROUP_ID,
+ permissions: { canRead: true, canWrite: true },
+ versionedFlowState: '',
+ breadcrumb: { id: DEFINING_GROUP_ID, name: 'Defining Process
Group' }
+ },
+ flow: {
+ processGroups: [],
+ remoteProcessGroups: [],
+ processors: [],
+ inputPorts: [],
+ outputPorts: [],
+ connections: [],
+ labels: [],
+ funnels: [],
+ ...flow
+ }
+ }
+ } as unknown as ProcessGroupFlowEntity;
+}
+
+describe('collectEndpointGroupIds', () => {
+ it('collects nothing when both ends are in the group that defines the
connections', () => {
+ expect(collectEndpointGroupIds([connection()],
DEFINING_GROUP_ID)).toEqual([]);
+ });
+
+ it('collects the group behind a port an end reaches into', () => {
+ const connections = [
+ connection({ destinationGroupId: CHILD_GROUP_ID, destinationType:
'INPUT_PORT' }),
+ connection({ sourceGroupId: OTHER_CHILD_GROUP_ID, sourceType:
'OUTPUT_PORT' })
+ ];
+
+ expect(collectEndpointGroupIds(connections,
DEFINING_GROUP_ID)).toEqual([CHILD_GROUP_ID, OTHER_CHILD_GROUP_ID]);
+ });
+
+ it('collects a group reached by several connections once', () => {
+ const connections = [
+ connection({ destinationGroupId: CHILD_GROUP_ID, destinationType:
'INPUT_PORT' }),
+ connection({ destinationGroupId: CHILD_GROUP_ID, destinationType:
'INPUT_PORT' }),
+ connection({ sourceGroupId: CHILD_GROUP_ID, sourceType:
'OUTPUT_PORT' })
+ ];
+
+ expect(collectEndpointGroupIds(connections,
DEFINING_GROUP_ID)).toEqual([CHILD_GROUP_ID]);
+ });
+
+ it('leaves out a Remote Process Group, which has no flow of its own to
load', () => {
+ const connections = [
+ connection({ sourceGroupId: REMOTE_GROUP_ID, sourceType:
'REMOTE_OUTPUT_PORT' }),
+ connection({ destinationGroupId: REMOTE_GROUP_ID, destinationType:
'REMOTE_INPUT_PORT' })
+ ];
+
+ expect(collectEndpointGroupIds(connections,
DEFINING_GROUP_ID)).toEqual([]);
+ });
+});
+
+describe('buildComponentIdToNameMap', () => {
+ it('names only the components the current user can read', () => {
+ const flow = flowEntity({
+ processors: [
+ component('readable-processor-id', 'Readable Processor', true),
+ component('hidden-processor-id', 'Hidden Processor', false)
+ ]
+ });
+
+ const idToName = buildComponentIdToNameMap([flow]);
+
+ expect(idToName.get('readable-processor-id')).toBe('Readable
Processor');
+ expect(idToName.has('hidden-processor-id')).toBeFalsy();
+ });
+
+ it('names components of every group it is given, which is what separates
the two ends', () => {
+ const definingFlow = flowEntity({
+ processors: [component('hidden-processor-id', 'Hidden Processor',
false)]
+ });
+ const childFlow = flowEntity({
+ inputPorts: [component('input-port-id', 'Input Port A', true)]
+ });
+
+ const idToName = buildComponentIdToNameMap([definingFlow, childFlow]);
+
+ expect(idToName.has('hidden-processor-id')).toBeFalsy();
+ expect(idToName.get('input-port-id')).toBe('Input Port A');
+ });
+
+ it('names every kind of component a connection can be attached to', () => {
+ const flow = flowEntity({
+ processors: [component('processor-id', 'Processor', true)],
+ inputPorts: [component('input-port-id', 'Input Port', true)],
+ outputPorts: [component('output-port-id', 'Output Port', true)],
+ funnels: [component('funnel-id', undefined, true)]
+ });
+
+ const idToName = buildComponentIdToNameMap([flow]);
+
+ expect(idToName.get('processor-id')).toBe('Processor');
+ expect(idToName.get('input-port-id')).toBe('Input Port');
+ expect(idToName.get('output-port-id')).toBe('Output Port');
+ // a funnel has no name of its own, and is still reported as readable
+ expect(idToName.get('funnel-id')).toBe('');
+ });
+
+ it('builds an empty map when no flow could be loaded', () => {
+ expect(buildComponentIdToNameMap([]).size).toBe(0);
+ });
+});
diff --git
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/state/flow/component-connections.utils.ts
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/state/flow/component-connections.utils.ts
new file mode 100644
index 00000000000..aa30186f629
--- /dev/null
+++
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/state/flow/component-connections.utils.ts
@@ -0,0 +1,80 @@
+/*
+ * 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 { ConnectionEntity, ProcessGroupFlowEntity } from './index';
+
+const REMOTE_PORT_TYPES: string[] = ['REMOTE_INPUT_PORT',
'REMOTE_OUTPUT_PORT'];
+
+/**
+ * Collects the groups, other than the one that defines the given connections,
that hold an end of one
+ * of them. Reporting a connection means reporting both of its ends, and a
connection is readable only
+ * when the current user can read both, so each end has to be looked up in its
own group to be named on
+ * its own permission.
+ *
+ * A Remote Process Group is left out: it is not a group whose flow can be
loaded, and the ports inside
+ * it are reported through the Remote Process Group itself.
+ *
+ * @param connections the connections being reported
+ * @param definingGroupId the group that defines them, whose flow is already
loaded
+ * @returns the id of each other group holding an end, without repeats
+ */
+export function collectEndpointGroupIds(connections: ConnectionEntity[],
definingGroupId: string): string[] {
+ const groupIds = new Set<string>();
+
+ connections.forEach((connection) => {
+ if (connection.sourceGroupId !== definingGroupId &&
!REMOTE_PORT_TYPES.includes(connection.sourceType)) {
+ groupIds.add(connection.sourceGroupId);
+ }
+ if (
+ connection.destinationGroupId !== definingGroupId &&
+ !REMOTE_PORT_TYPES.includes(connection.destinationType)
+ ) {
+ groupIds.add(connection.destinationGroupId);
+ }
+ });
+
+ return [...groupIds];
+}
+
+/**
+ * Collects the name of every component the current user can read across the
given flows. Each component
+ * is listed by its own group with its own permission, which is what lets one
end of a connection be
+ * named while the other is reported as unauthorized.
+ *
+ * @param flowEntities the flow of each group holding an end of the
connections being reported
+ * @returns the name of each readable component, by id
+ */
+export function buildComponentIdToNameMap(flowEntities:
ProcessGroupFlowEntity[]): Map<string, string> {
+ const idToName = new Map<string, string>();
+
+ flowEntities.forEach((flowEntity) => {
+ const flow = flowEntity.processGroupFlow.flow;
+
+ [
+ ...(flow.processors ?? []),
+ ...(flow.inputPorts ?? []),
+ ...(flow.outputPorts ?? []),
+ ...(flow.funnels ?? [])
+ ].forEach((component) => {
+ if (component.permissions.canRead) {
+ idToName.set(component.id, component.component.name ?? '');
+ }
+ });
+ });
+
+ return idToName;
+}
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 441f5b5c5e8..ac76762e2be 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 5d9220f4699..bba9d67ab20 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
@@ -38,6 +38,7 @@ import {
combineLatest,
exhaustMap,
filter,
+ forkJoin,
from,
interval,
map,
@@ -53,6 +54,7 @@ import {
} from 'rxjs';
import {
ComponentEntity,
+ ConnectionEntity,
CreateConnectionDialogRequest,
CreateProcessGroupDialogRequest,
DeleteComponentResponse,
@@ -73,6 +75,7 @@ import {
StopVersionControlResponse,
VersionControlInformationEntity
} from './index';
+import { buildComponentIdToNameMap, collectEndpointGroupIds } from
'./component-connections.utils';
import { Position } from '../shared';
import { Action, Store } from '@ngrx/store';
import {
@@ -101,6 +104,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 +166,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 +3185,139 @@ 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.
+ *
+ *
+ * The group on the canvas is the one id the resolvers never collapse to,
so when it is itself the
+ * requested component a connection into or out of it resolves to the port
it is attached to instead.
+ * Matching the endpoint group id on the connection covers that case; for
every other component the
+ * requested id is not a group id at that endpoint, so it adds nothing.
+ *
+ * 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.
+ *
+ * The flow of every other group an end of those connections lives in is
loaded as well, since a
+ * connection is readable only when the current user can read both of its
ends and carries no name
+ * for either one otherwise. Those flows are what report each end with its
own permission.
+ */
+ 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
||
+ connection.destinationGroupId === request.id
+ :
this.canvasUtils.getConnectionSourceComponentId(connection) === request.id ||
+ connection.sourceGroupId === request.id;
+
+ return from(this.flowService.getFlow(request.groupId)).pipe(
+ switchMap((flowEntity: ProcessGroupFlowEntity) => {
+ const connections =
flowEntity.processGroupFlow.flow.connections.filter(attachedTo);
+
+ return this.loadEndpointGroupFlows(connections,
request.groupId).pipe(
+ map((endpointFlows: ProcessGroupFlowEntity[]) =>
+ FlowActions.openComponentConnectionsDialog({
+ request: {
+ componentId: request.id,
+ componentName: request.name,
+ componentType: request.type,
+ groupId: request.groupId,
+ direction: request.direction,
+ connections,
+ groupIdToName:
this.buildProcessGroupIdToNameMap(flowEntity),
+ componentIdToName:
buildComponentIdToNameMap([flowEntity, ...endpointFlows])
+ }
+ })
+ )
+ );
+ }),
+ 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<string, string> {
+ const idToName = new Map<string, string>();
+ 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;
+ }
+
+ /**
+ * Loads the flow of every other group holding an end of the given
connections, so that each end can
+ * be reported on its own read permission. A group the current user cannot
read answers with an
+ * error, which leaves that end reported as unauthorized rather than
failing the dialog.
+ *
+ * @param connections the connections being reported
+ * @param definingGroupId the group that defines them, whose flow has
already been loaded
+ * @returns the flow of each of the other groups the current user can read
+ */
+ private loadEndpointGroupFlows(
+ connections: ConnectionEntity[],
+ definingGroupId: string
+ ): Observable<ProcessGroupFlowEntity[]> {
+ const groupIds = collectEndpointGroupIds(connections, definingGroupId);
+
+ if (groupIds.length === 0) {
+ return of([]);
+ }
+
+ return forkJoin(
+ groupIds.map((groupId) =>
+ from(this.flowService.getFlow(groupId)).pipe(
+ map((flowEntity) => flowEntity as ProcessGroupFlowEntity |
null),
+ catchError(() => of(null))
+ )
+ )
+ ).pipe(map((flows) => flows.filter((flowEntity) => flowEntity !==
null)));
+ }
+
showOkDialog$ = createEffect(
() =>
this.actions$.pipe(
@@ -4945,7 +5083,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 6e6f602ef42..d68881828e0 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<T extends ComponentEntity>(
+ 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/index.ts
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/state/flow/index.ts
index 1b7b830ec22..80f4cf79f38 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,38 @@ 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 {
+ componentId: string;
+ 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<string, string>;
+ // names of the components the current user can read, across every group
these connections reach
+ // into. a connection is readable only when both of its ends are, so this
is what lets each end be
+ // reported on its own permission rather than through the connection that
joins them
+ componentIdToName: Map<string, string>;
+}
+
/*
Snippets
*/
@@ -454,6 +486,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 +512,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 00000000000..4bbc41201de
--- /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,146 @@
+<!--
+ ~ 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="dialog-content flex flex-col h-full gap-y-2">
+ <component-context
+ [type]="componentType"
+ [name]="componentName"
+ [id]="componentId"></component-context>
+ @if (rows.length === 0) {
+ <div class="unset">{{ 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]="dataSource"
+ matSort
+ matSortDisableClear
+ (matSortChange)="sortData($event)"
+ [matSortActive]="initialSortColumn"
+ [matSortDirection]="initialSortDirection">
+ <ng-container matColumnDef="sourceProcessGroup">
+ <th mat-header-cell *matHeaderCellDef
mat-sort-header>Source <br>Process Group</th>
+ <td mat-cell *matCellDef="let row">
+ @if (isNavigableProcessGroup(row.source.groupId)) {
+ <a
+ class="component-connection-cell
neutral-contrast"
+
[matTooltip]="resolveGroupName(row.source.groupId)"
+
(click)="navigateToProcessGroup(row.source)"><i
+ class="icon component-type-icon flex-none"
+
[class]="componentIcon(processGroupTypeOf(row.source))">
+ </i>{{
resolveGroupName(row.source.groupId) }}
+ </a>
+ } @else {
+ <span class="component-connection-cell
neutral-contrast" [matTooltip]="resolveGroupName(row.source.groupId)">
+ <i class="icon component-type-icon"
[class]="componentIcon(processGroupTypeOf(row.source))">
+ </i>{{
resolveGroupName(row.source.groupId) }}
+ </span>
+ }
+ </td>
+ </ng-container>
+
+ <ng-container matColumnDef="sourceComponent">
+ <th mat-header-cell *matHeaderCellDef
mat-sort-header>Source <br>Component</th>
+ <td mat-cell *matCellDef="let row">
+ @if (isRemoteProcessGroupPort(row.source)) {
+ <span class="component-connection-cell
neutral-contrast" [matTooltip]="componentTooltip(row.source)">
+ <i class="icon component-type-icon"
[class]="componentIcon(row.source.type)">
+ </i>{{ formatComponentName(row.source) }}
+ </span>
+ } @else {
+ <a
+ class="component-connection-cell
neutral-contrast"
+ [matTooltip]="componentTooltip(row.source)"
+ (click)="navigateTo(row.source.id,
row.source.groupId, row.source.type)"><i
+ class="icon component-type-icon"
[class]="componentIcon(row.source.type)">
+ </i>{{ formatComponentName(row.source) }}
+ </a>
+ }
+ </td>
+ </ng-container>
+
+ <ng-container matColumnDef="connection">
+ <th mat-header-cell *matHeaderCellDef
mat-sort-header>Connection</th>
+ <td mat-cell *matCellDef="let row">
+ <a
+ class="component-connection-cell
neutral-contrast"
+ [matTooltip]="row.name"
+ (click)="navigateTo(row.id,
dialogRequestGroupId, connectionType)">
+ <i class="icon component-type-icon"
[class]="componentIcon(connectionType)"></i>
+ @if (row.name === null) {
+ <span class="unset
neutral-contrast">{{ formatConnectionName(row) }}</span>
+ } @else {
+ {{ row.name }}
+ }
+ </a>
+ </td>
+ </ng-container>
+
+ <ng-container matColumnDef="destinationProcessGroup">
+ <th mat-header-cell *matHeaderCellDef
mat-sort-header>Destination <br>Process Group</th>
+ <td mat-cell *matCellDef="let row">
+ @if
(isNavigableProcessGroup(row.destination.groupId)) {
+ <a
+ class="component-connection-cell
neutral-contrast"
+
[matTooltip]="resolveGroupName(row.destination.groupId)"
+
(click)="navigateToProcessGroup(row.destination)"><i
+ class="icon component-type-icon flex-none"
[class]="componentIcon(processGroupTypeOf(row.destination))">
+ </i>{{
resolveGroupName(row.destination.groupId) }}
+ </a>
+ } @else {
+ <span class="component-connection-cell
neutral-contrast" [matTooltip]="resolveGroupName(row.destination.groupId)">
+ <i class="icon component-type-icon
flex-none" [class]="componentIcon(processGroupTypeOf(row.destination))">
+ </i>{{
resolveGroupName(row.destination.groupId) }}
+ </span>
+ }
+ </td>
+ </ng-container>
+
+ <ng-container matColumnDef="destinationComponent">
+ <th mat-header-cell *matHeaderCellDef
mat-sort-header>Destination <br>Component</th>
+ <td mat-cell *matCellDef="let row">
+ @if (isRemoteProcessGroupPort(row.destination)) {
+ <span class="component-connection-cell
neutral-contrast" [matTooltip]="componentTooltip(row.destination)">
+ <i class="icon component-type-icon"
[class]="componentIcon(row.destination.type)">
+ </i>{{
formatComponentName(row.destination) }}
+ </span>
+ } @else {
+ <a
+ class="component-connection-cell
neutral-contrast"
+
[matTooltip]="componentTooltip(row.destination)"
+ (click)="navigateTo(row.destination.id,
row.destination.groupId, row.destination.type)"><i
+ class="icon component-type-icon"
[class]="componentIcon(row.destination.type)">
+ </i>{{
formatComponentName(row.destination) }}
+ </a>
+ }
+ </td>
+ </ng-container>
+
+ <tr mat-header-row *matHeaderRowDef="displayedColumns;
sticky: true"></tr>
+ <tr mat-row *matRowDef="let row; let even = even;
columns: displayedColumns" [class.even]="even"></tr>
+ </table>
+ </div>
+ </div>
+ }
+ </div>
+</mat-dialog-content>
+<mat-dialog-actions align="end">
+ <button type="button" mat-button mat-dialog-close
cdkFocusInitial>OK</button>
+</mat-dialog-actions>
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 00000000000..d120342b362
--- /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 00000000000..e73dc720ae3
--- /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,1430 @@
+/*
+ * 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 { NoopAnimationsModule } from '@angular/platform-browser/animations';
+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 { enterProcessGroup, 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 REMOTE_GROUP_ID = 'remote-process-group-id';
+
+// when the user selects nothing on the canvas the current group itself is
reported, and its connections
+// are defined in the parent group, so the parent is the group the dialog
request is built around
+const PARENT_GROUP_ID = 'parent-group-id';
+const CURRENT_GROUP_ID = 'current-group-id';
+const SIBLING_GROUP_ID = 'sibling-group-id';
+
+const SELECTED_COMPONENT_ID = 'selected-component-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
+ };
+}
+
+/**
+ * Builds the dialog. The group on the canvas defaults to the group the
connections belong to, which is
+ * where every component except a port searched across its own group's
boundary is reported from.
+ */
+function createDialog(
+ direction: ConnectionDirection,
+ connections: ConnectionEntity[],
+ overrides: Partial<ComponentConnectionsDialogRequest> = {},
+ canvasGroupId?: string
+): CreatedDialog {
+ const dialogRequest: ComponentConnectionsDialogRequest = {
+ componentId: SELECTED_COMPONENT_ID,
+ 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']
+ ]),
+ componentIdToName: new Map(),
+ ...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 '';
+ },
+ getProcessGroupId: (): string => canvasGroupId ?? dialogRequest.groupId
+ };
+
+ TestBed.resetTestingModule();
+ TestBed.configureTestingModule({
+ imports: [ComponentConnectionsDialog, NoopAnimationsModule],
+ 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
+ };
+}
+
+/**
+ * Builds the dialog as it is opened when the user selects nothing on the
canvas: the current group is
+ * the reported component, and the group of the request is its parent, where
its connections are defined.
+ */
+function createCurrentProcessGroupDialog(
+ direction: ConnectionDirection,
+ connections: ConnectionEntity[],
+ overrides: Partial<ComponentConnectionsDialogRequest> = {}
+): CreatedDialog {
+ return createDialog(
+ direction,
+ connections,
+ {
+ componentId: CURRENT_GROUP_ID,
+ componentName: 'Current Process Group',
+ componentType: ComponentType.ProcessGroup,
+ groupId: PARENT_GROUP_ID,
+ groupIdToName: new Map([
+ [PARENT_GROUP_ID, 'Parent Process Group'],
+ [CURRENT_GROUP_ID, 'Current Process Group'],
+ [SIBLING_GROUP_ID, 'Sibling Process Group']
+ ]),
+ ...overrides
+ },
+ CURRENT_GROUP_ID
+ );
+}
+
+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);
+}
+
+function clickCell(fixture: ComponentFixture<ComponentConnectionsDialog>,
columnClass: string): void {
+ const link = getCells(fixture, columnClass)[0].querySelector('a') as
HTMLAnchorElement;
+ link.click();
+}
+
+function clickHeader(fixture: ComponentFixture<ComponentConnectionsDialog>,
columnClass: string): void {
+ const header =
fixture.debugElement.query(By.css(`th.${columnClass}`)).nativeElement as
HTMLElement;
+ const sortButton = header.querySelector('button');
+ (sortButton ?? header).click();
+ fixture.detectChanges();
+}
+
+function renderedIds(component: ComponentConnectionsDialog): string[] {
+ return component.dataSource.data.map((row) => row.id);
+}
+
+/**
+ * Rebuilds the route the navigateToComponent effect pushes for a dispatched
navigation, so that a test
+ * can assert on the url the click produces rather than only on the component
type carried by the action.
+ */
+function navigationUrl(dispatch: ReturnType<typeof vi.spyOn>): string {
+ const { request } = dispatch.mock.calls[0][0] as ReturnType<typeof
navigateToComponent>;
+ return ['/process-groups', request.processGroupId, request.type,
request.id].join('/');
+}
+
+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('reports the selected component through the shared component
context', () => {
+ const { fixture } = createDialog('upstream', [], {
+ componentId: 'input-port-a-id',
+ componentName: 'Input Port A',
+ componentType: ComponentType.InputPort
+ });
+
+ const componentContext =
fixture.debugElement.query(By.css('component-context'));
+ expect(componentContext).not.toBeNull();
+
+ const contextText = (componentContext.nativeElement.textContent as
string).replace(/\s+/g, ' ').trim();
+ expect(contextText).toContain('Input Port A');
+ // the type label and copyable id the widget renders on top of the
name
+ expect(contextText).toContain('Input Port');
+ expect(contextText).toContain('input-port-a-id');
+
expect(componentContext.query(By.css('.icon-port-in'))).not.toBeNull();
+ });
+
+ it('reports an unreadable component by the id used in place of its
name', () => {
+ const { fixture } = createDialog('upstream', [], {
+ componentId: 'unreadable-component-id',
+ componentName: 'unreadable-component-id',
+ componentType: ComponentType.Processor
+ });
+
+ const componentContext =
fixture.debugElement.query(By.css('component-context'));
+ const contextText = (componentContext.nativeElement.textContent as
string).replace(/\s+/g, ' ').trim();
+
+ expect(contextText).toContain('unreadable-component-id');
+ expect(contextText).toContain('Processor');
+ });
+
+ it('reports a remote process group with the remote group icon', () => {
+ const { fixture } = createDialog('downstream', [], {
+ componentId: REMOTE_GROUP_ID,
+ componentName: 'Remote Process Group A',
+ componentType: ComponentType.RemoteProcessGroup
+ });
+
+ const componentContext =
fixture.debugElement.query(By.css('component-context'));
+
+
expect(componentContext.query(By.css('.icon-group-remote'))).not.toBeNull();
+
expect(componentContext.nativeElement.textContent).toContain('Remote Process
Group A');
+ });
+ });
+
+ 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('offers nowhere to go for the group that both defines the
connections and is on the canvas', () => {
+ const { component } = createDialog('upstream', []);
+
+
expect(component.isNavigableProcessGroup(REQUEST_GROUP_ID)).toBeFalsy();
+
expect(component.isNavigableProcessGroup(SOURCE_GROUP_ID)).toBeTruthy();
+ });
+
+ it('offers the group that defines the connections when it is not the
group on the canvas', () => {
+ // a port searched across its own group's boundary reports
connections defined in the parent
+ const { component } = createDialog('upstream', [], {},
SOURCE_GROUP_ID);
+
+
expect(component.isNavigableProcessGroup(REQUEST_GROUP_ID)).toBeTruthy();
+
expect(component.isNavigableProcessGroup(SOURCE_GROUP_ID)).toBeTruthy();
+ });
+ });
+
+ 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('keeps an unreadable component clickable so it can be reached like
it can on the canvas', () => {
+ const { fixture, store, dialogRef } = createDialog('upstream',
[unreadableConnection()]);
+ const dispatch = vi.spyOn(store, 'dispatch');
+
+ const sourceComponentCell = getCells(fixture,
'mat-column-sourceComponent')[0];
+ const destinationComponentCell = getCells(fixture,
'mat-column-destinationComponent')[0];
+
+ expect(sourceComponentCell.textContent).toContain('Unauthorized');
+
expect(destinationComponentCell.textContent).toContain('Unauthorized');
+ expect(sourceComponentCell.querySelector('a')).not.toBeNull();
+ expect(destinationComponentCell.querySelector('a')).not.toBeNull();
+
+ clickCell(fixture, 'mat-column-sourceComponent');
+
+ expect(dispatch).toHaveBeenCalledWith(
+ navigateToComponent({
+ request: {
+ id: SOURCE_ID,
+ processGroupId: SOURCE_GROUP_ID,
+ type: ComponentType.Processor
+ }
+ })
+ );
+ expect(dialogRef.close).toHaveBeenCalled();
+ });
+
+ it('identifies an unreadable component by its id, since the
placeholder identifies nothing', () => {
+ const { component } = createDialog('upstream', [
+ readableConnection({
+ source: { id: 'readable-source-id', name: 'Readable
Source' },
+ destination: { id: 'hidden-destination-id', name: 'Hidden
Destination' }
+ }),
+ unreadableConnection({ destination: { id:
'hidden-destination-id', name: 'Hidden Destination' } })
+ ]);
+
+
expect(component.componentTooltip(component.rows[0].source)).toBe('Readable
Source');
+
expect(component.componentTooltip(component.rows[1].destination)).toBe('hidden-destination-id');
+ });
+
+ 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]);
+ 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]);
+ 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]);
+ 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]);
+ 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();
+ });
+ });
+
+ /**
+ * A connection can be read only when the current user can read both of
its ends, so the names it
+ * carries vanish as soon as either end is unreadable. Each end is
therefore reported from what is
+ * known about that component on its own.
+ */
+ describe('per-endpoint authorization', () => {
+ const CHILD_GROUP_ID = 'child-group-id';
+ const SELECTED_PORT_NAME = 'Input Port A';
+
+ // an Input Port searched upstream: the connections are defined in the
parent, the port is not
+ // among the components of the parent, and the source processor there
cannot be read
+ function unreadableSourceIntoSelectedPort(): ConnectionEntity {
+ return unreadableConnection({
+ id: 'unreadable-connection-id',
+ source: { id: 'hidden-processor-id', name: 'Hidden Processor'
},
+ sourceGroupId: REQUEST_GROUP_ID,
+ sourceType: 'PROCESSOR',
+ destination: { id: SELECTED_COMPONENT_ID, name:
SELECTED_PORT_NAME },
+ destinationGroupId: CHILD_GROUP_ID,
+ destinationType: 'INPUT_PORT'
+ });
+ }
+
+ /**
+ * The components of the child group holding the port are listed
alongside those of the parent
+ * group that defines the connections, each with its own read
permission, which is how the port
+ * is named while the processor at the other end is not.
+ */
+ function createPortDialog(
+ connections: ConnectionEntity[],
+ readableComponents: [string, string][] = [[SELECTED_COMPONENT_ID,
SELECTED_PORT_NAME]]
+ ): CreatedDialog {
+ return createDialog(
+ 'upstream',
+ connections,
+ {
+ componentId: SELECTED_COMPONENT_ID,
+ componentName: SELECTED_PORT_NAME,
+ componentType: ComponentType.InputPort,
+ componentIdToName: new Map(readableComponents)
+ },
+ CHILD_GROUP_ID
+ );
+ }
+
+ it('names the readable end of a connection the user cannot read', ()
=> {
+ const { component, fixture } =
createPortDialog([unreadableSourceIntoSelectedPort()]);
+
+ expect(component.rows[0].source.name).toBeNull();
+
expect(component.rows[0].destination.name).toBe(SELECTED_PORT_NAME);
+
+ const sourceComponentCell = getCells(fixture,
'mat-column-sourceComponent')[0];
+ const destinationComponentCell = getCells(fixture,
'mat-column-destinationComponent')[0];
+ expect(sourceComponentCell.textContent).toContain('Unauthorized');
+
expect(destinationComponentCell.textContent).toContain(SELECTED_PORT_NAME);
+
expect(destinationComponentCell.textContent).not.toContain('Unauthorized');
+ });
+
+ it('navigates to the readable end of a connection the user cannot
read', () => {
+ const { fixture, store, dialogRef } =
createPortDialog([unreadableSourceIntoSelectedPort()]);
+ const dispatch = vi.spyOn(store, 'dispatch');
+
+ clickCell(fixture, 'mat-column-destinationComponent');
+
+ expect(dispatch).toHaveBeenCalledWith(
+ navigateToComponent({
+ request: {
+ id: SELECTED_COMPONENT_ID,
+ processGroupId: CHILD_GROUP_ID,
+ type: ComponentType.InputPort
+ }
+ })
+ );
+ expect(dialogRef.close).toHaveBeenCalled();
+ });
+
+ it('reports an end that no group reported as unauthorized, whichever
end it is', () => {
+ // the component the connections were requested for is reported by
its own group like any
+ // other component, and is unauthorized when that group did not
report it
+ const { component } =
createPortDialog([unreadableSourceIntoSelectedPort()], []);
+
+ expect(component.rows[0].source.name).toBeNull();
+ expect(component.rows[0].destination.name).toBeNull();
+
expect(component.formatComponentName(component.rows[0].destination)).toBe('Unauthorized');
+ });
+
+ it('names both ends when each of their groups reported them', () => {
+ const { component } = createPortDialog(
+ [unreadableSourceIntoSelectedPort()],
+ [
+ ['hidden-processor-id', 'No Longer Hidden Processor'],
+ [SELECTED_COMPONENT_ID, SELECTED_PORT_NAME]
+ ]
+ );
+
+ expect(component.rows[0].source.name).toBe('No Longer Hidden
Processor');
+
expect(component.rows[0].destination.name).toBe(SELECTED_PORT_NAME);
+ });
+
+ it('names a funnel by its type, which is all the canvas shows for
one', () => {
+ const funnelConnection = unreadableConnection({
+ source: { id: 'funnel-id', name: '' },
+ sourceGroupId: REQUEST_GROUP_ID,
+ sourceType: 'FUNNEL',
+ destination: { id: SELECTED_COMPONENT_ID, name:
SELECTED_PORT_NAME },
+ destinationGroupId: CHILD_GROUP_ID,
+ destinationType: 'INPUT_PORT'
+ });
+
+ const { component } = createPortDialog([funnelConnection]);
+
+ expect(component.rows[0].source.name).toBe('Funnel');
+
expect(component.formatComponentName(component.rows[0].source)).toBe('Funnel');
+ });
+
+ it('falls back to the name the connection carries for an end no group
reports', () => {
+ // a port inside a Remote Process Group is listed by no flow of
its own, and a readable
+ // connection - which means both of its ends are readable - is
what names it
+ const connection = readableConnection({
+ source: { id: 'remote-output-port-id', name: 'Remote Output
Port' },
+ sourceGroupId: REMOTE_GROUP_ID,
+ sourceType: 'REMOTE_OUTPUT_PORT',
+ destination: { id: SELECTED_COMPONENT_ID, name:
SELECTED_PORT_NAME },
+ destinationGroupId: CHILD_GROUP_ID,
+ destinationType: 'INPUT_PORT'
+ });
+
+ const { component } = createPortDialog([connection], []);
+
+ expect(component.rows[0].source.name).toBe('Remote Output Port');
+
expect(component.rows[0].destination.name).toBe(SELECTED_PORT_NAME);
+ });
+ });
+
+ /**
+ * A remote port's group is a Remote Process Group, which is navigated to
as a component of the group
+ * on the canvas rather than as a group that can be entered. The type
carried by the navigation is the
+ * ':type' segment of the resulting route, so a Process Group type here
produces the wrong url.
+ */
+ describe('remote process group navigation', () => {
+ it('navigates to a remote source process group as a Remote Process
Group', () => {
+ const connection = readableConnection({
+ source: { id: 'remote-output-port-id', name: 'Remote Output
Port' },
+ sourceGroupId: REMOTE_GROUP_ID,
+ sourceType: 'REMOTE_OUTPUT_PORT',
+ destination: { id: 'processor-id', name: 'Processor' },
+ destinationGroupId: DESTINATION_GROUP_ID,
+ destinationType: 'PROCESSOR'
+ });
+
+ const { fixture, store, dialogRef } = createDialog('upstream',
[connection]);
+ const dispatch = vi.spyOn(store, 'dispatch');
+
+ const sourceProcessGroupCell = getCells(fixture,
'mat-column-sourceProcessGroup')[0];
+
expect(sourceProcessGroupCell.querySelector('i.icon-group-remote')).not.toBeNull();
+
+ clickCell(fixture, 'mat-column-sourceProcessGroup');
+
+ expect(dispatch).toHaveBeenCalledWith(
+ navigateToComponent({
+ request: {
+ id: REMOTE_GROUP_ID,
+ processGroupId: REQUEST_GROUP_ID,
+ type: ComponentType.RemoteProcessGroup
+ }
+ })
+ );
+ expect(navigationUrl(dispatch)).toBe(
+
`/process-groups/${REQUEST_GROUP_ID}/${ComponentType.RemoteProcessGroup}/${REMOTE_GROUP_ID}`
+ );
+
expect(navigationUrl(dispatch)).not.toContain(`/${ComponentType.ProcessGroup}/`);
+ expect(dialogRef.close).toHaveBeenCalled();
+ });
+
+ it('navigates to a remote destination process group as a Remote
Process Group', () => {
+ const connection = readableConnection({
+ source: { id: 'processor-id', name: 'Processor' },
+ sourceGroupId: SOURCE_GROUP_ID,
+ sourceType: 'PROCESSOR',
+ destination: { id: 'remote-input-port-id', name: 'Remote Input
Port' },
+ destinationGroupId: REMOTE_GROUP_ID,
+ destinationType: 'REMOTE_INPUT_PORT'
+ });
+
+ const { fixture, store, dialogRef } = createDialog('downstream',
[connection]);
+ const dispatch = vi.spyOn(store, 'dispatch');
+
+ const destinationProcessGroupCell = getCells(fixture,
'mat-column-destinationProcessGroup')[0];
+
expect(destinationProcessGroupCell.querySelector('i.icon-group-remote')).not.toBeNull();
+
+ clickCell(fixture, 'mat-column-destinationProcessGroup');
+
+ expect(dispatch).toHaveBeenCalledWith(
+ navigateToComponent({
+ request: {
+ id: REMOTE_GROUP_ID,
+ processGroupId: REQUEST_GROUP_ID,
+ type: ComponentType.RemoteProcessGroup
+ }
+ })
+ );
+ expect(navigationUrl(dispatch)).toBe(
+
`/process-groups/${REQUEST_GROUP_ID}/${ComponentType.RemoteProcessGroup}/${REMOTE_GROUP_ID}`
+ );
+
expect(navigationUrl(dispatch)).not.toContain(`/${ComponentType.ProcessGroup}/`);
+ expect(dialogRef.close).toHaveBeenCalled();
+ });
+
+ it('keeps navigating to a local process group as a Process Group', ()
=> {
+ const connection = readableConnection({
+ source: { id: 'output-port-id', name: 'Output Port' },
+ sourceGroupId: SOURCE_GROUP_ID,
+ sourceType: 'OUTPUT_PORT'
+ });
+
+ const { fixture, store } = createDialog('upstream', [connection]);
+ const dispatch = vi.spyOn(store, 'dispatch');
+
+ const sourceProcessGroupCell = getCells(fixture,
'mat-column-sourceProcessGroup')[0];
+
expect(sourceProcessGroupCell.querySelector('i.icon-group')).not.toBeNull();
+
+ clickCell(fixture, 'mat-column-sourceProcessGroup');
+
+ expect(navigationUrl(dispatch)).toBe(
+
`/process-groups/${REQUEST_GROUP_ID}/${ComponentType.ProcessGroup}/${SOURCE_GROUP_ID}`
+ );
+ });
+ });
+
+ /**
+ * Right-clicking empty canvas selects no component, which implicitly
reports the current process
+ * group. Its connections are defined one level up, so the dialog is built
around the parent group:
+ * the parent is the group treated as current by the table, and the group
the user is in is itself a
+ * navigable component within it.
+ */
+ describe('current process group selection', () => {
+ function upstreamIntoCurrentGroup(): ConnectionEntity {
+ return readableConnection({
+ source: { id: 'parent-processor-id', name: 'Parent Processor'
},
+ sourceGroupId: PARENT_GROUP_ID,
+ sourceType: 'PROCESSOR',
+ destination: { id: 'input-port-id', name: 'Input Port' },
+ destinationGroupId: CURRENT_GROUP_ID,
+ destinationType: 'INPUT_PORT'
+ });
+ }
+
+ function downstreamOutOfCurrentGroup(): ConnectionEntity {
+ return readableConnection({
+ source: { id: 'output-port-id', name: 'Output Port' },
+ sourceGroupId: CURRENT_GROUP_ID,
+ sourceType: 'OUTPUT_PORT',
+ destination: { id: 'parent-processor-id', name: 'Parent
Processor' },
+ destinationGroupId: PARENT_GROUP_ID,
+ destinationType: 'PROCESSOR'
+ });
+ }
+
+ it('reports the current process group as the selected component', ()
=> {
+ const { component, fixture } =
createCurrentProcessGroupDialog('upstream', [upstreamIntoCurrentGroup()]);
+
+ expect(component.componentType).toBe(ComponentType.ProcessGroup);
+ expect(component.componentId).toBe(CURRENT_GROUP_ID);
+
+ const componentContext =
fixture.debugElement.query(By.css('component-context'));
+ const contextText = (componentContext.nativeElement.textContent as
string).replace(/\s+/g, ' ').trim();
+
+ expect(contextText).toContain('Current Process Group');
+ expect(contextText).toContain('Process Group');
+ expect(contextText).toContain(CURRENT_GROUP_ID);
+
expect(componentContext.query(By.css('.icon-group'))).not.toBeNull();
+ });
+
+ it('offers both the parent group and the group on the canvas as places
to go', () => {
+ const { component } = createCurrentProcessGroupDialog('upstream',
[upstreamIntoCurrentGroup()]);
+
+
expect(component.isNavigableProcessGroup(PARENT_GROUP_ID)).toBeTruthy();
+
expect(component.isNavigableProcessGroup(CURRENT_GROUP_ID)).toBeTruthy();
+ });
+
+ it('enters the parent group from the cell of a component that sits in
it', () => {
+ const { fixture, store, dialogRef } =
createCurrentProcessGroupDialog('upstream', [
+ upstreamIntoCurrentGroup()
+ ]);
+ const dispatch = vi.spyOn(store, 'dispatch');
+
+ const sourceProcessGroupCell = getCells(fixture,
'mat-column-sourceProcessGroup')[0];
+ expect(sourceProcessGroupCell.textContent).toContain('Parent
Process Group');
+
+ clickCell(fixture, 'mat-column-sourceProcessGroup');
+
+ // the parent holds no component of its own to select, so it is
entered instead
+ expect(dispatch).toHaveBeenCalledWith(enterProcessGroup({ request:
{ id: PARENT_GROUP_ID } }));
+ expect(dialogRef.close).toHaveBeenCalled();
+ });
+
+ it('navigates into the current process group from the upstream
destination group cell', () => {
+ const { fixture, store, dialogRef } =
createCurrentProcessGroupDialog('upstream', [
+ upstreamIntoCurrentGroup()
+ ]);
+ const dispatch = vi.spyOn(store, 'dispatch');
+
+ const destinationProcessGroupCell = getCells(fixture,
'mat-column-destinationProcessGroup')[0];
+ expect(destinationProcessGroupCell.textContent).toContain('Current
Process Group');
+
+ clickCell(fixture, 'mat-column-destinationProcessGroup');
+
+ expect(dispatch).toHaveBeenCalledWith(
+ navigateToComponent({
+ request: {
+ id: CURRENT_GROUP_ID,
+ processGroupId: PARENT_GROUP_ID,
+ type: ComponentType.ProcessGroup
+ }
+ })
+ );
+ expect(dialogRef.close).toHaveBeenCalled();
+ });
+
+ it('navigates into the current process group from the downstream
source group cell', () => {
+ const { fixture, store, dialogRef } =
createCurrentProcessGroupDialog('downstream', [
+ downstreamOutOfCurrentGroup()
+ ]);
+ const dispatch = vi.spyOn(store, 'dispatch');
+
+ clickCell(fixture, 'mat-column-sourceProcessGroup');
+
+ expect(dispatch).toHaveBeenCalledWith(
+ navigateToComponent({
+ request: {
+ id: CURRENT_GROUP_ID,
+ processGroupId: PARENT_GROUP_ID,
+ type: ComponentType.ProcessGroup
+ }
+ })
+ );
+ expect(dialogRef.close).toHaveBeenCalled();
+ });
+
+ it('navigates to the port of a sibling group feeding the current
process group', () => {
+ const siblingConnection = readableConnection({
+ source: { id: 'sibling-output-port-id', name: 'Sibling Output
Port' },
+ sourceGroupId: SIBLING_GROUP_ID,
+ sourceType: 'OUTPUT_PORT',
+ destination: { id: 'input-port-id', name: 'Input Port' },
+ destinationGroupId: CURRENT_GROUP_ID,
+ destinationType: 'INPUT_PORT'
+ });
+
+ const { fixture, store } =
createCurrentProcessGroupDialog('upstream', [siblingConnection]);
+ const dispatch = vi.spyOn(store, 'dispatch');
+
+ const sourceProcessGroupCell = getCells(fixture,
'mat-column-sourceProcessGroup')[0];
+ expect(sourceProcessGroupCell.textContent).toContain('Sibling
Process Group');
+
+ clickCell(fixture, 'mat-column-sourceComponent');
+
+ expect(dispatch).toHaveBeenCalledWith(
+ navigateToComponent({
+ request: {
+ id: 'sibling-output-port-id',
+ processGroupId: SIBLING_GROUP_ID,
+ type: ComponentType.OutputPort
+ }
+ })
+ );
+ });
+
+ it('reports that the current process group has no connections in the
requested direction', () => {
+ const { fixture } = createCurrentProcessGroupDialog('upstream',
[]);
+
+ const renderedText = textContent(fixture);
+ expect(renderedText).toContain('Current Process Group');
+ expect(renderedText).toContain('No upstream connections were
found.');
+ });
+ });
+
+ /**
+ * Every column sorts on the text it renders, so a row is ordered by what
the user reads in that
+ * column rather than by the identifier behind it.
+ */
+ describe('sorting', () => {
+ // resolved labels, by column: source group / source component /
connection / destination group /
+ // destination component
+ const CURRENT_ROW_ID = 'current-source-group-connection-id'; //
Current / Zeta / Beta / Destination / Alpha
+ const SOURCE_ROW_ID = 'source-source-group-connection-id'; // Source /
Alpha / Alpha / Current / Zeta
+ const DESTINATION_ROW_ID = 'destination-source-group-connection-id';
// Destination / Gamma / Gamma / Source / Gamma
+
+ function connectionsToSort(): ConnectionEntity[] {
+ return [
+ readableConnection({
+ id: CURRENT_ROW_ID,
+ name: 'Beta Connection',
+ source: { id: 'zeta-processor-id', name: 'Zeta Processor'
},
+ sourceGroupId: REQUEST_GROUP_ID,
+ destination: { id: 'alpha-port-id', name: 'Alpha Port' },
+ destinationGroupId: DESTINATION_GROUP_ID
+ }),
+ readableConnection({
+ id: SOURCE_ROW_ID,
+ name: 'Alpha Connection',
+ source: { id: 'alpha-processor-id', name: 'Alpha
Processor' },
+ sourceGroupId: SOURCE_GROUP_ID,
+ destination: { id: 'zeta-port-id', name: 'Zeta Port' },
+ destinationGroupId: REQUEST_GROUP_ID
+ }),
+ readableConnection({
+ id: DESTINATION_ROW_ID,
+ name: 'Gamma Connection',
+ source: { id: 'gamma-processor-id', name: 'Gamma
Processor' },
+ sourceGroupId: DESTINATION_GROUP_ID,
+ destination: { id: 'gamma-port-id', name: 'Gamma Port' },
+ destinationGroupId: SOURCE_GROUP_ID
+ })
+ ];
+ }
+
+ it('sorts by the connection column ascending by default', () => {
+ const { component } = createDialog('upstream',
connectionsToSort());
+
+ expect(component.initialSortColumn).toBe('connection');
+ expect(component.initialSortDirection).toBe('asc');
+ expect(component.activeSort).toEqual({ active: 'connection',
direction: 'asc' });
+ expect(renderedIds(component)).toEqual([SOURCE_ROW_ID,
CURRENT_ROW_ID, DESTINATION_ROW_ID]);
+ });
+
+ it('leaves the rows built from the request in their original order',
() => {
+ const { component } = createDialog('upstream',
connectionsToSort());
+
+ expect(component.rows.map((row) =>
row.id)).toEqual([CURRENT_ROW_ID, SOURCE_ROW_ID, DESTINATION_ROW_ID]);
+ });
+
+ it('renders every column as sortable', () => {
+ const { component, fixture } = createDialog('upstream',
connectionsToSort());
+
+ const sortableHeaders =
fixture.debugElement.queryAll(By.css('th.mat-sort-header'));
+
expect(sortableHeaders.length).toBe(component.displayedColumns.length);
+ });
+
+ it('sorts by the source process group name', () => {
+ const { component } = createDialog('upstream',
connectionsToSort());
+
+ component.sortData({ active: 'sourceProcessGroup', direction:
'asc' });
+ expect(renderedIds(component)).toEqual([CURRENT_ROW_ID,
DESTINATION_ROW_ID, SOURCE_ROW_ID]);
+
+ component.sortData({ active: 'sourceProcessGroup', direction:
'desc' });
+ expect(renderedIds(component)).toEqual([SOURCE_ROW_ID,
DESTINATION_ROW_ID, CURRENT_ROW_ID]);
+ });
+
+ it('sorts by the source component name', () => {
+ const { component } = createDialog('upstream',
connectionsToSort());
+
+ component.sortData({ active: 'sourceComponent', direction: 'asc'
});
+ expect(renderedIds(component)).toEqual([SOURCE_ROW_ID,
DESTINATION_ROW_ID, CURRENT_ROW_ID]);
+
+ component.sortData({ active: 'sourceComponent', direction: 'desc'
});
+ expect(renderedIds(component)).toEqual([CURRENT_ROW_ID,
DESTINATION_ROW_ID, SOURCE_ROW_ID]);
+ });
+
+ it('sorts by the connection name', () => {
+ const { component } = createDialog('upstream',
connectionsToSort());
+
+ component.sortData({ active: 'connection', direction: 'desc' });
+ expect(renderedIds(component)).toEqual([DESTINATION_ROW_ID,
CURRENT_ROW_ID, SOURCE_ROW_ID]);
+
+ component.sortData({ active: 'connection', direction: 'asc' });
+ expect(renderedIds(component)).toEqual([SOURCE_ROW_ID,
CURRENT_ROW_ID, DESTINATION_ROW_ID]);
+ });
+
+ it('sorts by the destination process group name', () => {
+ const { component } = createDialog('upstream',
connectionsToSort());
+
+ component.sortData({ active: 'destinationProcessGroup', direction:
'asc' });
+ expect(renderedIds(component)).toEqual([SOURCE_ROW_ID,
CURRENT_ROW_ID, DESTINATION_ROW_ID]);
+
+ component.sortData({ active: 'destinationProcessGroup', direction:
'desc' });
+ expect(renderedIds(component)).toEqual([DESTINATION_ROW_ID,
CURRENT_ROW_ID, SOURCE_ROW_ID]);
+ });
+
+ it('sorts by the destination component name', () => {
+ const { component } = createDialog('upstream',
connectionsToSort());
+
+ component.sortData({ active: 'destinationComponent', direction:
'asc' });
+ expect(renderedIds(component)).toEqual([CURRENT_ROW_ID,
DESTINATION_ROW_ID, SOURCE_ROW_ID]);
+
+ component.sortData({ active: 'destinationComponent', direction:
'desc' });
+ expect(renderedIds(component)).toEqual([SOURCE_ROW_ID,
DESTINATION_ROW_ID, CURRENT_ROW_ID]);
+ });
+
+ it('sorts a group with no resolved name by the id it renders', () => {
+ const connections = [
+ readableConnection({ id: 'named-group-connection-id',
sourceGroupId: SOURCE_GROUP_ID }),
+ readableConnection({ id: 'unnamed-group-connection-id',
sourceGroupId: UNKNOWN_GROUP_ID })
+ ];
+
+ const { component } = createDialog('upstream', connections);
+
+ // 'Source Process Group' sorts ahead of the raw
'unknown-group-id' shown in place of a name
+ component.sortData({ active: 'sourceProcessGroup', direction:
'asc' });
+
expect(renderedIds(component)).toEqual(['named-group-connection-id',
'unnamed-group-connection-id']);
+ });
+
+ it('sorts unreadable components under the placeholder rendered for
them', () => {
+ const connections = [
+ readableConnection({
+ id: 'zeta-connection-id',
+ source: { id: 'zeta-processor-id', name: 'Zeta Processor' }
+ }),
+ unreadableConnection({ id: 'first-unreadable-connection-id' }),
+ readableConnection({
+ id: 'alpha-connection-id',
+ source: { id: 'alpha-processor-id', name: 'Alpha
Processor' }
+ }),
+ unreadableConnection({ id: 'second-unreadable-connection-id' })
+ ];
+
+ const { component } = createDialog('upstream', connections);
+
+ // 'Alpha Processor' < 'Unauthorized' < 'Zeta Processor', and rows
sharing the placeholder keep
+ // the order they were listed in
+ component.sortData({ active: 'sourceComponent', direction: 'asc'
});
+ expect(renderedIds(component)).toEqual([
+ 'alpha-connection-id',
+ 'first-unreadable-connection-id',
+ 'second-unreadable-connection-id',
+ 'zeta-connection-id'
+ ]);
+ });
+
+ it('sorts unnamed connections under the placeholder rendered for
them', () => {
+ const connections = [
+ readableConnection({ id: 'delta-connection-id', name: 'Delta
Connection' }),
+ readableConnection({ id: 'unnamed-connection-id' }),
+ readableConnection({ id: 'alpha-connection-id', name: 'Alpha
Connection' })
+ ];
+
+ const { component } = createDialog('upstream', connections);
+
+ // an unnamed connection renders 'Connection', which sorts between
'Alpha' and 'Delta'
+ expect(component.rows[1].name).toBeNull();
+ component.sortData({ active: 'connection', direction: 'asc' });
+ expect(renderedIds(component)).toEqual([
+ 'alpha-connection-id',
+ 'unnamed-connection-id',
+ 'delta-connection-id'
+ ]);
+ });
+
+ it('leaves the order unchanged for a column it does not sort on', ()
=> {
+ const { component } = createDialog('upstream',
connectionsToSort());
+ const orderBeforeSort = renderedIds(component);
+
+ component.sortData({ active: 'unsortable-column', direction: 'asc'
});
+
+ expect(renderedIds(component)).toEqual(orderBeforeSort);
+ });
+
+ it('re-sorts the table when a column header is clicked', () => {
+ const { component, fixture } = createDialog('upstream',
connectionsToSort());
+
+ clickHeader(fixture, 'mat-column-sourceComponent');
+
+ expect(component.activeSort.active).toBe('sourceComponent');
+ expect(component.activeSort.direction).toBe('asc');
+ expect(renderedIds(component)).toEqual([SOURCE_ROW_ID,
DESTINATION_ROW_ID, CURRENT_ROW_ID]);
+
+ const sourceComponentCells = getCells(fixture,
'mat-column-sourceComponent');
+ expect(sourceComponentCells[0].textContent).toContain('Alpha
Processor');
+ expect(sourceComponentCells[2].textContent).toContain('Zeta
Processor');
+ });
+
+ it('reverses the order when the active column header is clicked
again', () => {
+ const { component, fixture } = createDialog('upstream',
connectionsToSort());
+
+ clickHeader(fixture, 'mat-column-connection');
+
+ expect(component.activeSort).toEqual({ active: 'connection',
direction: 'desc' });
+ expect(renderedIds(component)).toEqual([DESTINATION_ROW_ID,
CURRENT_ROW_ID, SOURCE_ROW_ID]);
+ });
+ });
+
+ 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 00000000000..7107a302694
--- /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,400 @@
+/*
+ * 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 { MatSortModule, Sort } from '@angular/material/sort';
+import { MatTableDataSource, MatTableModule } from '@angular/material/table';
+import { MatTooltipModule } from '@angular/material/tooltip';
+import { Store } from '@ngrx/store';
+import { CloseOnEscapeDialog, ComponentContext, ComponentType, NiFiCommon }
from '@nifi/shared';
+import { CanvasState } from '../../../state';
+import { ComponentConnectionsDialogRequest, ConnectionEntity } from
'../../../state/flow';
+import { CanvasUtils } from '../../../service/canvas-utils.service';
+import { enterProcessGroup, 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 this end, in
+ * which case the cell renders an "Unauthorized" placeholder. The cell stays
clickable either way,
+ * the same as an unreadable component the user can select on the canvas.
+ */
+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: [ComponentContext, MatButtonModule, MatDialogModule,
MatSortModule, MatTableModule, MatTooltipModule],
+ templateUrl: './component-connections-dialog.component.html',
+ styleUrls: ['./component-connections-dialog.component.scss']
+})
+export class ComponentConnectionsDialog extends CloseOnEscapeDialog {
+ private dialogRequest =
inject<ComponentConnectionsDialogRequest>(MAT_DIALOG_DATA);
+ private componentConnectionsDialogRef =
inject<MatDialogRef<ComponentConnectionsDialog>>(MatDialogRef);
+ private store = inject<Store<CanvasState>>(Store);
+ private canvasUtils = inject(CanvasUtils);
+ private nifiCommon = inject(NiFiCommon);
+
+ // Maps the string type returned by the NiFi API to the ComponentType enum
used for navigation.
+ private static readonly TYPE_MAP: Record<string, ComponentType> = {
+ PROCESSOR: ComponentType.Processor,
+ INPUT_PORT: ComponentType.InputPort,
+ OUTPUT_PORT: ComponentType.OutputPort,
+ REMOTE_INPUT_PORT: ComponentType.RemoteProcessGroup,
+ REMOTE_OUTPUT_PORT: ComponentType.RemoteProcessGroup,
+ FUNNEL: ComponentType.Funnel
+ };
+
+ // rendered in place of a name the current user cannot read, or that the
component does not have
+ private static readonly UNAUTHORIZED_LABEL = 'Unauthorized';
+ private static readonly UNNAMED_CONNECTION_LABEL = 'Connection';
+
+ readonly displayedColumns: string[] = [
+ 'sourceProcessGroup',
+ 'sourceComponent',
+ 'connection',
+ 'destinationProcessGroup',
+ 'destinationComponent'
+ ];
+ readonly componentId: string = this.dialogRequest.componentId;
+ // the name the current user can read, or the component id when they
cannot; component-context
+ // renders whatever it is given
+ 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 remoteProcessGroupType = ComponentType.RemoteProcessGroup;
+ readonly connectionType = ComponentType.Connection;
+
+ readonly initialSortColumn = 'connection';
+ readonly initialSortDirection: 'asc' | 'desc' = 'asc';
+ activeSort: Sort = {
+ active: this.initialSortColumn,
+ direction: this.initialSortDirection
+ };
+ readonly dataSource: MatTableDataSource<ComponentConnectionRow> = new
MatTableDataSource<ComponentConnectionRow>();
+
+ 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));
+ this.dataSource.data = this.sortRows(this.rows, this.activeSort);
+ }
+
+ sortData(sort: Sort): void {
+ this.activeSort = sort;
+ this.dataSource.data = this.sortRows(this.dataSource.data, sort);
+ }
+
+ /**
+ * Orders the rows by the text each column actually renders, so that a row
whose name is unreadable
+ * or absent sorts under the placeholder the user sees rather than under
an empty key.
+ *
+ * @param data the rows to sort
+ * @param sort the active column and direction
+ * @returns the sorted rows
+ */
+ sortRows(data: ComponentConnectionRow[], sort: Sort):
ComponentConnectionRow[] {
+ if (!data) {
+ return [];
+ }
+ return data.slice().sort((a, b) => {
+ const isAsc = sort.direction === 'asc';
+ let retVal: number;
+ switch (sort.active) {
+ case 'sourceProcessGroup':
+ retVal = this.nifiCommon.compareString(
+ this.resolveGroupName(a.source.groupId),
+ this.resolveGroupName(b.source.groupId)
+ );
+ break;
+ case 'sourceComponent':
+ retVal = this.nifiCommon.compareString(
+ this.formatComponentName(a.source),
+ this.formatComponentName(b.source)
+ );
+ break;
+ case 'connection':
+ retVal =
this.nifiCommon.compareString(this.formatConnectionName(a),
this.formatConnectionName(b));
+ break;
+ case 'destinationProcessGroup':
+ retVal = this.nifiCommon.compareString(
+ this.resolveGroupName(a.destination.groupId),
+ this.resolveGroupName(b.destination.groupId)
+ );
+ break;
+ case 'destinationComponent':
+ retVal = this.nifiCommon.compareString(
+ this.formatComponentName(a.destination),
+ this.formatComponentName(b.destination)
+ );
+ break;
+ default:
+ return 0;
+ }
+ return retVal * (isAsc ? 1 : -1);
+ });
+ }
+
+ /**
+ * Returns the name rendered for an endpoint, which is a placeholder when
the connection cannot be
+ * read and the endpoint has no name to show.
+ *
+ * @param endpoint the source or destination endpoint
+ * @returns the endpoint name to render and sort on
+ */
+ formatComponentName(endpoint: ConnectionEndpoint): string {
+ return endpoint.name ?? ComponentConnectionsDialog.UNAUTHORIZED_LABEL;
+ }
+
+ /**
+ * Returns the tooltip of an endpoint, which falls back to the id of a
component the current user
+ * cannot read, since the placeholder rendered in its place identifies
nothing on its own.
+ *
+ * @param endpoint the source or destination endpoint
+ * @returns the endpoint name, or its id when unreadable
+ */
+ componentTooltip(endpoint: ConnectionEndpoint): string {
+ return endpoint.name ?? endpoint.id;
+ }
+
+ /**
+ * Returns the name rendered for a connection, which is a placeholder when
the connection has
+ * neither a name nor relationships to name it by.
+ *
+ * @param row the row of the connection
+ * @returns the connection name to render and sort on
+ */
+ formatConnectionName(row: ComponentConnectionRow): string {
+ return row.name ?? ComponentConnectionsDialog.UNNAMED_CONNECTION_LABEL;
+ }
+
+ /**
+ * 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 there is somewhere to go for the given process
group. The only group there is
+ * not is the one that both defines these connections and is already open
on the canvas: selecting
+ * it within itself is not a place the canvas can go, and the user is
looking at it already.
+ *
+ * The group that defines the connections is the parent group when a
port's connections cross its
+ * own group's boundary, and the parent is a group the user can still be
taken to.
+ *
+ * @param groupId the process group id to check
+ * @returns whether the group can be navigated to
+ */
+ isNavigableProcessGroup(groupId: string): boolean {
+ return !(groupId === this.dialogRequestGroupId && groupId ===
this.canvasUtils.getProcessGroupId());
+ }
+
+ /**
+ * Navigates to the process group at one end of a connection, then closes
the dialog. The group that
+ * defines the connections is entered, since it holds no component of its
own to select, while any
+ * other group is a component of it and is selected there - as a Remote
Process Group when the end is
+ * one of its remote ports.
+ *
+ * @param endpoint the source or destination endpoint whose group should
be navigated to
+ */
+ navigateToProcessGroup(endpoint: ConnectionEndpoint): void {
+ if (endpoint.groupId === this.dialogRequestGroupId) {
+ this.store.dispatch(
+ enterProcessGroup({
+ request: {
+ id: endpoint.groupId
+ }
+ })
+ );
+ this.componentConnectionsDialogRef.close();
+ return;
+ }
+
+ this.navigateTo(endpoint.groupId, this.dialogRequestGroupId,
this.processGroupTypeOf(endpoint));
+ }
+
+ /**
+ * Returns the type the process group at one end of a connection is
navigated to as.
+ *
+ * @param endpoint the source or destination endpoint
+ * @returns Remote Process Group when the end is a remote port, Process
Group otherwise
+ */
+ processGroupTypeOf(endpoint: ConnectionEndpoint): ComponentType {
+ return this.isRemoteProcessGroupPort(endpoint) ?
this.remoteProcessGroupType : this.processGroupType;
+ }
+
+ /**
+ * 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: this.buildEndpoint(
+ connection.sourceId,
+ connection.sourceGroupId,
+ connection.sourceType,
+ connection.component?.source?.name
+ ),
+ destination: this.buildEndpoint(
+ connection.destinationId,
+ connection.destinationGroupId,
+ connection.destinationType,
+ connection.component?.destination?.name
+ )
+ };
+ }
+
+ private buildEndpoint(id: string, groupId: string, type: string,
nameOnConnection?: string): ConnectionEndpoint {
+ return {
+ id,
+ groupId,
+ type: this.mapComponentType(type),
+ name:
+ this.mapComponentType(type) === ComponentType.Funnel
+ ? 'Funnel'
+ : this.resolveComponentName(id, nameOnConnection)
+ };
+ }
+
+ /**
+ * Resolves the name of one end of a connection from that component's own
read permission, with no
+ * regard for the other end or for the connection between them. A
connection is readable only when
+ * the current user can read both of its ends, so the names it carries
disappear for both ends as
+ * soon as either one is unreadable; they are only a fallback here.
+ *
+ * The components of every group these connections reach into were listed
with their own
+ * permissions, and each readable one is named there. An end that is not
is either unreadable or a
+ * port inside a Remote Process Group, whose group lists nothing of its
own - the name the
+ * connection carries covers the latter, and is only ever present when
both ends are readable.
+ *
+ * @param id the id of the component at this end of the connection
+ * @param nameOnConnection the name the connection reports for this end,
when it can be read
+ * @returns the name to render, or null when this end is unreadable
+ */
+ private resolveComponentName(id: string, nameOnConnection?: string):
string | null {
+ return this.dialogRequest.componentIdToName.get(id) ??
nameOnConnection ?? null;
+ }
+
+ private mapComponentType(type: string): ComponentType {
+ return ComponentConnectionsDialog.TYPE_MAP[type] ??
ComponentType.Connector;
+ }
+}