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 bc098b0249b NIFI-15821: Adding support for the connector canvas 
context menu. (#11144)
bc098b0249b is described below

commit bc098b0249ba24cdaebe75689a9a1f9cb9fd0298
Author: Matt Gilman <[email protected]>
AuthorDate: Fri Apr 17 09:12:37 2026 -0400

    NIFI-15821: Adding support for the connector canvas context menu. (#11144)
    
    * NIFI-15821: Adding support for the connector canvas context menu.
    
    * NIFI-15821: Aligning casing in the canvas context menu.
    
    This closes #11144
---
 .../connector-canvas/connector-canvas.actions.ts   |   5 +
 .../connector-canvas.effects.spec.ts               |  36 +-
 .../connector-canvas/connector-canvas.effects.ts   |  36 +-
 .../connector-canvas.component.html                |   4 +-
 .../connector-canvas.component.spec.ts             | 554 ++++++++++++++++++++-
 .../connector-canvas/connector-canvas.component.ts | 181 ++++++-
 .../ui/connector-canvas/connector-canvas.module.ts |   4 +-
 .../src/app/service/component-state.service.ts     |  38 +-
 .../component-state/component-state.effects.ts     | 152 +++---
 .../component-state/component-state.reducer.ts     |   2 +
 .../component-state/component-state.selectors.ts   |   5 +
 .../nifi/src/app/state/component-state/index.ts    |   2 +
 .../context-menu/context-menu.component.html       |   2 +-
 13 files changed, 938 insertions(+), 83 deletions(-)

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 ff8679a446a..a87d8a9b2d2 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
@@ -102,4 +102,9 @@ export const setSkipTransform = createAction(
     props<{ skipTransform: boolean }>()
 );
 
+export const navigateToProvenanceForComponent = createAction(
+    '[Connector Canvas] Navigate To Provenance For Component',
+    props<{ id: string; 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 e8d8fbe954c..bcd7e919a7e 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
@@ -21,7 +21,7 @@ import { Action } from '@ngrx/store';
 import { Router } from '@angular/router';
 import { firstValueFrom, Observable, of, Subject, throwError } from 'rxjs';
 import { HttpErrorResponse } from '@angular/common/http';
-import { ComponentType } from '@nifi/shared';
+import { ComponentType, ComponentTypeNamePipe } from '@nifi/shared';
 import { ConnectorCanvasEffects } from './connector-canvas.effects';
 import { ConnectorService } from '../../service/connector.service';
 import { ErrorHelper } from '../../../../service/error-helper.service';
@@ -35,6 +35,7 @@ import {
     loadConnectorFlowComplete,
     loadConnectorFlowFailure,
     loadConnectorFlowSuccess,
+    navigateToProvenanceForComponent,
     navigateWithoutTransform,
     selectComponents
 } from './connector-canvas.actions';
@@ -94,7 +95,8 @@ describe('ConnectorCanvasEffects', () => {
                 }),
                 { provide: ConnectorService, useValue: mockConnectorService },
                 { provide: ErrorHelper, useValue: mockErrorHelper },
-                { provide: Router, useValue: mockRouter }
+                { provide: Router, useValue: mockRouter },
+                ComponentTypeNamePipe
             ]
         }).compileComponents();
 
@@ -421,4 +423,34 @@ describe('ConnectorCanvasEffects', () => {
             expect(mockRouter.navigate).toHaveBeenCalledWith(url, { 
replaceUrl: true });
         });
     });
+
+    describe('navigateToProvenanceForComponent$', () => {
+        it('should navigate to /provenance with componentId query param and 
back navigation state', async () => {
+            const { effects, actions$, mockRouter } = await setup({
+                connectorId: 'conn-1',
+                processGroupIdFromRoute: 'pg-root'
+            });
+            actions$(
+                of(
+                    navigateToProvenanceForComponent({
+                        id: 'proc-1',
+                        componentType: ComponentType.Processor
+                    })
+                )
+            );
+
+            await firstValueFrom(effects.navigateToProvenanceForComponent$);
+
+            expect(mockRouter.navigate).toHaveBeenCalledWith(['/provenance'], {
+                queryParams: { componentId: 'proc-1' },
+                state: {
+                    backNavigation: {
+                        route: ['/connectors', 'conn-1', 'canvas', 'pg-root', 
ComponentType.Processor, 'proc-1'],
+                        routeBoundary: ['/provenance'],
+                        context: 'Processor'
+                    }
+                }
+            });
+        });
+    });
 });
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 aebd009eb95..f7f7da47322 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
@@ -22,10 +22,11 @@ import { Store } from '@ngrx/store';
 import { Router } from '@angular/router';
 import { of } from 'rxjs';
 import { catchError, filter, map, switchMap, take, tap } from 'rxjs/operators';
-import { ComponentType } from '@nifi/shared';
+import { ComponentType, ComponentTypeNamePipe } from '@nifi/shared';
 import { ConnectorService } from '../../service/connector.service';
 import { ErrorHelper } from '../../../../service/error-helper.service';
 import { ErrorContextKey } from '../../../../state/error';
+import { BackNavigation } from '../../../../state/navigation';
 import * as ConnectorCanvasActions from './connector-canvas.actions';
 import { SelectedComponent } from './connector-canvas.actions';
 import {
@@ -42,6 +43,7 @@ export class ConnectorCanvasEffects {
     private router = inject(Router);
     private connectorService = inject(ConnectorService);
     private errorHelper = inject(ErrorHelper);
+    private componentTypeNamePipe = inject(ComponentTypeNamePipe);
 
     loadConnectorFlow$ = createEffect(() =>
         this.actions$.pipe(
@@ -225,4 +227,36 @@ export class ConnectorCanvasEffects {
             })
         )
     );
+
+    navigateToProvenanceForComponent$ = createEffect(
+        () =>
+            this.actions$.pipe(
+                
ofType(ConnectorCanvasActions.navigateToProvenanceForComponent),
+                map((action) => ({ id: action.id, componentType: 
action.componentType })),
+                concatLatestFrom(() => [
+                    this.store.select(selectConnectorIdFromRoute),
+                    this.store.select(selectProcessGroupIdFromRoute)
+                ]),
+                tap(([{ id: componentId, componentType }, connectorId, 
processGroupId]) => {
+                    this.router.navigate(['/provenance'], {
+                        queryParams: { componentId },
+                        state: {
+                            backNavigation: {
+                                route: [
+                                    '/connectors',
+                                    connectorId,
+                                    'canvas',
+                                    processGroupId,
+                                    componentType,
+                                    componentId
+                                ],
+                                routeBoundary: ['/provenance'],
+                                context: 
this.componentTypeNamePipe.transform(componentType)
+                            } as BackNavigation
+                        }
+                    });
+                })
+            ),
+        { dispatch: false }
+    );
 }
diff --git 
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/ui/connector-canvas/connector-canvas.component.html
 
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/ui/connector-canvas/connector-canvas.component.html
index 0abbee93469..75dbbe6e2c7 100644
--- 
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/ui/connector-canvas/connector-canvas.component.html
+++ 
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/ui/connector-canvas/connector-canvas.component.html
@@ -46,7 +46,9 @@
                 (deselectAll)="onDeselectAll()"
                 (initialized)="onCanvasInitialized()"
                 (transformChange)="onTransformChange($event)"
