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 {