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;

Reply via email to