-                (processGroupDoubleClick)="onProcessGroupDoubleClick($event)">
+                (processGroupDoubleClick)="onProcessGroupDoubleClick($event)"
+                [menuProvider]="contextMenuProvider"
+                (contextMenuOpened)="onContextMenuOpened($event)">
             </reusable-canvas>
         }
         @if ((hasError$ | async) === true) {
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 6e8e8278578..4ea516320ae 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
@@ -28,9 +28,11 @@ import { ComponentType, selectRouteParams, selectUrl } from 
'@nifi/shared';
 
 import { ConnectorCanvasComponent } from './connector-canvas.component';
 import { CanvasComponent } from 
'../../../../ui/common/canvas/canvas.component';
+import { ContextMenuContext } from '../../../../ui/common/canvas/canvas.types';
 import { Navigation } from 
'../../../../ui/common/navigation/navigation.component';
 import { ConnectorCanvasHeaderBarComponent } from 
'./header-bar/connector-canvas-header-bar.component';
 import { ConnectorCanvasFooterComponent } from './footer/footer.component';
+import { selectCurrentUser } from 
'../../../../state/current-user/current-user.selectors';
 import { setConfiguration } from 
'../../../../state/canvas-ui/canvas-ui.actions';
 import * as ConnectorCanvasSelectors from 
'../../state/connector-canvas/connector-canvas.selectors';
 import { selectParentProcessGroupId } from 
'../../state/connector-canvas/connector-canvas.selectors';
@@ -39,11 +41,13 @@ import {
     enterProcessGroup,
     leaveProcessGroup,
     loadConnectorFlow,
+    navigateToProvenanceForComponent,
     resetConnectorCanvasState,
     selectComponents,
     setSkipTransform
 } from '../../state/connector-canvas/connector-canvas.actions';
 import { resetConnectorCanvasEntityState } from 
'../../state/connector-canvas-entity/connector-canvas-entity.actions';
+import { getComponentStateAndOpenDialog } from 
'../../../../state/component-state/component-state.actions';
 
 // Mock components to avoid loading complex real components
 @Component({
@@ -66,9 +70,11 @@ class MockReusableCanvasComponent {
     selectedComponentIds = input<string[]>([]);
     dataReady = input(false);
     skipInitialCenter = input(false);
+    menuProvider = input<unknown>(undefined);
     selectComponents = output<Array<{ id: string; type: ComponentType }>>();
     deselectAll = output<void>();
     initialized = output<void>();
+    contextMenuOpened = output<unknown>();
     centerOnSelection = vi.fn();
     centerOnComponent = vi.fn();
 }
@@ -123,11 +129,32 @@ interface SetupOptions {
     parentProcessGroupId?: string | null;
     skipTransform?: boolean;
     routeParams?: Record<string, string>;
+    canAccessProvenance?: boolean;
 }
 
 const DEFAULT_CONNECTOR_ID = 'connector-1';
 const DEFAULT_PROCESS_GROUP_ID = 'pg-root';
 
+function buildMockCurrentUser(canAccessProvenance: boolean) {
+    const permissions = { canRead: false, canWrite: false };
+    return {
+        identity: 'test-user',
+        anonymous: false,
+        canVersionFlows: false,
+        logoutSupported: false,
+        provenancePermissions: { canRead: canAccessProvenance, canWrite: false 
},
+        countersPermissions: permissions,
+        tenantsPermissions: permissions,
+        controllerPermissions: permissions,
+        policiesPermissions: permissions,
+        systemPermissions: permissions,
+        parameterContextPermissions: permissions,
+        connectorsPermissions: permissions,
+        restrictedComponentsPermissions: permissions,
+        componentRestrictionPermissions: []
+    };
+}
+
 function buildMockSelectors(options: SetupOptions = {}) {
     const loadingStatus = options.loadingStatus ?? 'success';
     const connectorId = options.connectorId !== undefined ? 
options.connectorId : DEFAULT_CONNECTOR_ID;
@@ -136,6 +163,7 @@ function buildMockSelectors(options: SetupOptions = {}) {
     const parentProcessGroupId = options.parentProcessGroupId !== undefined ? 
options.parentProcessGroupId : null;
     const skipTransform = options.skipTransform ?? false;
     const routeParamsValue = options.routeParams ?? { id: connectorId, 
processGroupId };
+    const canAccessProvenance = options.canAccessProvenance ?? true;
 
     return [
         { selector: ConnectorCanvasSelectors.selectLabels, value: [] },
@@ -152,7 +180,8 @@ function buildMockSelectors(options: SetupOptions = {}) {
         { selector: ConnectorCanvasSelectors.selectProcessGroupIdFromRoute, 
value: processGroupId },
         { selector: selectParentProcessGroupId, value: parentProcessGroupId },
         { selector: selectUrl, value: url },
-        { selector: selectRouteParams, value: routeParamsValue }
+        { selector: selectRouteParams, value: routeParamsValue },
+        { selector: selectCurrentUser, value: 
buildMockCurrentUser(canAccessProvenance) }
     ];
 }
 
@@ -696,4 +725,527 @@ describe('ConnectorCanvasComponent', () => {
             ]);
         }));
     });
