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 cc1ef0cfe36 NIFI-16396: Type connector canvas state and flow responses
with canonical entities (#11750)
cc1ef0cfe36 is described below
commit cc1ef0cfe3650cbfcf202aacf297b1213a97acba
Author: Matt Gilman <[email protected]>
AuthorDate: Mon Oct 5 14:45:08 2026 -0400
NIFI-16396: Type connector canvas state and flow responses with canonical
entities (#11750)
Store connector canvas collections, load-success actions, selectors, and
getConnectorFlow() as canonical entities so the compiler checks the flow
snapshot before it reaches the canvas. Flow designer and reusable-canvas
contracts stay unchanged.
This closes #11750
---
.../src/main/frontend/apps/nifi/eslint.config.mjs | 17 +-
.../pages/connectors/service/connector.service.ts | 11 +-
.../connector-canvas/connector-canvas.actions.ts | 30 +-
.../connector-canvas.effects.spec.ts | 232 +++++++++++----
.../connector-canvas/connector-canvas.effects.ts | 112 ++++----
.../connector-canvas.reducer.spec.ts | 31 +-
.../connector-canvas/connector-canvas.selectors.ts | 24 +-
.../connectors/state/connector-canvas/index.ts | 37 ++-
.../testing/connector-canvas-entity-fixtures.ts | 311 +++++++++++++++++++++
.../connectors/testing/expect-action-of-type.ts | 29 ++
.../connector-canvas.component.spec.ts | 9 +-
.../apps/nifi/src/app/state/shared/index.ts | 32 +++
12 files changed, 721 insertions(+), 154 deletions(-)
diff --git a/nifi-frontend/src/main/frontend/apps/nifi/eslint.config.mjs
b/nifi-frontend/src/main/frontend/apps/nifi/eslint.config.mjs
index 93584306e18..9c830a6e61d 100644
--- a/nifi-frontend/src/main/frontend/apps/nifi/eslint.config.mjs
+++ b/nifi-frontend/src/main/frontend/apps/nifi/eslint.config.mjs
@@ -96,5 +96,20 @@ export default [
rules: {
...config.rules
}
- }))
+ })),
+ {
+ files: ['src/app/pages/connectors/state/connector-canvas/**/*.ts'],
+ rules: {
+ '@typescript-eslint/no-explicit-any': 'error'
+ }
+ },
+ {
+ files: [
+
'src/app/pages/connectors/testing/connector-canvas-entity-fixtures.ts',
+ 'src/app/pages/connectors/testing/expect-action-of-type.ts'
+ ],
+ rules: {
+ '@typescript-eslint/no-explicit-any': 'error'
+ }
+ }
];
diff --git
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/service/connector.service.ts
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/service/connector.service.ts
index 2c8dcb56744..dadc6748247 100644
---
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/service/connector.service.ts
+++
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/service/connector.service.ts
@@ -23,7 +23,12 @@ import { Client } from '../../../service/client.service';
import { ClusterConnectionService } from
'../../../service/cluster-connection.service';
import { ConnectorsResponse, CreateConnectorRequest } from '../state';
import { ConnectorEntity } from '@nifi/shared';
-import { ControllerServiceEntity, ParameterContextEntity, SearchResultsEntity
} from '../../../state/shared';
+import {
+ ControllerServiceEntity,
+ ParameterContextEntity,
+ ProcessGroupFlowEntity,
+ SearchResultsEntity
+} from '../../../state/shared';
import { DropRequestEntity } from '../../../state/empty-queue';
@Injectable({ providedIn: 'root' })
@@ -112,8 +117,8 @@ export class ConnectorService {
});
}
- getConnectorFlow(connectorId: string, processGroupId: string):
Observable<any> {
- return this.httpClient.get(
+ getConnectorFlow(connectorId: string, processGroupId: string):
Observable<ProcessGroupFlowEntity> {
+ return this.httpClient.get<ProcessGroupFlowEntity>(
`${ConnectorService.API}/connectors/${connectorId}/flow/process-groups/${processGroupId}`
);
}
diff --git
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/state/connector-canvas/connector-canvas.actions.ts
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/state/connector-canvas/connector-canvas.actions.ts
index 680b2efc1fc..fbfda61ef2f 100644
---
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/state/connector-canvas/connector-canvas.actions.ts
+++
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/state/connector-canvas/connector-canvas.actions.ts
@@ -16,9 +16,19 @@
*/
import { createAction, props } from '@ngrx/store';
-import { ComponentType } from '@nifi/shared';
+import {
+ ComponentType,
+ ConnectionEntity,
+ FunnelEntity,
+ LabelEntity,
+ PortEntity,
+ ProcessGroupEntity,
+ ProcessorEntity,
+ RemoteProcessGroupEntity
+} from '@nifi/shared';
import { BreadcrumbEntity, ParameterContextEntity } from
'../../../../state/shared';
import { ErrorContext, ErrorContextKey } from '../../../../state/error';
+import type { ConnectorCanvasComponentEntity } from './index';
/**
* Selected component for route-based selection
@@ -47,14 +57,14 @@ export const loadConnectorFlowSuccess = createAction(
processGroupId: string | null;
parentProcessGroupId: string | null;
breadcrumb: BreadcrumbEntity | null;
- labels: any[];
- funnels: any[];
- inputPorts: any[];
- outputPorts: any[];
- remoteProcessGroups: any[];
- processGroups: any[];
- processors: any[];
- connections: any[];
+ labels: LabelEntity[];
+ funnels: FunnelEntity[];
+ inputPorts: PortEntity[];
+ outputPorts: PortEntity[];
+ remoteProcessGroups: RemoteProcessGroupEntity[];
+ processGroups: ProcessGroupEntity[];
+ processors: ProcessorEntity[];
+ connections: ConnectionEntity[];
}>()
);
@@ -193,7 +203,7 @@ export const navigateToQueueListing = createAction(
*/
export const viewComponentConfiguration = createAction(
'[Connector Canvas] View Component Configuration',
- props<{ request: { entity: any; componentType: ComponentType } }>()
+ props<{ request: { entity: ConnectorCanvasComponentEntity; componentType:
ComponentType } }>()
);
export const resetConnectorCanvasState = createAction('[Connector Canvas]
Reset State');
diff --git
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/state/connector-canvas/connector-canvas.effects.spec.ts
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/state/connector-canvas/connector-canvas.effects.spec.ts
index 7bb534cacc6..8f2fa13d5ab 100644
---
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/state/connector-canvas/connector-canvas.effects.spec.ts
+++
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/state/connector-canvas/connector-canvas.effects.spec.ts
@@ -22,10 +22,29 @@ import { Router } from '@angular/router';
import { firstValueFrom, Observable, of, Subject, throwError } from 'rxjs';
import { toArray } from 'rxjs/operators';
import { HttpErrorResponse } from '@angular/common/http';
-import { ComponentType, ComponentTypeNamePipe } from '@nifi/shared';
+import {
+ ComponentType,
+ ComponentTypeNamePipe,
+ ConnectionDTO,
+ ConnectionEntity,
+ Position,
+ ProcessorEntity
+} from '@nifi/shared';
import { MatDialog } from '@angular/material/dialog';
-import { ParameterContextEntity } from '../../../../state/shared';
+import { ParameterContextEntity, ProcessGroupFlowEntity } from
'../../../../state/shared';
import { createParameterContextFixture } from
'../../testing/parameter-context-fixture';
+import {
+ makeConnection as makeConnectionEntity,
+ makeConnectionDto,
+ makeFunnel,
+ makeLabel,
+ makeLabelDto,
+ makeProcessGroup,
+ makeProcessor,
+ makeProcessorDto
+} from '../../testing/connector-canvas-entity-fixtures';
+import { expectActionOfType } from '../../testing/expect-action-of-type';
+import type { ConnectorCanvasComponentEntity } from './index';
import { ConnectorCanvasEffects } from './connector-canvas.effects';
import { ConnectorService } from '../../service/connector.service';
import { ErrorHelper } from '../../../../service/error-helper.service';
@@ -124,12 +143,11 @@ describe('ConnectorCanvasEffects', () => {
const afterClosed$ = new Subject<unknown>();
const mockDialogRef = {
- componentInstance: {} as {
+ componentInstance: {} as Record<string, unknown> & {
parameterContext?: ParameterContextEntity | null;
supportsParameters?: boolean;
goToParameter?: (parameter: string) => void;
goToService: (serviceId: string) => void;
- [key: string]: any;
},
afterClosed: () => afterClosed$.asObservable(),
close: vi.fn()
@@ -194,13 +212,14 @@ describe('ConnectorCanvasEffects', () => {
describe('loadConnectorFlow$', () => {
it('should call getConnectorFlow and dispatch
loadConnectorFlowSuccess', async () => {
const { effects, actions$, mockConnectorService } = await setup();
+ const label = makeLabel('l1', { position: { x: 10, y: 20 } });
const flowResponse = {
processGroupFlow: {
id: 'pg-123',
parentGroupId: 'parent-456',
breadcrumb: null,
flow: {
- labels: [{ id: 'l1', position: { x: 10, y: 20 } }],
+ labels: [label],
funnels: [],
inputPorts: [],
outputPorts: [],
@@ -229,7 +248,7 @@ describe('ConnectorCanvasEffects', () => {
processGroupId: 'pg-123',
parentProcessGroupId: 'parent-456',
breadcrumb: null,
- labels: [{ id: 'l1', position: { x: 10, y: 20 } }],
+ labels: [label],
funnels: [],
inputPorts: [],
outputPorts: [],
@@ -497,15 +516,11 @@ describe('ConnectorCanvasEffects', () => {
});
describe('viewComponentConfiguration$', () => {
- const baseEntity = {
- id: 'comp-1',
- uri: 'https://localhost/nifi-api/processors/comp-1',
- permissions: { canRead: true, canWrite: true },
- operatePermissions: { canRead: true, canWrite: true },
- component: { name: 'My Component' }
- };
+ const baseEntity = makeProcessor('comp-1', {
+ component: makeProcessorDto({ id: 'comp-1', name: 'My Component' })
+ });
- async function dispatchView(componentType: ComponentType, entity: any
= baseEntity) {
+ async function dispatchView(componentType: ComponentType, entity:
ConnectorCanvasComponentEntity = baseEntity) {
const { effects, actions$, mockDialog } = await setup();
actions$(of(viewComponentConfiguration({ request: { entity,
componentType } })));
await firstValueFrom(effects.viewComponentConfiguration$);
@@ -527,7 +542,7 @@ describe('ConnectorCanvasEffects', () => {
it('opens the connection dialog with the breadcrumbs observable
wired', async () => {
const { effects, actions$, mockDialog } = await setup();
- const componentInstance: any = {};
+ const componentInstance: Record<string, unknown> = {};
(mockDialog.open as Mock).mockReturnValue({ componentInstance });
actions$(
of(
@@ -540,10 +555,10 @@ describe('ConnectorCanvasEffects', () => {
const [, config] = (mockDialog.open as Mock).mock.calls[0];
expect(config.data.type).toBe(ComponentType.Connection);
- expect(componentInstance.breadcrumbs$).toBeDefined();
- expect(componentInstance.availablePrioritizers$).toBeDefined();
-
expect(componentInstance.getChildInputPorts).toBeInstanceOf(Function);
-
expect(componentInstance.getChildOutputPorts).toBeInstanceOf(Function);
+ expect(componentInstance['breadcrumbs$']).toBeDefined();
+ expect(componentInstance['availablePrioritizers$']).toBeDefined();
+
expect(componentInstance['getChildInputPorts']).toBeInstanceOf(Function);
+
expect(componentInstance['getChildOutputPorts']).toBeInstanceOf(Function);
});
it('opens the input port dialog with read-only permissions', async ()
=> {
@@ -569,7 +584,7 @@ describe('ConnectorCanvasEffects', () => {
it('opens the process group dialog with the current user observable
wired', async () => {
const { effects, actions$, mockDialog } = await setup();
- const componentInstance: any = {};
+ const componentInstance: Record<string, unknown> = {};
(mockDialog.open as Mock).mockReturnValue({ componentInstance });
actions$(
of(
@@ -582,8 +597,8 @@ describe('ConnectorCanvasEffects', () => {
const [, config] = (mockDialog.open as Mock).mock.calls[0];
expect(config.data.type).toBe(ComponentType.ProcessGroup);
- expect(componentInstance.currentUser$).toBeDefined();
- expect(componentInstance.parameterContexts).toEqual([]);
+ expect(componentInstance['currentUser$']).toBeDefined();
+ expect(componentInstance['parameterContexts']).toEqual([]);
});
it('opens the remote process group dialog with read-only permissions',
async () => {
@@ -598,17 +613,17 @@ describe('ConnectorCanvasEffects', () => {
expect(mockDialog.open).not.toHaveBeenCalled();
});
- it('handles entities without operatePermissions by forcing
operatePermissions.canWrite to false', async () => {
- const entityWithoutOperatePermissions = {
- id: 'comp-1',
- uri: 'https://localhost/nifi-api/processors/comp-1',
- permissions: { canRead: true, canWrite: true },
- component: { name: 'My Component' }
- };
- const { mockDialog } = await dispatchView(ComponentType.Processor,
entityWithoutOperatePermissions);
+ it.each([
+ ['connection', ComponentType.Connection,
makeConnectionEntity('comp-1')],
+ ['label', ComponentType.Label, makeLabel('comp-1', { component:
makeLabelDto({ id: 'comp-1' }) })],
+ ['process group', ComponentType.ProcessGroup,
makeProcessGroup('comp-1')],
+ // Funnel has no configuration dialog, so the processor dialog
carries the entity.
+ ['funnel', ComponentType.Processor, makeFunnel('comp-1')]
+ ] as const)('does not fabricate operatePermissions on a %s', async
(_kind, componentType, entity) => {
+ const { mockDialog } = await dispatchView(componentType, entity);
const [, config] = (mockDialog.open as Mock).mock.calls[0];
- expect(config.data.entity.operatePermissions).toBeDefined();
- expect(config.data.entity.operatePermissions.canWrite).toBe(false);
+ expect(config.data.entity.operatePermissions).toBeUndefined();
+ expect(config.data.entity.permissions.canWrite).toBe(false);
});
it('wires parameter context onto the EditProcessor dialog when one is
bound', async () => {
@@ -1542,38 +1557,41 @@ describe('ConnectorCanvasEffects', () => {
});
describe('loadConnectorFlow$ — position sanitization (NIFI-16025)', () => {
- function makeEntity(id: string, x: number, y: number): any {
- return {
- id,
- permissions: { canRead: true, canWrite: true },
- revision: { version: 0 },
+ function makeEntity(id: string, x: number, y: number, readable =
true): ProcessorEntity {
+ return makeProcessor(id, {
position: { x, y },
- component: {}
- };
+ component: readable ? makeProcessorDto({ id, position: { x, y
} }) : undefined
+ });
}
- function makeConnection(
+ function makeConnectionWithBends(
id: string,
bendX: number,
bendY: number,
componentBendX = bendX,
componentBendY = bendY
- ): any {
- return {
- id,
- permissions: { canRead: true, canWrite: true },
- revision: { version: 0 },
+ ): ConnectionEntity {
+ return makeConnectionEntity(id, {
bends: [{ x: bendX, y: bendY }],
- component: { bends: [{ x: componentBendX, y: componentBendY }]
}
- };
+ component: makeConnectionDto({ id, bends: [{ x:
componentBendX, y: componentBendY }] })
+ });
}
- function makeFlowResponse(overrides: { processors?: any[];
connections?: any[] } = {}): any {
+ function makeFlowResponse(
+ overrides: { processors?: ProcessorEntity[]; connections?:
ConnectionEntity[] } = {}
+ ): ProcessGroupFlowEntity {
return {
+ permissions: { canRead: true, canWrite: true },
+ revision: { version: 0 },
processGroupFlow: {
id: 'pg-123',
- parentGroupId: null,
- breadcrumb: null,
+ breadcrumb: {
+ id: 'pg-123',
+ permissions: { canRead: true, canWrite: true },
+ versionedFlowState: '',
+ breadcrumb: { id: 'pg-123', name: 'Process Group' }
+ },
+ resolvedExecutionEngine: 'STANDARD',
flow: {
labels: [],
funnels: [],
@@ -1596,7 +1614,26 @@ describe('ConnectorCanvasEffects', () => {
);
actions$(of(loadConnectorFlow({ connectorId: 'c-1',
processGroupId: 'pg-1' })));
- const action: any = await
firstValueFrom(effects.loadConnectorFlow$);
+ const action = expectActionOfType(
+ await firstValueFrom(effects.loadConnectorFlow$),
+ loadConnectorFlowSuccess
+ );
+ expect(action.processors[0].position).toEqual({ x: 100, y: 200 });
+ });
+
+ it('preserves an unreadable processor without adding a component
body', async () => {
+ const { effects, actions$, mockConnectorService } = await setup();
+ const entity = makeEntity('p-unreadable', 100, 200, false);
+ (mockConnectorService.getConnectorFlow as Mock).mockReturnValue(
+ of(makeFlowResponse({ processors: [entity] }))
+ );
+ actions$(of(loadConnectorFlow({ connectorId: 'c-1',
processGroupId: 'pg-1' })));
+
+ const action = expectActionOfType(
+ await firstValueFrom(effects.loadConnectorFlow$),
+ loadConnectorFlowSuccess
+ );
+ expect(action.processors[0].component).toBeUndefined();
expect(action.processors[0].position).toEqual({ x: 100, y: 200 });
});
@@ -1610,7 +1647,10 @@ describe('ConnectorCanvasEffects', () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() =>
undefined);
actions$(of(loadConnectorFlow({ connectorId: 'c-1',
processGroupId: 'pg-1' })));
- const action: any = await
firstValueFrom(effects.loadConnectorFlow$);
+ const action = expectActionOfType(
+ await firstValueFrom(effects.loadConnectorFlow$),
+ loadConnectorFlowSuccess
+ );
expect(action.processors[0].position).toEqual({ x: 0, y: 0 });
expect(warnSpy).toHaveBeenCalled();
warnSpy.mockRestore();
@@ -1618,7 +1658,7 @@ describe('ConnectorCanvasEffects', () => {
it('does not sanitize a nonexistent connection position', async () => {
const { effects, actions$, mockConnectorService } = await setup();
- const connection = makeConnection('conn-valid', 100, 200, 300,
400);
+ const connection = makeConnectionWithBends('conn-valid', 100, 200,
300, 400);
(mockConnectorService.getConnectorFlow as Mock).mockReturnValue(
of(makeFlowResponse({ connections: [connection] }))
);
@@ -1626,18 +1666,21 @@ describe('ConnectorCanvasEffects', () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() =>
undefined);
actions$(of(loadConnectorFlow({ connectorId: 'c-1',
processGroupId: 'pg-1' })));
- const action: any = await
firstValueFrom(effects.loadConnectorFlow$);
+ const action = expectActionOfType(
+ await firstValueFrom(effects.loadConnectorFlow$),
+ loadConnectorFlowSuccess
+ );
expect(action.connections[0]).not.toHaveProperty('position');
expect(action.connections[0].bends[0]).toEqual({ x: 100, y: 200 });
- expect(action.connections[0].component.bends[0]).toEqual({ x: 300,
y: 400 });
+ expect(action.connections[0].component!.bends[0]).toEqual({ x:
300, y: 400 });
expect(warnSpy).not.toHaveBeenCalled();
warnSpy.mockRestore();
});
it('clamps top-level and nested catastrophic-finite connection bends
to (0, 0)', async () => {
const { effects, actions$, mockConnectorService } = await setup();
- const connection = {
- ...makeConnection('conn-bad', 9e307, 0, 0, 8e307),
+ const connection: ConnectionEntity & { position: Position } = {
+ ...makeConnectionWithBends('conn-bad', 9e307, 0, 0, 8e307),
position: { x: 7e307, y: 0 }
};
(mockConnectorService.getConnectorFlow as Mock).mockReturnValue(
@@ -1647,10 +1690,16 @@ describe('ConnectorCanvasEffects', () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() =>
undefined);
actions$(of(loadConnectorFlow({ connectorId: 'c-1',
processGroupId: 'pg-1' })));
- const action: any = await
firstValueFrom(effects.loadConnectorFlow$);
+ const action = expectActionOfType(
+ await firstValueFrom(effects.loadConnectorFlow$),
+ loadConnectorFlowSuccess
+ );
expect(action.connections[0].bends[0]).toEqual({ x: 0, y: 0 });
- expect(action.connections[0].component.bends[0]).toEqual({ x: 0,
y: 0 });
- expect(action.connections[0].position).toEqual({ x: 7e307, y: 0 });
+ expect(action.connections[0].component!.bends[0]).toEqual({ x: 0,
y: 0 });
+ expect((action.connections[0] as ConnectionEntity & { position:
Position }).position).toEqual({
+ x: 7e307,
+ y: 0
+ });
expect(warnSpy).toHaveBeenCalledTimes(1);
expect(warnSpy).toHaveBeenCalledWith(
expect.stringContaining('Component Connection bend
conn-bad:bend:0 has an out-of-range position'),
@@ -1659,5 +1708,68 @@ describe('ConnectorCanvasEffects', () => {
);
warnSpy.mockRestore();
});
+
+ it('clamps entity bends on an unreadable connection and leaves
component unset', async () => {
+ const { effects, actions$, mockConnectorService } = await setup();
+ const connection = makeConnectionEntity('conn-unreadable', {
+ permissions: { canRead: false, canWrite: false },
+ bends: [{ x: 9e307, y: 0 }],
+ component: undefined
+ });
+ (mockConnectorService.getConnectorFlow as Mock).mockReturnValue(
+ of(makeFlowResponse({ connections: [connection] }))
+ );
+
+ const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() =>
undefined);
+ actions$(of(loadConnectorFlow({ connectorId: 'c-1',
processGroupId: 'pg-1' })));
+
+ const action = expectActionOfType(
+ await firstValueFrom(effects.loadConnectorFlow$),
+ loadConnectorFlowSuccess
+ );
+ expect(action.connections[0].component).toBeUndefined();
+ expect(action.connections[0].bends[0]).toEqual({ x: 0, y: 0 });
+ expect(warnSpy).toHaveBeenCalledTimes(1);
+ warnSpy.mockRestore();
+ });
+
+ it('treats an omitted connection bends list as empty', async () => {
+ const { effects, actions$, mockConnectorService } = await setup();
+ const { bends: _omitted, ...connectionWithoutBends } =
makeConnectionEntity('conn-missing-bends', {
+ component: makeConnectionDto({ id: 'conn-missing-bends',
bends: [{ x: 5, y: 6 }] })
+ });
+ (mockConnectorService.getConnectorFlow as Mock).mockReturnValue(
+ of(makeFlowResponse({ connections: [connectionWithoutBends as
ConnectionEntity] }))
+ );
+ actions$(of(loadConnectorFlow({ connectorId: 'c-1',
processGroupId: 'pg-1' })));
+
+ const action = expectActionOfType(
+ await firstValueFrom(effects.loadConnectorFlow$),
+ loadConnectorFlowSuccess
+ );
+ expect(action.connections[0].bends).toEqual([]);
+ expect(action.connections[0].component!.bends[0]).toEqual({ x: 5,
y: 6 });
+ });
+
+ it('treats an omitted component bends list as empty', async () => {
+ const { effects, actions$, mockConnectorService } = await setup();
+ const dto = makeConnectionDto({ id: 'conn-missing-component-bends'
});
+ const { bends: _omitted, ...dtoWithoutBends } = dto;
+ const connection =
makeConnectionEntity('conn-missing-component-bends', {
+ bends: [{ x: 10, y: 20 }],
+ component: dtoWithoutBends as ConnectionDTO
+ });
+ (mockConnectorService.getConnectorFlow as Mock).mockReturnValue(
+ of(makeFlowResponse({ connections: [connection] }))
+ );
+ actions$(of(loadConnectorFlow({ connectorId: 'c-1',
processGroupId: 'pg-1' })));
+
+ const action = expectActionOfType(
+ await firstValueFrom(effects.loadConnectorFlow$),
+ loadConnectorFlowSuccess
+ );
+ expect(action.connections[0].bends[0]).toEqual({ x: 10, y: 20 });
+ expect(action.connections[0].component!.bends).toEqual([]);
+ });
});
});
diff --git
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/state/connector-canvas/connector-canvas.effects.ts
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/state/connector-canvas/connector-canvas.effects.ts
index a6d68d55b21..d98f04bbd2e 100644
---
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/state/connector-canvas/connector-canvas.effects.ts
+++
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/state/connector-canvas/connector-canvas.effects.ts
@@ -37,9 +37,13 @@ import {
import {
ComponentType,
ComponentTypeNamePipe,
+ ConnectionEntity,
+ FlowDTO,
LARGE_DIALOG,
MEDIUM_DIALOG,
NiFiCommon,
+ Permissions,
+ PortEntity,
Position,
sanitizePosition,
XL_DIALOG
@@ -85,6 +89,23 @@ import {
} from './connector-canvas.selectors';
import { selectDocumentVisibilityState } from
'../../../../state/document-visibility/document-visibility.selectors';
import { DocumentVisibility } from '../../../../state/document-visibility';
+import type { ConnectorCanvasComponentEntity } from './index';
+
+/**
+ * Shallow-copies `entity` with `permissions.canWrite` forced to false.
+ * `operatePermissions.canWrite` is rewritten only when that field is already
+ * present. Connection, label, funnel, and process group entities do not
+ * declare it, and a read-only dialog must not invent the field.
+ */
+function withReadonlyPermissions<E extends { permissions: Permissions;
operatePermissions?: Permissions }>(
+ entity: E
+): E {
+ return {
+ ...entity,
+ permissions: { ...entity.permissions, canWrite: false },
+ ...(entity.operatePermissions ? { operatePermissions: {
...entity.operatePermissions, canWrite: false } } : {})
+ };
+}
@Injectable()
export class ConnectorCanvasEffects {
@@ -692,12 +713,11 @@ export class ConnectorCanvasEffects {
* configuration in a strictly read-only mode, so the entity permissions
are
* forced to readable-only-without-write before being passed to the dialog.
*/
- private buildDialogRequest(entity: any, componentType: ComponentType):
EditComponentDialogRequest {
- const readOnlyEntity = {
- ...entity,
- permissions: { ...entity?.permissions, canWrite: false },
- operatePermissions: { ...entity?.operatePermissions, canWrite:
false }
- };
+ private buildDialogRequest(
+ entity: ConnectorCanvasComponentEntity,
+ componentType: ComponentType
+ ): EditComponentDialogRequest {
+ const readOnlyEntity = withReadonlyPermissions(entity);
return {
type: componentType,
uri: readOnlyEntity.uri,
@@ -747,22 +767,22 @@ export class ConnectorCanvasEffects {
instance.saving$ = of(false);
instance.availablePrioritizers$ =
this.store.select(selectPrioritizerTypes);
instance.breadcrumbs$ = this.store.select(selectBreadcrumbs);
- instance.getChildOutputPorts = (groupId: string): Observable<any> =>
+ instance.getChildOutputPorts = (groupId: string):
Observable<PortEntity[]> =>
this.store.select(selectConnectorIdFromRoute).pipe(
take(1),
switchMap((connectorId) =>
this.connectorService
.getConnectorFlow(connectorId!, groupId)
- .pipe(map((response: any) =>
response.processGroupFlow.flow.outputPorts))
+ .pipe(map((response) =>
response.processGroupFlow.flow.outputPorts))
)
);
- instance.getChildInputPorts = (groupId: string): Observable<any> =>
+ instance.getChildInputPorts = (groupId: string):
Observable<PortEntity[]> =>
this.store.select(selectConnectorIdFromRoute).pipe(
take(1),
switchMap((connectorId) =>
this.connectorService
.getConnectorFlow(connectorId!, groupId)
- .pipe(map((response: any) =>
response.processGroupFlow.flow.inputPorts))
+ .pipe(map((response) =>
response.processGroupFlow.flow.inputPorts))
)
);
instance.selectProcessor = (id: string) =>
this.store.select(selectProcessor(id));
@@ -812,52 +832,48 @@ export class ConnectorCanvasEffects {
* Even though the connector canvas is read-only, it renders via the same
reusable canvas
* component, so out-of-range coordinates must be clamped here to prevent
SVG overflow.
*/
- private sanitizeConnectorFlowPositions(flow: any): {
- labels: any[];
- funnels: any[];
- inputPorts: any[];
- outputPorts: any[];
- remoteProcessGroups: any[];
- processGroups: any[];
- processors: any[];
- connections: any[];
- } {
- const sanitize = (entity: any, kind: string) => ({
- ...entity,
- position: sanitizePosition(entity.position, {
- componentId: entity.id,
- componentKind: kind,
- warnedIds: this.warnedPositionIds
- })
- });
- const sanitizeBends = (entityId: string, bends: Position[] |
undefined): Position[] | undefined =>
- bends?.map((bend: Position, index: number) =>
+ private sanitizeConnectorFlowPositions(flow: FlowDTO | undefined): FlowDTO
{
+ const sanitizeCollectionPositions = <E extends { id: string; position:
Position }>(
+ entities: readonly E[],
+ componentKind: string
+ ): E[] =>
+ entities.map((entity) => ({
+ ...entity,
+ position: sanitizePosition(entity.position, {
+ componentId: entity.id,
+ componentKind,
+ warnedIds: this.warnedPositionIds
+ })
+ }));
+ const sanitizeBends = (entityId: string, bends: readonly Position[] |
null | undefined): Position[] =>
+ (bends ?? []).map((bend, index) =>
sanitizePosition(bend, {
componentId: `${entityId}:bend:${index}`,
componentKind: 'Connection bend',
warnedIds: this.warnedPositionIds
})
);
- const sanitizeConnection = (entity: any) => ({
- ...entity,
- bends: sanitizeBends(entity.id, entity.bends),
- component: entity.component
- ? {
- ...entity.component,
- bends: sanitizeBends(entity.id, entity.component.bends)
- }
- : entity.component
- });
+ const sanitizeConnections = (connections: readonly
ConnectionEntity[]): ConnectionEntity[] =>
+ connections.map((entity) => ({
+ ...entity,
+ bends: sanitizeBends(entity.id, entity.bends),
+ component: entity.component
+ ? {
+ ...entity.component,
+ bends: sanitizeBends(entity.id,
entity.component.bends)
+ }
+ : entity.component
+ }));
return {
- labels: (flow?.labels || []).map((e: any) => sanitize(e, 'Label')),
- funnels: (flow?.funnels || []).map((e: any) => sanitize(e,
'Funnel')),
- inputPorts: (flow?.inputPorts || []).map((e: any) => sanitize(e,
'Input Port')),
- outputPorts: (flow?.outputPorts || []).map((e: any) => sanitize(e,
'Output Port')),
- remoteProcessGroups: (flow?.remoteProcessGroups || []).map((e:
any) => sanitize(e, 'Remote Process Group')),
- processGroups: (flow?.processGroups || []).map((e: any) =>
sanitize(e, 'Process Group')),
- processors: (flow?.processors || []).map((e: any) => sanitize(e,
'Processor')),
- connections: (flow?.connections || []).map(sanitizeConnection)
+ labels: sanitizeCollectionPositions(flow?.labels ?? [], 'Label'),
+ funnels: sanitizeCollectionPositions(flow?.funnels ?? [],
'Funnel'),
+ inputPorts: sanitizeCollectionPositions(flow?.inputPorts ?? [],
'Input Port'),
+ outputPorts: sanitizeCollectionPositions(flow?.outputPorts ?? [],
'Output Port'),
+ remoteProcessGroups:
sanitizeCollectionPositions(flow?.remoteProcessGroups ?? [], 'Remote Process
Group'),
+ processGroups: sanitizeCollectionPositions(flow?.processGroups ??
[], 'Process Group'),
+ processors: sanitizeCollectionPositions(flow?.processors ?? [],
'Processor'),
+ connections: sanitizeConnections(flow?.connections ?? [])
};
}
}
diff --git
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/state/connector-canvas/connector-canvas.reducer.spec.ts
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/state/connector-canvas/connector-canvas.reducer.spec.ts
index ab4d41c1186..90f53324e77 100644
---
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/state/connector-canvas/connector-canvas.reducer.spec.ts
+++
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/state/connector-canvas/connector-canvas.reducer.spec.ts
@@ -20,6 +20,15 @@ import { ConnectorCanvasState, initialConnectorCanvasState }
from './index';
import * as ConnectorCanvasActions from './connector-canvas.actions';
import { ErrorContextKey } from '../../../../state/error';
import { createParameterContextFixture } from
'../../testing/parameter-context-fixture';
+import {
+ makeConnection,
+ makeFunnel,
+ makeLabel,
+ makePort,
+ makeProcessGroup,
+ makeProcessor,
+ makeRemoteProcessGroup
+} from '../../testing/connector-canvas-entity-fixtures';
describe('connectorCanvasReducer', () => {
function createPopulatedState(overrides: Partial<ConnectorCanvasState> =
{}): ConnectorCanvasState {
@@ -34,14 +43,14 @@ describe('connectorCanvasReducer', () => {
breadcrumb: { id: 'pg-current', name: 'Current PG' },
versionedFlowState: ''
},
- labels: [{ id: 'label-1' }],
- funnels: [{ id: 'funnel-1' }],
- inputPorts: [{ id: 'input-1' }],
- outputPorts: [{ id: 'output-1' }],
- remoteProcessGroups: [{ id: 'rpg-1' }],
- processGroups: [{ id: 'pg-child-1' }],
- processors: [{ id: 'proc-1' }, { id: 'proc-2' }],
- connections: [{ id: 'conn-1' }],
+ labels: [makeLabel('label-1')],
+ funnels: [makeFunnel('funnel-1')],
+ inputPorts: [makePort('input-1')],
+ outputPorts: [makePort('output-1', { portType: 'OUTPUT_PORT' })],
+ remoteProcessGroups: [makeRemoteProcessGroup('rpg-1')],
+ processGroups: [makeProcessGroup('pg-child-1')],
+ processors: [makeProcessor('proc-1'), makeProcessor('proc-2')],
+ connections: [makeConnection('conn-1')],
registryClients: [],
skipTransform: false,
loadingStatus: 'success',
@@ -124,9 +133,9 @@ describe('connectorCanvasReducer', () => {
describe('loadConnectorFlowSuccess', () => {
it('should populate state with flow data', () => {
- const processors = [{ id: 'proc-new' }];
- const connections = [{ id: 'conn-new' }];
- const labels = [{ id: 'label-new' }];
+ const processors = [makeProcessor('proc-new')];
+ const connections = [makeConnection('conn-new')];
+ const labels = [makeLabel('label-new')];
const result = connectorCanvasReducer(
initialConnectorCanvasState,
diff --git
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/state/connector-canvas/connector-canvas.selectors.ts
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/state/connector-canvas/connector-canvas.selectors.ts
index 40cfb3b9e19..dfa65feeb82 100644
---
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/state/connector-canvas/connector-canvas.selectors.ts
+++
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/state/connector-canvas/connector-canvas.selectors.ts
@@ -16,7 +16,7 @@
*/
import { createFeatureSelector, createSelector } from '@ngrx/store';
-import { selectRouteParams } from '@nifi/shared';
+import { ConnectionEntity, Position, selectRouteParams } from '@nifi/shared';
import { ConnectorCanvasState, connectorCanvasFeatureKey } from './index';
export const selectConnectorCanvasState =
createFeatureSelector<ConnectorCanvasState>(connectorCanvasFeatureKey);
@@ -55,9 +55,9 @@ export const selectProcessGroups =
createSelector(selectConnectorCanvasState, (s
export const selectConnections = createSelector(selectConnectorCanvasState,
(state) =>
// Deep copy connections to prevent state mutation
- state.connections.map((conn: any) => ({
+ state.connections.map((conn: ConnectionEntity) => ({
...conn,
- bends: conn.bends ? [...conn.bends.map((b: any) => ({ ...b }))] : []
+ bends: conn.bends ? [...conn.bends.map((bend: Position) => ({ ...bend
}))] : []
}))
);
@@ -87,22 +87,26 @@ export const selectConnectorParameterContext =
createSelector(
// Entity-by-id factory selectors for provenance eligibility checks
export const selectProcessor = (id: string) =>
- createSelector(selectProcessors, (processors) => processors.find((p: any)
=> p.id === id));
+ createSelector(selectProcessors, (processors) =>
processors.find((processor) => processor.id === id));
export const selectInputPort = (id: string) =>
- createSelector(selectInputPorts, (ports) => ports.find((p: any) => p.id
=== id));
+ createSelector(selectInputPorts, (ports) => ports.find((port) => port.id
=== id));
export const selectOutputPort = (id: string) =>
- createSelector(selectOutputPorts, (ports) => ports.find((p: any) => p.id
=== id));
+ createSelector(selectOutputPorts, (ports) => ports.find((port) => port.id
=== id));
export const selectConnection = (id: string) =>
- createSelector(selectConnections, (connections) => connections.find((c:
any) => c.id === id));
+ createSelector(selectConnections, (connections) =>
connections.find((connection) => connection.id === id));
export const selectRemoteProcessGroup = (id: string) =>
- createSelector(selectRemoteProcessGroups, (rpgs) => rpgs.find((r: any) =>
r.id === id));
+ createSelector(selectRemoteProcessGroups, (remoteProcessGroups) =>
+ remoteProcessGroups.find((remoteProcessGroup) => remoteProcessGroup.id
=== id)
+ );
export const selectProcessGroup = (id: string) =>
- createSelector(selectProcessGroups, (pgs) => pgs.find((pg: any) => pg.id
=== id));
+ createSelector(selectProcessGroups, (processGroups) =>
+ processGroups.find((processGroup) => processGroup.id === id)
+ );
export const selectFunnel = (id: string) =>
- createSelector(selectFunnels, (funnels) => funnels.find((f: any) => f.id
=== id));
+ createSelector(selectFunnels, (funnels) => funnels.find((funnel) =>
funnel.id === id));
diff --git
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/state/connector-canvas/index.ts
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/state/connector-canvas/index.ts
index 1bdefea534a..be22ab9f5dd 100644
---
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/state/connector-canvas/index.ts
+++
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/state/connector-canvas/index.ts
@@ -15,23 +15,44 @@
* limitations under the License.
*/
+import {
+ ConnectionEntity,
+ FunnelEntity,
+ LabelEntity,
+ PortEntity,
+ ProcessGroupEntity,
+ ProcessorEntity,
+ RemoteProcessGroupEntity
+} from '@nifi/shared';
import { BreadcrumbEntity, ParameterContextEntity, RegistryClientEntity } from
'../../../../state/shared';
export const connectorCanvasFeatureKey = 'connectorCanvas';
+export type ConnectorCanvasComponentEntity =
+ | ProcessorEntity
+ | PortEntity
+ | ProcessGroupEntity
+ | RemoteProcessGroupEntity
+ | (FunnelEntity & {
+ dimensions?: never;
+ zIndex?: never;
+ })
+ | ConnectionEntity
+ | LabelEntity;
+
export interface ConnectorCanvasState {
connectorId: string;
processGroupId: string | null;
parentProcessGroupId: string | null;
breadcrumb: BreadcrumbEntity | null;
- labels: any[];
- funnels: any[];
- inputPorts: any[];
- outputPorts: any[];
- remoteProcessGroups: any[];
- processGroups: any[];
- processors: any[];
- connections: any[];
+ labels: LabelEntity[];
+ funnels: FunnelEntity[];
+ inputPorts: PortEntity[];
+ outputPorts: PortEntity[];
+ remoteProcessGroups: RemoteProcessGroupEntity[];
+ processGroups: ProcessGroupEntity[];
+ processors: ProcessorEntity[];
+ connections: ConnectionEntity[];
registryClients: RegistryClientEntity[];
skipTransform: boolean;
loadingStatus: 'pending' | 'loading' | 'success' | 'error';
diff --git
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/testing/connector-canvas-entity-fixtures.ts
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/testing/connector-canvas-entity-fixtures.ts
new file mode 100644
index 00000000000..b4f73f1634f
--- /dev/null
+++
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/testing/connector-canvas-entity-fixtures.ts
@@ -0,0 +1,311 @@
+/*
+ * 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 {
+ ConnectableDTO,
+ ConnectionDTO,
+ ConnectionEntity,
+ FunnelEntity,
+ LabelDTO,
+ LabelEntity,
+ Permissions,
+ PortDTO,
+ PortEntity,
+ ProcessGroupDTO,
+ ProcessGroupEntity,
+ ProcessorDTO,
+ ProcessorEntity,
+ RemoteProcessGroupDTO,
+ RemoteProcessGroupEntity,
+ Revision
+} from '@nifi/shared';
+
+function defaultPermissions(): Permissions {
+ return { canRead: true, canWrite: true };
+}
+
+function defaultRevision(): Revision {
+ return { version: 1 };
+}
+
+export function makeProcessor(id: string, overrides: Partial<ProcessorEntity>
= {}): ProcessorEntity {
+ return {
+ id,
+ uri: `https://localhost/nifi-api/processors/${id}`,
+ revision: defaultRevision(),
+ permissions: defaultPermissions(),
+ operatePermissions: defaultPermissions(),
+ position: { x: 0, y: 0 },
+ inputRequirement: 'INPUT_ALLOWED',
+ physicalState: 'STOPPED',
+ ...overrides
+ };
+}
+
+export function makeConnection(id: string, overrides:
Partial<ConnectionEntity> = {}): ConnectionEntity {
+ return {
+ id,
+ uri: `https://localhost/nifi-api/connections/${id}`,
+ revision: defaultRevision(),
+ permissions: defaultPermissions(),
+ sourceId: `${id}-source`,
+ sourceGroupId: 'pg-current',
+ sourceType: 'PROCESSOR',
+ destinationId: `${id}-destination`,
+ destinationGroupId: 'pg-current',
+ destinationType: 'PROCESSOR',
+ bends: [],
+ labelIndex: 0,
+ zIndex: 0,
+ ...overrides
+ };
+}
+
+export function makePort(id: string, overrides: Partial<PortEntity> = {}):
PortEntity {
+ return {
+ id,
+ uri: `https://localhost/nifi-api/input-ports/${id}`,
+ revision: defaultRevision(),
+ permissions: defaultPermissions(),
+ operatePermissions: defaultPermissions(),
+ position: { x: 0, y: 0 },
+ portType: 'INPUT_PORT',
+ ...overrides
+ };
+}
+
+export function makeProcessGroup(id: string, overrides:
Partial<ProcessGroupEntity> = {}): ProcessGroupEntity {
+ return {
+ id,
+ uri: `https://localhost/nifi-api/process-groups/${id}`,
+ revision: defaultRevision(),
+ permissions: defaultPermissions(),
+ position: { x: 0, y: 0 },
+ runningCount: 0,
+ stoppedCount: 0,
+ invalidCount: 0,
+ disabledCount: 0,
+ activeRemotePortCount: 0,
+ inactiveRemotePortCount: 0,
+ upToDateCount: 0,
+ locallyModifiedCount: 0,
+ staleCount: 0,
+ locallyModifiedAndStaleCount: 0,
+ syncFailureCount: 0,
+ localInputPortCount: 0,
+ localOutputPortCount: 0,
+ publicInputPortCount: 0,
+ publicOutputPortCount: 0,
+ inputPortCount: 0,
+ outputPortCount: 0,
+ resolvedExecutionEngine: 'STANDARD',
+ ...overrides
+ };
+}
+
+export function makeRemoteProcessGroup(
+ id: string,
+ overrides: Partial<RemoteProcessGroupEntity> = {}
+): RemoteProcessGroupEntity {
+ return {
+ id,
+ uri: `https://localhost/nifi-api/remote-process-groups/${id}`,
+ revision: defaultRevision(),
+ permissions: defaultPermissions(),
+ operatePermissions: defaultPermissions(),
+ position: { x: 0, y: 0 },
+ inputPortCount: 0,
+ outputPortCount: 0,
+ ...overrides
+ };
+}
+
+export function makeFunnel(id: string, overrides: Partial<FunnelEntity> = {}):
FunnelEntity {
+ return {
+ id,
+ uri: `https://localhost/nifi-api/funnels/${id}`,
+ revision: defaultRevision(),
+ permissions: defaultPermissions(),
+ position: { x: 0, y: 0 },
+ ...overrides
+ };
+}
+
+export function makeLabel(id: string, overrides: Partial<LabelEntity> = {}):
LabelEntity {
+ return {
+ id,
+ uri: `https://localhost/nifi-api/labels/${id}`,
+ revision: defaultRevision(),
+ permissions: defaultPermissions(),
+ position: { x: 0, y: 0 },
+ dimensions: { width: 200, height: 80 },
+ zIndex: 0,
+ ...overrides
+ };
+}
+
+export function makeLabelDto(overrides: Partial<LabelDTO> = {}): LabelDTO {
+ return {
+ id: 'label-1',
+ position: { x: 0, y: 0 },
+ style: {},
+ width: 200,
+ height: 80,
+ zIndex: 0,
+ ...overrides
+ };
+}
+
+export function makeConnectableDto(overrides: Partial<ConnectableDTO> = {}):
ConnectableDTO {
+ return {
+ id: 'connectable-1',
+ type: 'PROCESSOR',
+ groupId: 'pg-current',
+ name: 'connectable-1',
+ running: false,
+ ...overrides
+ };
+}
+
+export function makeProcessorDto(overrides: Partial<ProcessorDTO> = {}):
ProcessorDTO {
+ return {
+ id: 'proc-1',
+ position: { x: 0, y: 0 },
+ name: 'Processor',
+ type: 'org.apache.nifi.processors.test.TestProcessor',
+ bundle: { group: 'org.apache.nifi', artifact: 'nifi-standard-nar',
version: '2.0.0' },
+ state: 'STOPPED',
+ relationships: [],
+ supportsParallelProcessing: true,
+ supportsBatching: false,
+ supportsSensitiveDynamicProperties: false,
+ supportsBacklogReporting: false,
+ persistsState: false,
+ restricted: false,
+ deprecated: false,
+ extensionMissing: false,
+ executionNodeRestricted: false,
+ multipleVersionsAvailable: false,
+ inputRequirement: 'INPUT_ALLOWED',
+ physicalState: 'STOPPED',
+ config: {
+ schedulingPeriod: '0 sec',
+ schedulingStrategy: 'TIMER_DRIVEN',
+ executionNode: 'ALL',
+ penaltyDuration: '30 sec',
+ yieldDuration: '1 sec',
+ bulletinLevel: 'WARN',
+ runDurationMillis: 0,
+ concurrentlySchedulableTaskCount: 1,
+ autoTerminatedRelationships: [],
+ lossTolerant: false,
+ retryCount: 10,
+ retriedRelationships: [],
+ backoffMechanism: 'PENALIZE_FLOWFILE',
+ maxBackoffPeriod: '10 mins'
+ },
+ validationStatus: 'VALID',
+ ...overrides
+ };
+}
+
+export function makeConnectionDto(overrides: Partial<ConnectionDTO> = {}):
ConnectionDTO {
+ return {
+ id: 'conn-1',
+ source: makeConnectableDto(),
+ destination: makeConnectableDto(),
+ labelIndex: 0,
+ zIndex: 0,
+ backPressureObjectThreshold: 10000,
+ backPressureDataSizeThreshold: '1 GB',
+ flowFileExpiration: '0 sec',
+ prioritizers: [],
+ bends: [],
+ loadBalanceStrategy: 'DO_NOT_LOAD_BALANCE',
+ loadBalanceCompression: 'DO_NOT_COMPRESS',
+ loadBalanceStatus: 'LOAD_BALANCE_NOT_CONFIGURED',
+ ...overrides
+ };
+}
+
+export function makePortDto(overrides: Partial<PortDTO> = {}): PortDTO {
+ return {
+ id: 'port-1',
+ position: { x: 0, y: 0 },
+ name: 'Port',
+ state: 'STOPPED',
+ type: 'INPUT_PORT',
+ portFunction: 'STANDARD',
+ concurrentlySchedulableTaskCount: 1,
+ ...overrides
+ };
+}
+
+export function makeProcessGroupDto(overrides: Partial<ProcessGroupDTO> = {}):
ProcessGroupDTO {
+ return {
+ id: 'pg-1',
+ position: { x: 0, y: 0 },
+ name: 'Process Group',
+ flowfileConcurrency: 'UNBOUNDED',
+ flowfileOutboundPolicy: 'STREAM_WHEN_AVAILABLE',
+ defaultFlowFileExpiration: '0 sec',
+ defaultBackPressureObjectThreshold: 10000,
+ defaultBackPressureDataSizeThreshold: '1 GB',
+ executionEngine: 'STANDARD',
+ resolvedExecutionEngine: 'STANDARD',
+ maxConcurrentTasks: 1,
+ statelessFlowTimeout: '1 min',
+ statelessGroupScheduledState: 'STOPPED',
+ runningCount: 0,
+ stoppedCount: 0,
+ invalidCount: 0,
+ disabledCount: 0,
+ activeRemotePortCount: 0,
+ inactiveRemotePortCount: 0,
+ upToDateCount: 0,
+ locallyModifiedCount: 0,
+ staleCount: 0,
+ locallyModifiedAndStaleCount: 0,
+ syncFailureCount: 0,
+ localInputPortCount: 0,
+ localOutputPortCount: 0,
+ publicInputPortCount: 0,
+ publicOutputPortCount: 0,
+ inputPortCount: 0,
+ outputPortCount: 0,
+ ...overrides
+ };
+}
+
+export function makeRemoteProcessGroupDto(overrides:
Partial<RemoteProcessGroupDTO> = {}): RemoteProcessGroupDTO {
+ return {
+ id: 'rpg-1',
+ position: { x: 0, y: 0 },
+ targetUris: 'http://localhost:8080/nifi',
+ communicationsTimeout: '30 sec',
+ yieldDuration: '10 sec',
+ transportProtocol: 'RAW',
+ transmitting: false,
+ activeRemoteInputPortCount: 0,
+ inactiveRemoteInputPortCount: 0,
+ activeRemoteOutputPortCount: 0,
+ inactiveRemoteOutputPortCount: 0,
+ flowRefreshed: '2026-01-01T00:00:00.000Z',
+ contents: { inputPorts: [], outputPorts: [] },
+ ...overrides
+ };
+}
diff --git
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/testing/expect-action-of-type.ts
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/testing/expect-action-of-type.ts
new file mode 100644
index 00000000000..2d13cc22059
--- /dev/null
+++
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/testing/expect-action-of-type.ts
@@ -0,0 +1,29 @@
+/*
+ * 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 { Action, ActionCreator, ActionType, Creator } from '@ngrx/store';
+
+export function expectActionOfType<T extends string, C extends Creator>(
+ action: Action,
+ creator: ActionCreator<T, C>
+): ActionType<ActionCreator<T, C>> {
+ if (action.type !== creator.type) {
+ throw new Error(`Expected action of type "${creator.type}" but
received "${action.type}"`);
+ }
+
+ return action as ActionType<ActionCreator<T, C>>;
+}
diff --git
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/ui/connector-canvas/connector-canvas.component.spec.ts
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/ui/connector-canvas/connector-canvas.component.spec.ts
index 5942573b570..f6ec1948878 100644
---
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/ui/connector-canvas/connector-canvas.component.spec.ts
+++
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/ui/connector-canvas/connector-canvas.component.spec.ts
@@ -86,6 +86,7 @@ import {
selectConnectorProvenanceEvents,
selectConnectorProvenanceStatus
} from
'../../state/connector-provenance-preview/connector-provenance-preview.selectors';
+import { makeProcessor } from '../../testing/connector-canvas-entity-fixtures';
// Mock components to avoid loading complex real components
@Component({
@@ -1739,7 +1740,7 @@ describe('ConnectorCanvasComponent', () => {
it('should dispatch viewComponentConfiguration with the supplied
entity and componentType', () => {
const { component, dispatchSpy } = setup();
dispatchSpy.mockClear();
- const entity = { id: 'proc-1', permissions: { canRead: true }
};
+ const entity = makeProcessor('proc-1');
component.viewConfigurationAction(entity,
ComponentType.Processor);
@@ -1755,7 +1756,7 @@ describe('ConnectorCanvasComponent', () => {
it('should dispatch viewComponentConfiguration when entity is
readable', () => {
const { component, dispatchSpy } = setup();
dispatchSpy.mockClear();
- const entity = { id: 'proc-1', permissions: { canRead: true }
};
+ const entity = makeProcessor('proc-1');
component.onComponentDoubleClick({ entity, componentType:
ComponentType.Processor });
@@ -1769,7 +1770,9 @@ describe('ConnectorCanvasComponent', () => {
it('should not dispatch when entity is not readable', () => {
const { component, dispatchSpy } = setup();
dispatchSpy.mockClear();
- const entity = { id: 'proc-1', permissions: { canRead: false }
};
+ const entity = makeProcessor('proc-1', {
+ permissions: { canRead: false, canWrite: false }
+ });
component.onComponentDoubleClick({ entity, componentType:
ComponentType.Processor });
diff --git
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/state/shared/index.ts
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/state/shared/index.ts
index 144bbec4ca7..e8e5cd7deab 100644
--- a/nifi-frontend/src/main/frontend/apps/nifi/src/app/state/shared/index.ts
+++ b/nifi-frontend/src/main/frontend/apps/nifi/src/app/state/shared/index.ts
@@ -23,9 +23,11 @@ import {
BulletinEntity,
Bundle,
ComponentType,
+ FlowDTO,
Parameter,
ParameterContextReferenceEntity,
Permissions,
+ ResolvedExecutionEngine,
Revision,
SelectOption
} from '@nifi/shared';
@@ -275,6 +277,36 @@ export interface BreadcrumbEntity {
parentBreadcrumb?: BreadcrumbEntity;
}
+/**
+ * The flow rooted at a process group and its breadcrumb trail.
+ *
+ * @nifi-source:
nifi-framework-bundle/nifi-framework/nifi-client-dto/src/main/java/org/apache/nifi/web/api/dto/flow/ProcessGroupFlowDTO.java
+ *
+ * `uri`, `lastRefreshed`, and `parameterContext` are omitted because current
+ * connector consumers do not read them.
+ */
+export interface ProcessGroupFlowDTO {
+ id: string;
+ parentGroupId?: string;
+ breadcrumb: BreadcrumbEntity;
+ flow: FlowDTO;
+ resolvedExecutionEngine: ResolvedExecutionEngine;
+}
+
+/**
+ * Response body for connector-scoped process group flow requests.
+ *
+ * @nifi-source:
nifi-framework-bundle/nifi-framework/nifi-client-dto/src/main/java/org/apache/nifi/web/api/entity/ProcessGroupFlowEntity.java
+ *
+ * Flow designer maintains a separate, looser contract in its feature state.
+ * Consolidating that contract is outside the connector canvas migration.
+ */
+export interface ProcessGroupFlowEntity {
+ permissions: Permissions;
+ revision: Revision;
+ processGroupFlow: ProcessGroupFlowDTO;
+}
+
export interface Relationship {
autoTerminate: boolean;
description: string;