+
+    describe('Context menu provider', () => {
+        function buildCanvasContext(): ContextMenuContext {
+            return {
+                processGroupId: DEFAULT_PROCESS_GROUP_ID,
+                targetType: 'canvas',
+                selectedComponents: [],
+                allConnections: []
+            };
+        }
+
+        function buildComponentContext(
+            componentType: ComponentType,
+            entityId: string,
+            selectedCount = 1,
+            entityOverrides: Record<string, any> = {}
+        ): ContextMenuContext {
+            const component = {
+                ui: { componentType } as any,
+                entity: { id: entityId, permissions: { canRead: true, 
canWrite: true }, ...entityOverrides } as any
+            };
+            return {
+                processGroupId: DEFAULT_PROCESS_GROUP_ID,
+                targetType: 'component',
+                clickedComponent: component,
+                selectedComponents: Array.from({ length: selectedCount }, () 
=> component),
+                allConnections: []
+            };
+        }
+
+        describe('getMenu', () => {
+            it('should return undefined for non-root menuId', () => {
+                const { component } = setup();
+                const result = component.contextMenuProvider.getMenu('other');
+                expect(result).toBeUndefined();
+            });
+
+            it('should return canvas menu with Refresh and Leave Group when 
targetType is canvas', fakeAsync(() => {
+                const { fixture, component } = setup({ parentProcessGroupId: 
'parent-pg' });
+                fixture.detectChanges();
+                tick();
+
+                component.onContextMenuOpened(buildCanvasContext());
+
+                const menu = component.contextMenuProvider.getMenu('root');
+                expect(menu).toBeDefined();
+
+                const items = menu!.menuItems.filter((item) => 
!item.isSeparator);
+                expect(items.map((i) => i.text)).toEqual(['Refresh', 'Leave 
Group']);
+            }));
+
+            it('should return component menu with Enter Group, View Data 
Provenance, View State, and Center In View when targetType is component', 
fakeAsync(() => {
+                const { fixture, component } = setup();
+                fixture.detectChanges();
+                tick();
+
+                
component.onContextMenuOpened(buildComponentContext(ComponentType.ProcessGroup, 
'pg-1'));
+
+                const menu = component.contextMenuProvider.getMenu('root');
+                expect(menu).toBeDefined();
+
+                const items = menu!.menuItems.filter((item) => 
!item.isSeparator);
+                expect(items.map((i) => i.text)).toEqual([
+                    'Enter Group',
+                    'View Data Provenance',
+                    'View State',
+                    'Center In View'
+                ]);
+            }));
+
+            it('should return empty menu when no context is set', () => {
+                const { component } = setup();
+                const menu = component.contextMenuProvider.getMenu('root');
+                expect(menu).toBeDefined();
+                expect(menu!.menuItems).toEqual([]);
+            });
+        });
+
+        describe('canvas menu conditions', () => {
+            it('should always show Refresh', fakeAsync(() => {
+                const { fixture, component } = setup();
+                fixture.detectChanges();
+                tick();
+
+                component.onContextMenuOpened(buildCanvasContext());
+                const menu = component.contextMenuProvider.getMenu('root')!;
+                const refresh = menu.menuItems.find((i) => i.text === 
'Refresh');
+
+                expect(refresh).toBeDefined();
+                expect(refresh!.condition!(null)).toBe(true);
+            }));
+
+            it('should show Leave Group only when canNavigateToParent is 
true', fakeAsync(() => {
+                const { fixture, component } = setup({ parentProcessGroupId: 
'parent-pg' });
+                fixture.detectChanges();
+                tick();
+
+                component.onContextMenuOpened(buildCanvasContext());
+                const menu = component.contextMenuProvider.getMenu('root')!;
+                const leaveGroup = menu.menuItems.find((i) => i.text === 
'Leave Group');
+
+                expect(leaveGroup).toBeDefined();
+                expect(leaveGroup!.condition!(null)).toBe(true);
+            }));
+
+            it('should hide Leave Group when canNavigateToParent is false', 
fakeAsync(() => {
+                const { fixture, component } = setup({ parentProcessGroupId: 
null });
+                fixture.detectChanges();
+                tick();
+
+                component.onContextMenuOpened(buildCanvasContext());
+                const menu = component.contextMenuProvider.getMenu('root')!;
+                const leaveGroup = menu.menuItems.find((i) => i.text === 
'Leave Group');
+
+                expect(leaveGroup).toBeDefined();
+                expect(leaveGroup!.condition!(null)).toBe(false);
+            }));
+        });
+
+        describe('component menu conditions', () => {
+            it('should show Enter Group for ProcessGroup with single 
selection', fakeAsync(() => {
+                const { fixture, component } = setup();
+                fixture.detectChanges();
+                tick();
+
+                
component.onContextMenuOpened(buildComponentContext(ComponentType.ProcessGroup, 
'pg-1', 1));
+                const menu = component.contextMenuProvider.getMenu('root')!;
+                const enterGroup = menu.menuItems.find((i) => i.text === 
'Enter Group');
+
+                expect(enterGroup).toBeDefined();
+                expect(enterGroup!.condition!(null)).toBe(true);
+            }));
+
+            it('should hide Enter Group for non-ProcessGroup types', 
fakeAsync(() => {
+                const { fixture, component } = setup();
+                fixture.detectChanges();
+                tick();
+
+                
component.onContextMenuOpened(buildComponentContext(ComponentType.Processor, 
'proc-1', 1));
+                const menu = component.contextMenuProvider.getMenu('root')!;
+                const enterGroup = menu.menuItems.find((i) => i.text === 
'Enter Group');
+
+                expect(enterGroup).toBeDefined();
+                expect(enterGroup!.condition!(null)).toBe(false);
+            }));
+
+            it('should hide Enter Group for multi-selection', fakeAsync(() => {
+                const { fixture, component } = setup();
+                fixture.detectChanges();
+                tick();
+
+                
component.onContextMenuOpened(buildComponentContext(ComponentType.ProcessGroup, 
'pg-1', 3));
+                const menu = component.contextMenuProvider.getMenu('root')!;
+                const enterGroup = menu.menuItems.find((i) => i.text === 
'Enter Group');
+
+                expect(enterGroup).toBeDefined();
+                expect(enterGroup!.condition!(null)).toBe(false);
+            }));
+
+            it('should always show Center In View', fakeAsync(() => {
+                const { fixture, component } = setup();
+                fixture.detectChanges();
+                tick();
+
+                
component.onContextMenuOpened(buildComponentContext(ComponentType.Processor, 
'proc-1', 1));
+                const menu = component.contextMenuProvider.getMenu('root')!;
+                const center = menu.menuItems.find((i) => i.text === 'Center 
In View');
+
+                expect(center).toBeDefined();
+                expect(center!.condition!(null)).toBe(true);
+            }));
+
+            it('should show View Data Provenance for Processor with provenance 
access', fakeAsync(() => {
+                const { fixture, component } = setup({ canAccessProvenance: 
true });
+                fixture.detectChanges();
+                tick();
+
+                
component.onContextMenuOpened(buildComponentContext(ComponentType.Processor, 
'proc-1', 1));
+                const menu = component.contextMenuProvider.getMenu('root')!;
+                const provenance = menu.menuItems.find((i) => i.text === 'View 
Data Provenance');
+
+                expect(provenance).toBeDefined();
+                expect(provenance!.condition!(null)).toBe(true);
+            }));
+
+            it('should hide View Data Provenance when user lacks provenance 
access', fakeAsync(() => {
+                const { fixture, component } = setup({ canAccessProvenance: 
false });
+                fixture.detectChanges();
+                tick();
+
+                
component.onContextMenuOpened(buildComponentContext(ComponentType.Processor, 
'proc-1', 1));
+                const menu = component.contextMenuProvider.getMenu('root')!;
+                const provenance = menu.menuItems.find((i) => i.text === 'View 
Data Provenance');
+
+                expect(provenance).toBeDefined();
+                expect(provenance!.condition!(null)).toBe(false);
+            }));
+
+            it('should hide View Data Provenance for ProcessGroup', 
fakeAsync(() => {
+                const { fixture, component } = setup({ canAccessProvenance: 
true });
+                fixture.detectChanges();
+                tick();
+
+                
component.onContextMenuOpened(buildComponentContext(ComponentType.ProcessGroup, 
'pg-1', 1));
+                const menu = component.contextMenuProvider.getMenu('root')!;
+                const provenance = menu.menuItems.find((i) => i.text === 'View 
Data Provenance');
+
+                expect(provenance).toBeDefined();
+                expect(provenance!.condition!(null)).toBe(false);
+            }));
+
+            it('should hide View Data Provenance for Connection', fakeAsync(() 
=> {
+                const { fixture, component } = setup({ canAccessProvenance: 
true });
+                fixture.detectChanges();
+                tick();
+
+                
component.onContextMenuOpened(buildComponentContext(ComponentType.Connection, 
'conn-1', 1));
+                const menu = component.contextMenuProvider.getMenu('root')!;
+                const provenance = menu.menuItems.find((i) => i.text === 'View 
Data Provenance');
+
+                expect(provenance).toBeDefined();
+                expect(provenance!.condition!(null)).toBe(false);
+            }));
+
+            it('should hide View Data Provenance for multi-selection', 
fakeAsync(() => {
+                const { fixture, component } = setup({ canAccessProvenance: 
true });
+                fixture.detectChanges();
+                tick();
+
+                
component.onContextMenuOpened(buildComponentContext(ComponentType.Processor, 
'proc-1', 3));
+                const menu = component.contextMenuProvider.getMenu('root')!;
+                const provenance = menu.menuItems.find((i) => i.text === 'View 
Data Provenance');
+
+                expect(provenance).toBeDefined();
+                expect(provenance!.condition!(null)).toBe(false);
+            }));
+
+            it('should show View State for a stateful Processor with 
read/write permissions', fakeAsync(() => {
+                const { fixture, component } = setup();
+                fixture.detectChanges();
+                tick();
+
+                component.onContextMenuOpened(
+                    buildComponentContext(ComponentType.Processor, 'proc-1', 
1, {
+                        component: { persistsState: true, name: 'My Processor' 
},
+                        permissions: { canRead: true, canWrite: true }
+                    })
+                );
+                const menu = component.contextMenuProvider.getMenu('root')!;
+                const viewState = menu.menuItems.find((i) => i.text === 'View 
State');
+
+                expect(viewState).toBeDefined();
+                expect(viewState!.condition!(null)).toBe(true);
+            }));
+
+            it('should hide View State for non-Processor types', fakeAsync(() 
=> {
+                const { fixture, component } = setup();
+                fixture.detectChanges();
+                tick();
+
+                component.onContextMenuOpened(
+                    buildComponentContext(ComponentType.ProcessGroup, 'pg-1', 
1, {
+                        component: { persistsState: true }
+                    })
+                );
+                const menu = component.contextMenuProvider.getMenu('root')!;
+                const viewState = menu.menuItems.find((i) => i.text === 'View 
State');
+
+                expect(viewState).toBeDefined();
+                expect(viewState!.condition!(null)).toBe(false);
+            }));
+
+            it('should hide View State when processor does not persist state', 
fakeAsync(() => {
+                const { fixture, component } = setup();
+                fixture.detectChanges();
+                tick();
+
+                component.onContextMenuOpened(
+                    buildComponentContext(ComponentType.Processor, 'proc-1', 
1, {
+                        component: { persistsState: false, name: 'Stateless 
Proc' },
+                        permissions: { canRead: true, canWrite: true }
+                    })
+                );
+                const menu = component.contextMenuProvider.getMenu('root')!;
+                const viewState = menu.menuItems.find((i) => i.text === 'View 
State');
+
+                expect(viewState).toBeDefined();
+                expect(viewState!.condition!(null)).toBe(false);
+            }));
+
+            it('should hide View State when user lacks write permission', 
fakeAsync(() => {
+                const { fixture, component } = setup();
+                fixture.detectChanges();
+                tick();
+
+                component.onContextMenuOpened(
+                    buildComponentContext(ComponentType.Processor, 'proc-1', 
1, {
+                        component: { persistsState: true, name: 'My Processor' 
},
+                        permissions: { canRead: true, canWrite: false }
+                    })
+                );
+                const menu = component.contextMenuProvider.getMenu('root')!;
+                const viewState = menu.menuItems.find((i) => i.text === 'View 
State');
+
+                expect(viewState).toBeDefined();
+                expect(viewState!.condition!(null)).toBe(false);
+            }));
+
+            it('should hide View State for multi-selection', fakeAsync(() => {
+                const { fixture, component } = setup();
+                fixture.detectChanges();
+                tick();
+
+                component.onContextMenuOpened(
+                    buildComponentContext(ComponentType.Processor, 'proc-1', 
3, {
+                        component: { persistsState: true, name: 'My Processor' 
},
+                        permissions: { canRead: true, canWrite: true }
+                    })
+                );
+                const menu = component.contextMenuProvider.getMenu('root')!;
+                const viewState = menu.menuItems.find((i) => i.text === 'View 
State');
+
+                expect(viewState).toBeDefined();
+                expect(viewState!.condition!(null)).toBe(false);
+            }));
+        });
+
+        describe('filterMenuItem', () => {
+            it('should return true when condition returns true', () => {
+                const { component } = setup();
+                const item = { condition: () => true, text: 'test' };
+                
expect(component.contextMenuProvider.filterMenuItem(item)).toBe(true);
+            });
+
+            it('should return false when condition returns false', () => {
+                const { component } = setup();
+                const item = { condition: () => false, text: 'test' };
+                
expect(component.contextMenuProvider.filterMenuItem(item)).toBe(false);
+            });
+
+            it('should return true when no condition is set (separators)', () 
=> {
+                const { component } = setup();
+                const item = { isSeparator: true };
+                
expect(component.contextMenuProvider.filterMenuItem(item)).toBe(true);
+            });
+        });
+
+        describe('menuItemClicked', () => {
+            it('should call the item action', () => {
+                const { component } = setup();
+                const action = vi.fn();
+                const item = { text: 'test', action };
+                const event = new MouseEvent('click');
+
+                component.contextMenuProvider.menuItemClicked(item, event);
+
+                expect(action).toHaveBeenCalled();
+            });
+        });
+
+        describe('onContextMenuOpened', () => {
+            it('should store the context', () => {
+                const { component } = setup();
+                const context = buildCanvasContext();
+
+                component.onContextMenuOpened(context);
+
+                const menu = component.contextMenuProvider.getMenu('root');
+                expect(menu).toBeDefined();
+                expect(menu!.menuItems.length).toBeGreaterThan(0);
+            });
+        });
+    });
+
+    describe('Action methods', () => {
+        describe('refreshAction', () => {
+            it('should dispatch loadConnectorFlow', fakeAsync(() => {
+                const { fixture, dispatchSpy } = setup();
+                fixture.detectChanges();
+                tick();
+                dispatchSpy.mockClear();
+
+                fixture.componentInstance.refreshAction();
+
+                expect(dispatchSpy).toHaveBeenCalledWith(
+                    loadConnectorFlow({
+                        connectorId: DEFAULT_CONNECTOR_ID,
+                        processGroupId: DEFAULT_PROCESS_GROUP_ID
+                    })
+                );
+            }));
+
+            it('should not dispatch when connectorId is empty', () => {
+                const { component, dispatchSpy } = setup();
+                component.currentConnectorId = '';
+                component.currentProcessGroupId = null;
+                dispatchSpy.mockClear();
+
+                component.refreshAction();
+
+                expect(dispatchSpy).not.toHaveBeenCalled();
+            });
+        });
+
+        describe('enterGroupAction', () => {
+            it('should dispatch enterProcessGroup', () => {
+                const { component, dispatchSpy } = setup();
+                dispatchSpy.mockClear();
+
+                component.enterGroupAction('pg-nested');
+
+                expect(dispatchSpy).toHaveBeenCalledWith(enterProcessGroup({ 
request: { id: 'pg-nested' } }));
+            });
+        });
+
+        describe('viewDataProvenanceAction', () => {
+            it('should dispatch navigateToProvenanceForComponent', () => {
+                const { component, dispatchSpy } = setup();
+                dispatchSpy.mockClear();
+
+                component.viewDataProvenanceAction('proc-1', 
ComponentType.Processor);
+
+                expect(dispatchSpy).toHaveBeenCalledWith(
+                    navigateToProvenanceForComponent({ id: 'proc-1', 
componentType: ComponentType.Processor })
+                );
+            });
+        });
+
+        describe('viewProcessorStateAction', () => {
+            it('should dispatch getComponentStateAndOpenDialog with 
connectorId', fakeAsync(() => {
+                const { fixture, component, dispatchSpy } = setup();
+                fixture.detectChanges();
+                tick();
+                dispatchSpy.mockClear();
+
+                const processorEntity = {
+                    id: 'proc-1',
+                    component: { name: 'My Processor', persistsState: true },
+                    status: { aggregateSnapshot: { runStatus: 'Stopped', 
activeThreadCount: 0 } }
+                };
+
+                component.viewProcessorStateAction(processorEntity);
+
+                expect(dispatchSpy).toHaveBeenCalledWith(
+                    getComponentStateAndOpenDialog({
+                        request: {
+                            componentName: 'My Processor',
+                            componentId: 'proc-1',
+                            componentType: ComponentType.Processor,
+                            canClear: true,
+                            connectorId: DEFAULT_CONNECTOR_ID
+                        }
+                    })
+                );
+            }));
+
+            it('should set canClear to false when processor is running', 
fakeAsync(() => {
+                const { fixture, component, dispatchSpy } = setup();
+                fixture.detectChanges();
+                tick();
+                dispatchSpy.mockClear();
+
+                const processorEntity = {
+                    id: 'proc-1',
+                    component: { name: 'Running Processor', persistsState: 
true },
+                    status: { aggregateSnapshot: { runStatus: 'Running', 
activeThreadCount: 1 } }
+                };
+
+                component.viewProcessorStateAction(processorEntity);
+
+                expect(dispatchSpy).toHaveBeenCalledWith(
+                    getComponentStateAndOpenDialog({
+                        request: {
+                            componentName: 'Running Processor',
+                            componentId: 'proc-1',
+                            componentType: ComponentType.Processor,
+                            canClear: false,
+                            connectorId: DEFAULT_CONNECTOR_ID
+                        }
+                    })
+                );
+            }));
+
+            it('should set canClear to false when processor has active 
threads', fakeAsync(() => {
+                const { fixture, component, dispatchSpy } = setup();
+                fixture.detectChanges();
+                tick();
+                dispatchSpy.mockClear();
+
+                const processorEntity = {
+                    id: 'proc-1',
+                    component: { name: 'Active Processor', persistsState: true 
},
+                    status: { aggregateSnapshot: { runStatus: 'Stopped', 
activeThreadCount: 2 } }
+                };
+
+                component.viewProcessorStateAction(processorEntity);
+
+                expect(dispatchSpy).toHaveBeenCalledWith(
+                    getComponentStateAndOpenDialog({
+                        request: {
+                            componentName: 'Active Processor',
+                            componentId: 'proc-1',
+                            componentType: ComponentType.Processor,
+                            canClear: false,
+                            connectorId: DEFAULT_CONNECTOR_ID
+                        }
+                    })
+                );
+            }));
+        });
+    });
+
+    describe('Template bindings', () => {
+        it('should pass menuProvider to reusable-canvas', fakeAsync(() => {
+            const { fixture } = setup();
+            fixture.detectChanges();
+            tick();
+
+            const canvasDebugEl = fixture.debugElement.query((el) => el.name 
=== 'reusable-canvas');
+            expect(canvasDebugEl).toBeTruthy();
+            
expect(canvasDebugEl.componentInstance.menuProvider()).toBeTruthy();
+        }));
+    });
 });
diff --git 
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/ui/connector-canvas/connector-canvas.component.ts
 
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/ui/connector-canvas/connector-canvas.component.ts
index e7b540a6e9f..2fcce807855 100644
--- 
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/ui/connector-canvas/connector-canvas.component.ts
+++ 
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/ui/connector-canvas/connector-canvas.component.ts
@@ -15,7 +15,7 @@
  * limitations under the License.
  */
 
-import { Component, OnDestroy, OnInit, DestroyRef, inject, HostListener, 
viewChild } from '@angular/core';
+import { Component, computed, OnDestroy, OnInit, DestroyRef, inject, 
HostListener, viewChild } from '@angular/core';
 import { CommonModule } from '@angular/common';
 import { Router } from '@angular/router';
 import { MatButton } from '@angular/material/button';
@@ -26,15 +26,23 @@ import { ComponentType, selectRouteParams, selectUrl } from 
'@nifi/shared';
 import { DocumentedType, RegistryClientEntity } from 
'../../../../state/shared';
 import { combineLatest, distinctUntilChanged, filter, map, Observable, of } 
from 'rxjs';
 import { NiFiState } from '../../../../state';
+import { selectCurrentUser } from 
'../../../../state/current-user/current-user.selectors';
 import { CanvasConfiguration } from '../../../../state/canvas-ui';
 import { setConfiguration } from 
'../../../../state/canvas-ui/canvas-ui.actions';
 import { CanvasComponent } from 
'../../../../ui/common/canvas/canvas.component';
+import { ContextMenuContext } from '../../../../ui/common/canvas/canvas.types';
+import {
+    ContextMenuDefinition,
+    ContextMenuDefinitionProvider,
+    ContextMenuItemDefinition
+} from '../../../../ui/common/context-menu/context-menu.component';
 import { Navigation } from 
'../../../../ui/common/navigation/navigation.component';
 import { ConnectorCanvasHeaderBarComponent } from 
'./header-bar/connector-canvas-header-bar.component';
 import { ConnectorCanvasFooterComponent } from './footer/footer.component';
 import * as ConnectorCanvasActions from 
'../../state/connector-canvas/connector-canvas.actions';
 import * as ConnectorCanvasSelectors from 
'../../state/connector-canvas/connector-canvas.selectors';
 import * as ConnectorCanvasEntityActions from 
'../../state/connector-canvas-entity/connector-canvas-entity.actions';
+import { getComponentStateAndOpenDialog } from 
'../../../../state/component-state/component-state.actions';
 
 const GRAPH_CONTROLS_STORAGE_KEY = 'connector-graph-controls';
 
@@ -66,7 +74,6 @@ export class ConnectorCanvasComponent implements OnInit, 
OnDestroy {
     skipTransform = 
this.store.selectSignal(ConnectorCanvasSelectors.selectSkipTransform);
     graphControlsOpen = localStorage.getItem(GRAPH_CONTROLS_STORAGE_KEY) !== 
'false';
 
-    // Subscribe to connector canvas state (flow data)
     labels$: Observable<unknown[]> = 
this.store.select(ConnectorCanvasSelectors.selectLabels);
     processors$: Observable<unknown[]> = 
this.store.select(ConnectorCanvasSelectors.selectProcessors);
     funnels$: Observable<unknown[]> = 
this.store.select(ConnectorCanvasSelectors.selectFunnels);
@@ -79,6 +86,108 @@ export class ConnectorCanvasComponent implements OnInit, 
OnDestroy {
     );
     previewExtensions$: Observable<DocumentedType[]> = of([]);
 
+    private currentUser = this.store.selectSignal(selectCurrentUser);
+    canAccessProvenance = computed(() => 
this.currentUser().provenancePermissions.canRead);
+
+    // 
=========================================================================
+    // Context Menu
+    // 
=========================================================================
+
+    private currentContextMenuContext: ContextMenuContext | null = null;
+
+    contextMenuProvider: ContextMenuDefinitionProvider = {
+        getMenu: (menuId: string): ContextMenuDefinition | undefined => {
+            if (menuId !== 'root') {
+                return undefined;
+            }
+
+            const context = this.currentContextMenuContext;
+            let menuItems: ContextMenuItemDefinition[] = [];
+
+            if (context?.targetType === 'canvas') {
+                menuItems = [
+                    {
+                        text: 'Refresh',
+                        clazz: 'fa fa-refresh',
+                        condition: () => true,
+                        action: () => this.refreshAction(),
+                        shortcut: { control: true, code: 'R' }
+                    },
+                    {
+                        text: 'Leave Group',
+                        clazz: 'fa fa-level-up',
+                        condition: () => this.canNavigateToParent,
+                        action: () => this.leaveGroupAction(),
+                        shortcut: { code: 'ESC' }
+                    }
+                ];
+            } else if (context?.targetType === 'component') {
+                const clicked = context.clickedComponent;
+                const isSingleSelection = context.selectedComponents.length <= 
1;
+                const isProcessGroup = clicked?.ui.componentType === 
ComponentType.ProcessGroup;
+                const isConnection = clicked?.ui.componentType === 
ComponentType.Connection;
+                const isProcessor = clicked?.ui.componentType === 
ComponentType.Processor;
+                const isProvenanceTarget =
+                    !!clicked &&
+                    isSingleSelection &&
+                    !isProcessGroup &&
+                    !isConnection &&
+                    clicked.ui.componentType !== 
ComponentType.RemoteProcessGroup &&
+                    clicked.ui.componentType !== ComponentType.Label;
+
+                menuItems = [
+                    {
+                        text: 'Enter Group',
+                        clazz: 'fa fa-sign-in',
+                        condition: () => isProcessGroup && isSingleSelection,
+                        action: () => this.enterGroupAction(clicked!.entity.id)
+                    },
+                    { isSeparator: true },
+                    {
+                        text: 'View Data Provenance',
+                        clazz: 'icon icon-provenance',
+                        condition: () => isProvenanceTarget && 
this.canAccessProvenance(),
+                        action: () => 
this.viewDataProvenanceAction(clicked!.entity.id, clicked!.ui.componentType)
+                    },
+                    {
+                        text: 'View State',
+                        clazz: 'fa fa-tasks',
+                        condition: () =>
+                            isProcessor &&
+                            isSingleSelection &&
+                            clicked!.entity.component?.persistsState === true 
&&
+                            clicked!.entity.permissions.canRead === true &&
+                            clicked!.entity.permissions.canWrite === true,
+                        action: () => 
this.viewProcessorStateAction(clicked!.entity)
+                    },
+                    { isSeparator: true },
+                    this.getCenterInViewMenuItem()
+                ];
+            }
+
+            return { id: menuId, menuItems };
+        },
+        filterMenuItem: (menuItem: ContextMenuItemDefinition): boolean => {
+            return menuItem.condition ? menuItem.condition(null) : true;
+        },
+        menuItemClicked: (menuItem: ContextMenuItemDefinition, event: 
MouseEvent): void => {
+            menuItem.action?.(null, event);
+        }
+    };
+
+    onContextMenuOpened(context: ContextMenuContext): void {
+        this.currentContextMenuContext = context;
+    }
+
+    private getCenterInViewMenuItem(): ContextMenuItemDefinition {
+        return {
+            text: 'Center In View',
+            clazz: 'fa fa-crosshairs',
+            condition: () => true,
+            action: () => this.centerInViewAction()
+        };
+    }
+
     // Data ready signal for canvas - true when flow data is successfully 
loaded
     dataReady$: Observable<boolean> = this.store
         .select(ConnectorCanvasSelectors.selectLoadingStatus)
@@ -191,12 +300,67 @@ export class ConnectorCanvasComponent implements OnInit, 
OnDestroy {
         }
     }
 
+    // 
=========================================================================
+    // Shared Actions (used by both context menu and keyboard shortcuts)
+    // 
=========================================================================
+
+    refreshAction(): void {
+        if (this.currentConnectorId && this.currentProcessGroupId) {
+            this.store.dispatch(
+                ConnectorCanvasActions.loadConnectorFlow({
+                    connectorId: this.currentConnectorId,
+                    processGroupId: this.currentProcessGroupId
+                })
+            );
+        }
+    }
+
     leaveGroupAction(): void {
         if (this.canNavigateToParent) {
             this.store.dispatch(ConnectorCanvasActions.leaveProcessGroup());
         }
     }
 
+    enterGroupAction(processGroupId: string): void {
+        this.store.dispatch(ConnectorCanvasActions.enterProcessGroup({ 
request: { id: processGroupId } }));
+    }
+
+    viewDataProvenanceAction(componentId: string, componentType: 
ComponentType): void {
+        this.store.dispatch(
+            ConnectorCanvasActions.navigateToProvenanceForComponent({ id: 
componentId, componentType })
+        );
+    }
+
+    viewProcessorStateAction(processorEntity: any): void {
+        this.store.dispatch(
+            getComponentStateAndOpenDialog({
+                request: {
+                    componentName: processorEntity.component.name,
+                    componentId: processorEntity.id,
+                    componentType: ComponentType.Processor,
+                    canClear: this.canClearProcessorState(processorEntity),
+                    connectorId: this.currentConnectorId
+                }
+            })
+        );
+    }
+
+    private canClearProcessorState(processorEntity: any): boolean {
+        const runStatus = processorEntity.status?.aggregateSnapshot?.runStatus;
+        const activeThreadCount = 
processorEntity.status?.aggregateSnapshot?.activeThreadCount || 0;
+        return runStatus !== 'Running' && activeThreadCount === 0;
+    }
+
+    centerInViewAction(): void {
+        this.canvasComponent().centerOnSelection(true);
+    }
+
+    // 
=========================================================================
+    // Keyboard Shortcuts
+    // Typed as Event (not KeyboardEvent) because Angular 21's 
typeCheckHostBindings
+    // infers $event as Event for key-specific bindings 
(angular/angular#40778).
+    // 
=========================================================================
+
     @HostListener('window:keydown.escape', ['$event'])
     handleEscapeShortcut(event: Event): void {
         if (this.shouldProcessKeyboardEvent(event)) {
@@ -209,21 +373,10 @@ export class ConnectorCanvasComponent implements OnInit, 
OnDestroy {
     handleRefreshShortcut(event: Event): void {
         if (this.shouldProcessKeyboardEvent(event)) {
             event.preventDefault();
-            this.store.dispatch(
-                ConnectorCanvasActions.loadConnectorFlow({
-                    connectorId: this.currentConnectorId,
-                    processGroupId: this.currentProcessGroupId!
-                })
-            );
+            this.refreshAction();
         }
     }
 
-    // 
=========================================================================
-    // Keyboard Shortcuts
-    // Typed as Event (not KeyboardEvent) because Angular 21's 
typeCheckHostBindings
-    // infers $event as Event for key-specific bindings 
(angular/angular#40778).
-    // 
=========================================================================
-
     private shouldProcessKeyboardEvent(event: Event): boolean {
         if (this.dialog.openDialogs.length > 0) {
             return false;
diff --git 
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/ui/connector-canvas/connector-canvas.module.ts
 
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/ui/connector-canvas/connector-canvas.module.ts
index f8616bf7403..dc8d9873ed0 100644
--- 
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/ui/connector-canvas/connector-canvas.module.ts
+++ 
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/ui/connector-canvas/connector-canvas.module.ts
@@ -20,6 +20,7 @@ import { CommonModule } from '@angular/common';
 import { RouterModule, Routes } from '@angular/router';
 import { StoreModule } from '@ngrx/store';
 import { EffectsModule } from '@ngrx/effects';
+import { ComponentTypeNamePipe } from '@nifi/shared';
 import { ConnectorCanvasComponent } from './connector-canvas.component';
 import { ConnectorCanvasRedirector } from 
'./guard/connector-canvas-redirector.component';
 import { connectorCanvasRootGuard } from './guard/connector-canvas-root.guard';
@@ -53,6 +54,7 @@ const routes: Routes = [
         RouterModule.forChild(routes),
         StoreModule.forFeature(connectorCanvasFeatureKey, 
connectorCanvasReducer),
         EffectsModule.forFeature(ConnectorCanvasEffects, 
ConnectorCanvasEntityEffects)
-    ]
+    ],
+    providers: [ComponentTypeNamePipe]
 })
 export class ConnectorCanvasModule {}
diff --git 
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/service/component-state.service.ts
 
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/service/component-state.service.ts
index fa6adf2f82b..a2abec98539 100644
--- 
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/service/component-state.service.ts
+++ 
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/service/component-state.service.ts
@@ -48,12 +48,46 @@ export class ComponentStateService {
         componentId: string,
         componentStateEntity: ComponentStateEntity
     ): Observable<any> {
-        // To clear a specific state entry, we send the updated state
-        // without the key to be cleared in the ComponentStateEntity format
         const path = this.nifiCommon.getComponentTypeApiPath(componentType);
         return this.httpClient.post(
             
`${ComponentStateService.API}/${path}/${componentId}/state/clear-requests`,
             componentStateEntity
         );
     }
+
+    getConnectorComponentState(
+        connectorId: string,
+        componentType: ComponentType,
+        componentId: string
+    ): Observable<ComponentStateEntity> {
+        const typePath = 
this.nifiCommon.getComponentTypeApiPath(componentType);
+        return this.httpClient.get<ComponentStateEntity>(
+            
`${ComponentStateService.API}/connectors/${connectorId}/${typePath}/${componentId}/state`
+        );
+    }
+
+    clearConnectorComponentState(
+        connectorId: string,
+        componentType: ComponentType,
+        componentId: string
+    ): Observable<any> {
+        const typePath = 
this.nifiCommon.getComponentTypeApiPath(componentType);
+        return this.httpClient.post(
+            
`${ComponentStateService.API}/connectors/${connectorId}/${typePath}/${componentId}/state/clear-requests`,
+            {}
+        );
+    }
+
+    clearConnectorComponentStateEntry(
+        connectorId: string,
+        componentType: ComponentType,
+        componentId: string,
+        componentStateEntity: ComponentStateEntity
+    ): Observable<any> {
+        const typePath = 
this.nifiCommon.getComponentTypeApiPath(componentType);
+        return this.httpClient.post(
+            
`${ComponentStateService.API}/connectors/${connectorId}/${typePath}/${componentId}/state/clear-requests`,
+            componentStateEntity
+        );
+    }
 }
diff --git 
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/state/component-state/component-state.effects.ts
 
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/state/component-state/component-state.effects.ts
index cb987915e92..ba6c5ed7f79 100644
--- 
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/state/component-state/component-state.effects.ts
+++ 
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/state/component-state/component-state.effects.ts
@@ -26,7 +26,12 @@ import { catchError, from, map, of, switchMap, tap } from 
'rxjs';
 import { MatDialog } from '@angular/material/dialog';
 import { ComponentStateService } from '../../service/component-state.service';
 import { ComponentStateDialog } from 
'../../ui/common/component-state/component-state.component';
-import { selectComponentType, selectComponentId, selectComponentState } from 
'./component-state.selectors';
+import {
+    selectComponentType,
+    selectComponentId,
+    selectComponentState,
+    selectConnectorId
+} from './component-state.selectors';
 import { isDefinedAndNotNull, XL_DIALOG } from '@nifi/shared';
 import * as ErrorActions from '../error/error.actions';
 import { HttpErrorResponse } from '@angular/common/http';
@@ -46,34 +51,40 @@ export class ComponentStateEffects {
         this.actions$.pipe(
             ofType(ComponentStateActions.getComponentStateAndOpenDialog),
             map((action) => action.request),
-            switchMap((request) =>
-                from(
-                    this.componentStateService
-                        .getComponentState({
-                            componentType: request.componentType,
-                            componentId: request.componentId
-                        })
-                        .pipe(
-                            map((response: ComponentStateEntity) =>
-                                
ComponentStateActions.loadComponentStateSuccess({
-                                    response: {
-                                        componentState: response.componentState
-                                    }
+            switchMap((request) => {
+                const stateRequest$ = request.connectorId
+                    ? this.componentStateService.getConnectorComponentState(
+                          request.connectorId,
+                          request.componentType,
+                          request.componentId
+                      )
+                    : this.componentStateService.getComponentState({
+                          componentType: request.componentType,
+                          componentId: request.componentId
+                      });
+
+                return from(
+                    stateRequest$.pipe(
+                        map((response: ComponentStateEntity) =>
+                            ComponentStateActions.loadComponentStateSuccess({
+                                response: {
+                                    componentState: response.componentState
+                                }
+                            })
+                        ),
+                        catchError((errorResponse: HttpErrorResponse) =>
+                            of(
+                                ErrorActions.snackBarError({
+                                    error: this.errorHelper.getErrorString(
+                                        errorResponse,
+                                        `Failed to get the component state for 
${request.componentName}.`
+                                    )
                                 })
-                            ),
-                            catchError((errorResponse: HttpErrorResponse) =>
-                                of(
-                                    ErrorActions.snackBarError({
-                                        error: this.errorHelper.getErrorString(
-                                            errorResponse,
-                                            `Failed to get the component state 
for ${request.componentName}.`
-                                        )
-                                    })
-                                )
                             )
                         )
-                )
-            )
+                    )
+                );
+            })
         )
     );
 
@@ -108,11 +119,16 @@ export class ComponentStateEffects {
             ofType(ComponentStateActions.clearComponentState),
             concatLatestFrom(() => [
                 
this.store.select(selectComponentType).pipe(isDefinedAndNotNull()),
-                
this.store.select(selectComponentId).pipe(isDefinedAndNotNull())
+                
this.store.select(selectComponentId).pipe(isDefinedAndNotNull()),
+                this.store.select(selectConnectorId)
             ]),
-            switchMap(([, componentType, componentId]) =>
-                from(
-                    this.componentStateService.clearComponentState({ 
componentType, componentId }).pipe(
+            switchMap(([, componentType, componentId, connectorId]) => {
+                const clearRequest$ = connectorId
+                    ? 
this.componentStateService.clearConnectorComponentState(connectorId, 
componentType, componentId)
+                    : this.componentStateService.clearComponentState({ 
componentType, componentId });
+
+                return from(
+                    clearRequest$.pipe(
                         map(() => 
ComponentStateActions.reloadComponentState()),
                         catchError((errorResponse: HttpErrorResponse) =>
                             of(
@@ -130,8 +146,8 @@ export class ComponentStateEffects {
                             )
                         )
                     )
-                )
-            )
+                );
+            })
         )
     );
 
@@ -140,12 +156,17 @@ export class ComponentStateEffects {
             ofType(ComponentStateActions.reloadComponentState),
             concatLatestFrom(() => [
                 
this.store.select(selectComponentType).pipe(isDefinedAndNotNull()),
-                
this.store.select(selectComponentId).pipe(isDefinedAndNotNull())
+                
this.store.select(selectComponentId).pipe(isDefinedAndNotNull()),
+                this.store.select(selectConnectorId)
             ]),
-            switchMap(([, componentType, componentId]) =>
-                from(
-                    this.componentStateService.getComponentState({ 
componentType, componentId }).pipe(
-                        map((response: any) =>
+            switchMap(([, componentType, componentId, connectorId]) => {
+                const stateRequest$ = connectorId
+                    ? 
this.componentStateService.getConnectorComponentState(connectorId, 
componentType, componentId)
+                    : this.componentStateService.getComponentState({ 
componentType, componentId });
+
+                return from(
+                    stateRequest$.pipe(
+                        map((response: ComponentStateEntity) =>
                             ComponentStateActions.reloadComponentStateSuccess({
                                 response: {
                                     componentState: response.componentState
@@ -168,8 +189,8 @@ export class ComponentStateEffects {
                             )
                         )
                     )
-                )
-            )
+                );
+            })
         )
     );
 
@@ -179,12 +200,12 @@ export class ComponentStateEffects {
             concatLatestFrom(() => [
                 
this.store.select(selectComponentType).pipe(isDefinedAndNotNull()),
                 
this.store.select(selectComponentId).pipe(isDefinedAndNotNull()),
-                
this.store.select(selectComponentState).pipe(isDefinedAndNotNull())
+                
this.store.select(selectComponentState).pipe(isDefinedAndNotNull()),
+                this.store.select(selectConnectorId)
             ]),
-            switchMap(([action, componentType, componentId, currentState]) => {
+            switchMap(([action, componentType, componentId, currentState, 
connectorId]) => {
                 const { keyToDelete, scope } = action.request;
 
-                // Create new state without the deleted key
                 const newState: ComponentState = { ...currentState };
 
                 if (scope === 'LOCAL' && newState.localState?.state) {
@@ -203,27 +224,38 @@ export class ComponentStateEffects {
                     componentState: newState
                 };
 
+                const clearRequest$ = connectorId
+                    ? 
this.componentStateService.clearConnectorComponentStateEntry(
+                          connectorId,
+                          componentType,
+                          componentId,
+                          componentStateEntity
+                      )
+                    : this.componentStateService.clearComponentStateEntry(
+                          componentType,
+                          componentId,
+                          componentStateEntity
+                      );
+
                 return from(
-                    this.componentStateService
-                        .clearComponentStateEntry(componentType, componentId, 
componentStateEntity)
-                        .pipe(
-                            map(() => 
ComponentStateActions.reloadComponentState()),
-                            catchError((errorResponse: HttpErrorResponse) =>
-                                of(
-                                    
ComponentStateActions.clearComponentStateFailure({
-                                        errorContext: {
-                                            errors: [
-                                                
this.errorHelper.getErrorString(
-                                                    errorResponse,
-                                                    `Failed to clear state 
entry: ${keyToDelete}.`
-                                                )
-                                            ],
-                                            context: 
ErrorContextKey.COMPONENT_STATE
-                                        }
-                                    })
-                                )
+                    clearRequest$.pipe(
+                        map(() => 
ComponentStateActions.reloadComponentState()),
+                        catchError((errorResponse: HttpErrorResponse) =>
+                            of(
+                                
ComponentStateActions.clearComponentStateFailure({
+                                    errorContext: {
+                                        errors: [
+                                            this.errorHelper.getErrorString(
+                                                errorResponse,
+                                                `Failed to clear state entry: 
${keyToDelete}.`
+                                            )
+                                        ],
+                                        context: 
ErrorContextKey.COMPONENT_STATE
+                                    }
+                                })
                             )
                         )
+                    )
                 );
             })
         )
diff --git 
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/state/component-state/component-state.reducer.ts
 
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/state/component-state/component-state.reducer.ts
index 18e9f45c43a..f9bc83631aa 100644
--- 
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/state/component-state/component-state.reducer.ts
+++ 
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/state/component-state/component-state.reducer.ts
@@ -31,6 +31,7 @@ export const initialState: ComponentStateState = {
     componentName: null,
     componentType: null,
     componentId: null,
+    connectorId: null,
     componentState: null,
     canClear: null,
     clearing: false,
@@ -44,6 +45,7 @@ export const componentStateReducer = createReducer(
         componentName: request.componentName,
         componentType: request.componentType,
         componentId: request.componentId,
+        connectorId: request.connectorId ?? null,
         canClear: request.canClear,
         status: 'loading' as const
     })),
diff --git 
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/state/component-state/component-state.selectors.ts
 
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/state/component-state/component-state.selectors.ts
index e2c8853a0b7..9c4d332c5ec 100644
--- 
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/state/component-state/component-state.selectors.ts
+++ 
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/state/component-state/component-state.selectors.ts
@@ -44,6 +44,11 @@ export const selectCanClear = 
createSelector(selectComponentStateState, (state:
 
 export const selectClearing = createSelector(selectComponentStateState, 
(state: ComponentStateState) => state.clearing);
 
+export const selectConnectorId = createSelector(
+    selectComponentStateState,
+    (state: ComponentStateState) => state.connectorId
+);
+
 export const selectDropStateKeySupported = createSelector(
     selectComponentState,
     (componentState: ComponentState | null) => 
componentState?.dropStateKeySupported ?? false
diff --git 
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/state/component-state/index.ts
 
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/state/component-state/index.ts
index b40a0b9d312..cb0ef10ccf2 100644
--- 
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/state/component-state/index.ts
+++ 
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/state/component-state/index.ts
@@ -24,6 +24,7 @@ export interface ComponentStateRequest {
     componentType: ComponentType;
     componentId: string;
     canClear: boolean;
+    connectorId?: string;
 }
 
 export interface LoadComponentStateRequest {
@@ -80,6 +81,7 @@ export interface ComponentStateState {
     componentName: string | null;
     componentType: ComponentType | null;
     componentId: string | null;
+    connectorId: string | null;
     componentState: ComponentState | null;
     canClear: boolean | null;
     clearing: boolean;
diff --git 
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/context-menu/context-menu.component.html
 
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/context-menu/context-menu.component.html
index bc87d1e023b..dd9470a3490 100644
--- 
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/context-menu/context-menu.component.html
+++ 
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/context-menu/context-menu.component.html
@@ -21,7 +21,7 @@
         [class.show-focused]="showFocused$ | async"
         (keydown)="keydown($event)"
         cdkMenu>
-        @for (item of getMenuItems(menuId); track item) {
+        @for (item of getMenuItems(menuId); track item.text || $index) {
             @if (item.isSeparator) {
                 <hr />
             } @else {

Reply via email to