This is an automated email from the ASF dual-hosted git repository.
scottyaslan 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 143ba4a634d NIFI-16240 - Navigate to the referenced parameter from
property table Go to Parameter (#11580)
143ba4a634d is described below
commit 143ba4a634d9c7dec56890491e9e55e6b5755e58
Author: Rob Fellows <[email protected]>
AuthorDate: Wed Aug 26 15:22:40 2026 -0400
NIFI-16240 - Navigate to the referenced parameter from property table Go to
Parameter (#11580)
* NIFI-16240 - Navigate to the referenced parameter from property table Go
to Parameter
Extract the referenced parameter name from a property value and pass it
through
router state so the Parameter Context edit dialog can highlight and scroll
to
that row. Carry the same state through save-then-navigate so the highlight
is
preserved when the edit dialog is dirty.
* address review feedback
---
.../controller-services.effects.spec.ts | 184 ++++++++++++++++++-
.../controller-services.effects.ts | 41 ++++-
.../state/controller-services/index.ts | 4 +-
.../flow-designer/state/flow/flow.effects.spec.ts | 194 ++++++++++++++++++++-
.../pages/flow-designer/state/flow/flow.effects.ts | 35 +++-
.../apps/nifi/src/app/state/shared/index.ts | 3 +
.../edit-processor.component.spec.ts | 15 ++
.../edit-processor/edit-processor.component.ts | 8 +-
.../edit-controller-service.component.spec.ts | 23 +++
.../edit-controller-service.component.ts | 10 +-
.../property-table/property-table.component.ts | 12 +-
.../app/ui/common/utils/parameter.utils.spec.ts | 110 ++++++++++++
.../src/app/ui/common/utils/parameter.utils.ts | 47 +++++
13 files changed, 653 insertions(+), 33 deletions(-)
diff --git
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/state/controller-services/controller-services.effects.spec.ts
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/state/controller-services/controller-services.effects.spec.ts
index 747ce4e1d6c..99a2cbe0614 100644
---
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/state/controller-services/controller-services.effects.spec.ts
+++
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/state/controller-services/controller-services.effects.spec.ts
@@ -26,6 +26,7 @@ import * as ControllerServicesActions from
'./controller-services.actions';
import {
clearControllerServiceBulletins,
clearControllerServiceBulletinsSuccess,
+ configureControllerServiceSuccess,
createControllerService,
createControllerServiceSuccess,
deleteControllerService,
@@ -41,9 +42,10 @@ import { PropertyTableHelperService } from
'../../../../service/property-table-h
import { ParameterHelperService } from
'../../service/parameter-helper.service';
import { ExtensionTypesService } from
'../../../../service/extension-types.service';
import { Client } from '../../../../service/client.service';
-import { ComponentType, Storage } from '@nifi/shared';
+import { ComponentType, Storage, YesNoDialog } from '@nifi/shared';
+import { EventEmitter } from '@angular/core';
import { ParameterContextService } from
'../../../parameter-contexts/service/parameter-contexts.service';
-import { controllerServicesFeatureKey } from './index';
+import { ConfigureControllerServiceSuccess, controllerServicesFeatureKey }
from './index';
import { initialState } from './controller-services.reducer';
import { ClearBulletinsRequest, ClearBulletinsResponse } from
'../../../../state/shared';
import * as ErrorActions from '../../../../state/error/error.actions';
@@ -642,4 +644,182 @@ describe('ControllerServicesEffects', () => {
);
});
});
+
+ describe('openConfigureControllerServiceDialog$ goToParameter', () => {
+ const PARAMETER_CONTEXT_ID = 'ctx-1';
+ const SERVICE_ID = 'cs-1';
+ const EXPECTED_COMMANDS = ['/parameter-contexts',
PARAMETER_CONTEXT_ID, 'edit'];
+ const EXPECTED_BACK_NAVIGATION = {
+ route: ['/process-groups', 'root', 'controller-services',
SERVICE_ID, 'edit'],
+ routeBoundary: ['/parameter-contexts'],
+ context: 'Controller Service'
+ };
+
+ let editServiceInstance: any;
+ let saveChangesInstance: { yes: EventEmitter<any>; no:
EventEmitter<any> };
+
+ // Opens the edit dialog and returns the goToParameter callback the
effect assigned to it.
+ async function openDialogAndGetGoToParameter(dirty: boolean) {
+ const { effects } = await setup({
+ controllerServicesState: {
+ ...initialState,
+ parameterContext: {
+ id: PARAMETER_CONTEXT_ID,
+ permissions: { canRead: true, canWrite: true }
+ }
+ }
+ });
+
+ const router = TestBed.inject(Router);
+
+ editServiceInstance = {
+ verify: new EventEmitter<any>(),
+ editControllerService: new EventEmitter<any>(),
+ editControllerServiceForm: { dirty },
+ submitForm: vi.fn()
+ };
+ saveChangesInstance = { yes: new EventEmitter<any>(), no: new
EventEmitter<any>() };
+
+ const dialog = TestBed.inject(MatDialog);
+ vi.mocked(dialog.open).mockImplementation((component: any) => {
+ if (component === YesNoDialog) {
+ return {
+ componentInstance: saveChangesInstance,
+ afterClosed: () => of()
+ } as any;
+ }
+
+ return {
+ close: vi.fn(),
+ afterClosed: () => of(),
+ componentInstance: editServiceInstance
+ } as any;
+ });
+
+
vi.mocked(TestBed.inject(PropertyTableHelperService).getComponentHistory).mockReturnValue(
+ of({ componentHistory: {} }) as any
+ );
+
vi.mocked(TestBed.inject(ParameterContextService).getParameterContext).mockReturnValue(
+ of({ id: PARAMETER_CONTEXT_ID, component: { name: 'ctx' } })
as any
+ );
+
+ effects.openConfigureControllerServiceDialog$.subscribe();
+ action$.next(
+
ControllerServicesActions.openConfigureControllerServiceDialog({
+ request: {
+ id: SERVICE_ID,
+ controllerService: { id: SERVICE_ID, uri:
`https://localhost:4200/${SERVICE_ID}` }
+ } as any
+ })
+ );
+
+ return { goToParameter: editServiceInstance.goToParameter, router
};
+ }
+
+ it('navigates to the parameter context highlighting the referenced
parameter when the form is pristine', async () => {
+ const { goToParameter, router } = await
openDialogAndGetGoToParameter(false);
+
+ goToParameter('#{my-param}');
+
+ expect(router.navigate).toHaveBeenCalledWith(EXPECTED_COMMANDS, {
+ state: {
+ backNavigation: EXPECTED_BACK_NAVIGATION,
+ highlightedParameterName: 'my-param'
+ }
+ });
+ });
+
+ it('navigates without a highlight when the property value contains no
parameter reference', async () => {
+ const { goToParameter, router } = await
openDialogAndGetGoToParameter(false);
+
+ goToParameter('a literal value');
+
+ const state = vi.mocked(router.navigate).mock.calls[0][1]?.state
?? {};
+ expect(Object.keys(state)).toEqual(['backNavigation']);
+ });
+
+ it('submits the form with the highlight when the form is dirty and
changes are saved', async () => {
+ const { goToParameter, router } = await
openDialogAndGetGoToParameter(true);
+
+ goToParameter('#{my-param}');
+ saveChangesInstance.yes.emit();
+
+
expect(editServiceInstance.submitForm).toHaveBeenCalledWith(EXPECTED_COMMANDS,
['/parameter-contexts'], {
+ highlightedParameterName: 'my-param'
+ });
+ expect(router.navigate).not.toHaveBeenCalled();
+ });
+
+ it('navigates with the highlight when the form is dirty and changes
are discarded', async () => {
+ const { goToParameter, router } = await
openDialogAndGetGoToParameter(true);
+
+ goToParameter('#{my-param}');
+ saveChangesInstance.no.emit();
+
+ expect(editServiceInstance.submitForm).not.toHaveBeenCalled();
+ expect(router.navigate).toHaveBeenCalledWith(EXPECTED_COMMANDS, {
+ state: {
+ backNavigation: EXPECTED_BACK_NAVIGATION,
+ highlightedParameterName: 'my-param'
+ }
+ });
+ });
+ });
+
+ describe('configureControllerServiceSuccess$', () => {
+ it('should include postUpdateNavigationState in router navigate state
when postUpdateNavigationState is provided', async () => {
+ const { effects } = await setup();
+ const router = TestBed.inject(Router);
+
+ const response: ConfigureControllerServiceSuccess = {
+ id: 'cs-1',
+ controllerService: {} as any,
+ postUpdateNavigation: ['/parameter-contexts', 'ctx-1', 'edit'],
+ postUpdateNavigationBoundary: ['/parameter-contexts'],
+ postUpdateNavigationState: { highlightedParameterName:
'my-param' }
+ };
+
+ effects.configureControllerServiceSuccess$.subscribe();
+ action$.next(configureControllerServiceSuccess({ response }));
+
+
expect(router.navigate).toHaveBeenCalledWith(['/parameter-contexts', 'ctx-1',
'edit'], {
+ state: {
+ backNavigation: {
+ route: ['/process-groups', 'root',
'controller-services', 'cs-1', 'edit'],
+ routeBoundary: ['/parameter-contexts'],
+ context: 'Controller Service'
+ },
+ highlightedParameterName: 'my-param'
+ }
+ });
+ });
+
+ it('should omit postUpdateNavigationState keys from router navigate
state when postUpdateNavigationState is absent', async () => {
+ const { effects } = await setup();
+ const router = TestBed.inject(Router);
+
+ const response: ConfigureControllerServiceSuccess = {
+ id: 'cs-2',
+ controllerService: {} as any,
+ postUpdateNavigation: ['/parameter-contexts', 'ctx-2', 'edit'],
+ postUpdateNavigationBoundary: ['/parameter-contexts']
+ };
+
+ effects.configureControllerServiceSuccess$.subscribe();
+ action$.next(configureControllerServiceSuccess({ response }));
+
+ expect(router.navigate).toHaveBeenCalledTimes(1);
+
expect(vi.mocked(router.navigate).mock.calls[0][0]).toEqual(['/parameter-contexts',
'ctx-2', 'edit']);
+
+ // Object.keys rather than toHaveBeenCalledWith: argument matching
treats an absent key and an
+ // explicit undefined as equal, so it cannot distinguish omission
from highlightedParameterName: undefined.
+ const state = vi.mocked(router.navigate).mock.calls[0][1]?.state
?? {};
+ expect(Object.keys(state)).toEqual(['backNavigation']);
+ expect(state['backNavigation']).toEqual({
+ route: ['/process-groups', 'root', 'controller-services',
'cs-2', 'edit'],
+ routeBoundary: ['/parameter-contexts'],
+ context: 'Controller Service'
+ });
+ });
+ });
});
diff --git
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/state/controller-services/controller-services.effects.ts
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/state/controller-services/controller-services.effects.ts
index f0fc80574cb..ad185a28342 100644
---
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/state/controller-services/controller-services.effects.ts
+++
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/state/controller-services/controller-services.effects.ts
@@ -35,9 +35,11 @@ import {
ControllerServiceReferencingComponent,
EditControllerServiceDialogRequest,
OpenChangeComponentVersionDialogRequest,
+ PostUpdateNavigationState,
UpdateControllerServiceRequest
} from '../../../../state/shared';
import { Router } from '@angular/router';
+import { extractParameterName } from
'../../../../ui/common/utils/parameter.utils';
import {
selectCurrentProcessGroupId,
selectLoadedTimestamp,
@@ -406,7 +408,12 @@ export class ControllerServicesEffects {
selectPropertyVerificationStatus
);
- const goTo = (commands: string[], destination: string,
commandBoundary?: string[]): void => {
+ const goTo = (
+ commands: string[],
+ destination: string,
+ commandBoundary?: string[],
+ navigationState?: PostUpdateNavigationState
+ ): void => {
if
(editDialogReference.componentInstance.editControllerServiceForm.dirty) {
const saveChangesDialogReference =
this.dialog.open(YesNoDialog, {
...SMALL_DIALOG,
@@ -417,7 +424,11 @@ export class ControllerServicesEffects {
});
saveChangesDialogReference.componentInstance.yes.pipe(take(1)).subscribe(() => {
-
editDialogReference.componentInstance.submitForm(commands, commandBoundary);
+
editDialogReference.componentInstance.submitForm(
+ commands,
+ commandBoundary,
+ navigationState
+ );
});
saveChangesDialogReference.componentInstance.no.pipe(take(1)).subscribe(() => {
@@ -434,7 +445,8 @@ export class ControllerServicesEffects {
],
routeBoundary: commandBoundary,
context: 'Controller Service'
- } as BackNavigation
+ } as BackNavigation,
+ ...navigationState
}
});
} else {
@@ -455,7 +467,8 @@ export class ControllerServicesEffects {
],
routeBoundary: commandBoundary,
context: 'Controller Service'
- } as BackNavigation
+ } as BackNavigation,
+ ...navigationState
}
});
} else {
@@ -473,12 +486,18 @@ export class ControllerServicesEffects {
if (parameterContext != null) {
editDialogReference.componentInstance.parameterContext
= parameterContext;
- editDialogReference.componentInstance.goToParameter =
() => {
+ editDialogReference.componentInstance.goToParameter =
(parameterValue: string) => {
this.storage.setItem<number>(NiFiCommon.EDIT_PARAMETER_CONTEXT_DIALOG_ID, 1);
+ const parameterName =
extractParameterName(parameterValue);
const commandBoundary: string[] =
['/parameter-contexts'];
const commands: string[] = [...commandBoundary,
parameterContext.id, 'edit'];
- goTo(commands, 'Parameter', commandBoundary);
+ goTo(
+ commands,
+ 'Parameter',
+ commandBoundary,
+ parameterName ? { highlightedParameterName:
parameterName } : undefined
+ );
};
editDialogReference.componentInstance.convertToParameter =
@@ -533,7 +552,9 @@ export class ControllerServicesEffects {
payload:
updateControllerServiceRequest.payload,
postUpdateNavigation:
updateControllerServiceRequest.postUpdateNavigation,
postUpdateNavigationBoundary:
-
updateControllerServiceRequest.postUpdateNavigationBoundary
+
updateControllerServiceRequest.postUpdateNavigationBoundary,
+ postUpdateNavigationState:
+
updateControllerServiceRequest.postUpdateNavigationState
}
})
);
@@ -570,7 +591,8 @@ export class ControllerServicesEffects {
id: request.id,
controllerService: response,
postUpdateNavigation:
request.postUpdateNavigation,
- postUpdateNavigationBoundary:
request.postUpdateNavigationBoundary
+ postUpdateNavigationBoundary:
request.postUpdateNavigationBoundary,
+ postUpdateNavigationState:
request.postUpdateNavigationState
}
})
),
@@ -625,7 +647,8 @@ export class ControllerServicesEffects {
],
routeBoundary:
response.postUpdateNavigationBoundary,
context: 'Controller Service'
- } as BackNavigation
+ } as BackNavigation,
+ ...response.postUpdateNavigationState
}
});
} else {
diff --git
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/state/controller-services/index.ts
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/state/controller-services/index.ts
index 940ecc4834c..c5cfb5c238d 100644
---
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/state/controller-services/index.ts
+++
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/state/controller-services/index.ts
@@ -15,7 +15,7 @@
* limitations under the License.
*/
-import { BreadcrumbEntity, ControllerServiceEntity } from
'../../../../state/shared';
+import { BreadcrumbEntity, ControllerServiceEntity, PostUpdateNavigationState
} from '../../../../state/shared';
import { ParameterContextReferenceEntity } from '@nifi/shared';
export const controllerServicesFeatureKey = 'controllerServiceListing';
@@ -42,6 +42,7 @@ export interface ConfigureControllerServiceRequest {
payload: any;
postUpdateNavigation?: string[];
postUpdateNavigationBoundary?: string[];
+ postUpdateNavigationState?: PostUpdateNavigationState;
}
export interface ConfigureControllerServiceSuccess {
@@ -49,6 +50,7 @@ export interface ConfigureControllerServiceSuccess {
controllerService: ControllerServiceEntity;
postUpdateNavigation?: string[];
postUpdateNavigationBoundary?: string[];
+ postUpdateNavigationState?: PostUpdateNavigationState;
}
export interface DeleteControllerServiceRequest {
diff --git
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/state/flow/flow.effects.spec.ts
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/state/flow/flow.effects.spec.ts
index e2f21e170fe..f8ffd720ac8 100644
---
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/state/flow/flow.effects.spec.ts
+++
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/state/flow/flow.effects.spec.ts
@@ -43,7 +43,8 @@ import {
EnableComponentRequest,
StartComponentRequest,
StopComponentRequest,
- UpdateProcessorRequest
+ UpdateProcessorRequest,
+ UpdateProcessorResponse
} from '../../../../state/shared';
import { selectCurrentUser } from
'../../../../state/current-user/current-user.selectors';
import * as fromUser from
'../../../../state/current-user/current-user.reducer';
@@ -63,7 +64,7 @@ import { CopyPasteService } from
'../../service/copy-paste.service';
import { CanvasView } from '../../service/canvas-view.service';
import { BirdseyeView } from '../../service/birdseye-view.service';
import { selectDisconnectionAcknowledged } from
'../../../../state/cluster-summary/cluster-summary.selectors';
-import { ComponentType, ComponentTypeNamePipe } from '@nifi/shared';
+import { ComponentType, ComponentTypeNamePipe, YesNoDialog } from
'@nifi/shared';
import { ParameterContextService } from
'../../../parameter-contexts/service/parameter-contexts.service';
import { HttpErrorResponse } from '@angular/common/http';
import { provideRouter, Router } from '@angular/router';
@@ -853,7 +854,8 @@ describe('FlowEffects', () => {
{
provide: ParameterHelperService,
useValue: {
- getParameterContext: vi.fn()
+ getParameterContext: vi.fn(),
+ convertToParameter: vi.fn()
}
},
{
@@ -1546,4 +1548,190 @@ describe('FlowEffects', () => {
);
});
});
+
+ describe('openEditProcessorDialog$ goToParameter', () => {
+ const PARAMETER_CONTEXT_ID = 'ctx-1';
+ const PROCESSOR_ID = 'd90ac264-018b-1000-1827-a86c8156fd9e';
+ const EXPECTED_COMMANDS = ['/parameter-contexts',
PARAMETER_CONTEXT_ID, 'edit'];
+ const EXPECTED_BACK_NAVIGATION = {
+ route: ['/process-groups', 'pg-123', ComponentType.Processor,
PROCESSOR_ID, 'edit'],
+ routeBoundary: ['/parameter-contexts'],
+ context: 'Processor'
+ };
+
+ let router: Router;
+ let editProcessorInstance: any;
+ let saveChangesInstance: { yes: EventEmitter<any>; no:
EventEmitter<any> };
+
+ // Opens the edit dialog and returns the goToParameter callback the
effect assigned to it.
+ const openDialogAndGetGoToParameter = (dirty: boolean):
((parameterValue: string) => void) => {
+ editProcessorInstance = {
+ ...MockComponent(EditProcessor),
+ verify,
+ editProcessor,
+ startComponentRequest: startRequest,
+ stopComponentRequest: stopRequest,
+ disableComponentRequest: disableRequest,
+ enableComponentRequest: enableRequest,
+ editProcessorForm: { dirty },
+ submitForm: vi.fn()
+ };
+ saveChangesInstance = { yes: new EventEmitter<any>(), no: new
EventEmitter<any>() };
+
+ vi.spyOn(dialog, 'open').mockImplementation((component: any) => {
+ if (component === YesNoDialog) {
+ return {
+ componentInstance: saveChangesInstance,
+ afterClosed: () => of()
+ } as unknown as MatDialogRef<any>;
+ }
+
+ return {
+ close: vi.fn(),
+ afterClosed: () => of(),
+ componentInstance: editProcessorInstance
+ } as unknown as MatDialogRef<EditProcessor>;
+ });
+
+ effects.openEditProcessorDialog$.subscribe();
+ action$.next(
+ FlowActions.openEditProcessorDialog({
+ request: {
+ type: mockData.type,
+ uri: mockData.uri,
+ entity: mockData.entity
+ } as any
+ })
+ );
+
+ return editProcessorInstance.goToParameter;
+ };
+
+ beforeEach(() => {
+ router = TestBed.inject(Router);
+ vi.spyOn(router, 'navigate').mockImplementation(() =>
Promise.resolve(true));
+
+ store.overrideSelector(selectCurrentProcessGroupId, 'pg-123');
+
store.overrideSelector(flowSelectors.selectCurrentParameterContext, {
+ id: PARAMETER_CONTEXT_ID,
+ permissions: { canRead: true, canWrite: true }
+ } as any);
+ store.refreshState();
+
+ vi.spyOn(TestBed.inject(ParameterContextService),
'getParameterContext').mockReturnValue(
+ of({ id: PARAMETER_CONTEXT_ID, component: { name: 'ctx' } })
as any
+ );
+ });
+
+ it('navigates to the parameter context highlighting the referenced
parameter when the form is pristine', () => {
+ const goToParameter = openDialogAndGetGoToParameter(false);
+
+ goToParameter('#{my-param}');
+
+ expect(router.navigate).toHaveBeenCalledWith(EXPECTED_COMMANDS, {
+ state: {
+ backNavigation: EXPECTED_BACK_NAVIGATION,
+ highlightedParameterName: 'my-param'
+ }
+ });
+ });
+
+ it('navigates without a highlight when the property value contains no
parameter reference', () => {
+ const goToParameter = openDialogAndGetGoToParameter(false);
+
+ goToParameter('a literal value');
+
+ const state = vi.mocked(router.navigate).mock.calls[0][1]?.state
?? {};
+ expect(Object.keys(state)).toEqual(['backNavigation']);
+ });
+
+ it('submits the form with the highlight when the form is dirty and
changes are saved', () => {
+ const goToParameter = openDialogAndGetGoToParameter(true);
+
+ goToParameter('#{my-param}');
+ saveChangesInstance.yes.emit();
+
+
expect(editProcessorInstance.submitForm).toHaveBeenCalledWith(EXPECTED_COMMANDS,
['/parameter-contexts'], {
+ highlightedParameterName: 'my-param'
+ });
+ expect(router.navigate).not.toHaveBeenCalled();
+ });
+
+ it('navigates with the highlight when the form is dirty and changes
are discarded', () => {
+ const goToParameter = openDialogAndGetGoToParameter(true);
+
+ goToParameter('#{my-param}');
+ saveChangesInstance.no.emit();
+
+ expect(editProcessorInstance.submitForm).not.toHaveBeenCalled();
+ expect(router.navigate).toHaveBeenCalledWith(EXPECTED_COMMANDS, {
+ state: {
+ backNavigation: EXPECTED_BACK_NAVIGATION,
+ highlightedParameterName: 'my-param'
+ }
+ });
+ });
+ });
+
+ describe('updateProcessorSuccess$', () => {
+ let router: Router;
+
+ beforeEach(() => {
+ router = TestBed.inject(Router);
+ vi.spyOn(router, 'navigate').mockImplementation(() =>
Promise.resolve(true));
+ store.overrideSelector(selectCurrentProcessGroupId, 'pg-123');
+ store.refreshState();
+ });
+
+ it('should include postUpdateNavigationState in router navigate state
when postUpdateNavigationState is provided', () => {
+ const response: UpdateProcessorResponse = {
+ id: 'proc-1',
+ type: ComponentType.Processor,
+ postUpdateNavigation: ['/parameter-contexts', 'ctx-1', 'edit'],
+ postUpdateNavigationBoundary: ['/parameter-contexts'],
+ postUpdateNavigationState: { highlightedParameterName:
'my-param' },
+ response: {}
+ };
+
+ effects.updateProcessorSuccess$.subscribe();
+ action$.next(FlowActions.updateProcessorSuccess({ response }));
+
+
expect(router.navigate).toHaveBeenCalledWith(['/parameter-contexts', 'ctx-1',
'edit'], {
+ state: {
+ backNavigation: {
+ route: ['/process-groups', 'pg-123',
ComponentType.Processor, 'proc-1', 'edit'],
+ routeBoundary: ['/parameter-contexts'],
+ context: 'Processor'
+ },
+ highlightedParameterName: 'my-param'
+ }
+ });
+ });
+
+ it('should omit postUpdateNavigationState keys from router navigate
state when postUpdateNavigationState is absent', () => {
+ const response: UpdateProcessorResponse = {
+ id: 'proc-2',
+ type: ComponentType.Processor,
+ postUpdateNavigation: ['/parameter-contexts', 'ctx-2', 'edit'],
+ postUpdateNavigationBoundary: ['/parameter-contexts'],
+ response: {}
+ };
+
+ effects.updateProcessorSuccess$.subscribe();
+ action$.next(FlowActions.updateProcessorSuccess({ response }));
+
+ expect(router.navigate).toHaveBeenCalledTimes(1);
+
expect(vi.mocked(router.navigate).mock.calls[0][0]).toEqual(['/parameter-contexts',
'ctx-2', 'edit']);
+
+ // Object.keys rather than toHaveBeenCalledWith: argument matching
treats an absent key and an
+ // explicit undefined as equal, so it cannot distinguish omission
from highlightedParameterName: undefined.
+ const state = vi.mocked(router.navigate).mock.calls[0][1]?.state
?? {};
+ expect(Object.keys(state)).toEqual(['backNavigation']);
+ expect(state['backNavigation']).toEqual({
+ route: ['/process-groups', 'pg-123', ComponentType.Processor,
'proc-2', 'edit'],
+ routeBoundary: ['/parameter-contexts'],
+ context: 'Processor'
+ });
+ });
+ });
});
diff --git
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/state/flow/flow.effects.ts
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/state/flow/flow.effects.ts
index a66564ab5bc..5413002cecf 100644
---
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/state/flow/flow.effects.ts
+++
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/state/flow/flow.effects.ts
@@ -106,6 +106,7 @@ import {
EnableComponentRequest,
OpenChangeComponentVersionDialogRequest,
ParameterContextEntity,
+ PostUpdateNavigationState,
RegistryClientEntity,
StartComponentRequest,
StopComponentRequest,
@@ -180,6 +181,7 @@ import {
} from
'../../../../state/property-verification/property-verification.selectors';
import { VerifyPropertiesRequestContext } from
'../../../../state/property-verification';
import { BackNavigation } from '../../../../state/navigation';
+import { extractParameterName } from
'../../../../ui/common/utils/parameter.utils';
import { resetPollingFlowAnalysis } from
'../flow-analysis/flow-analysis.actions';
import { selectDocumentVisibilityState } from
'../../../../state/document-visibility/document-visibility.selectors';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
@@ -1564,7 +1566,12 @@ export class FlowEffects {
selectPropertyVerificationStatus
);
- const goTo = (commands: string[], commandBoundary:
string[], destination: string): void => {
+ const goTo = (
+ commands: string[],
+ commandBoundary: string[],
+ destination: string,
+ navigationState?: PostUpdateNavigationState
+ ): void => {
if
(editDialogReference.componentInstance.editProcessorForm.dirty) {
const saveChangesDialogReference =
this.dialog.open(YesNoDialog, {
...SMALL_DIALOG,
@@ -1575,7 +1582,11 @@ export class FlowEffects {
});
saveChangesDialogReference.componentInstance.yes.pipe(take(1)).subscribe(() => {
-
editDialogReference.componentInstance.submitForm(commands, commandBoundary);
+
editDialogReference.componentInstance.submitForm(
+ commands,
+ commandBoundary,
+ navigationState
+ );
});
saveChangesDialogReference.componentInstance.no.pipe(take(1)).subscribe(() => {
@@ -1591,7 +1602,8 @@ export class FlowEffects {
],
routeBoundary: commandBoundary,
context: 'Processor'
- } as BackNavigation
+ } as BackNavigation,
+ ...navigationState
}
});
});
@@ -1608,7 +1620,8 @@ export class FlowEffects {
],
routeBoundary: commandBoundary,
context: 'Processor'
- } as BackNavigation
+ } as BackNavigation,
+ ...navigationState
}
});
}
@@ -1616,12 +1629,18 @@ export class FlowEffects {
if (parameterContext != null) {
editDialogReference.componentInstance.parameterContext
= parameterContext;
- editDialogReference.componentInstance.goToParameter =
() => {
+ editDialogReference.componentInstance.goToParameter =
(parameterValue: string) => {
this.storage.setItem<number>(NiFiCommon.EDIT_PARAMETER_CONTEXT_DIALOG_ID, 1);
+ const parameterName =
extractParameterName(parameterValue);
const commandBoundary: string[] =
['/parameter-contexts'];
const commands: string[] = [...commandBoundary,
parameterContext.id, 'edit'];
- goTo(commands, commandBoundary, 'Parameter');
+ goTo(
+ commands,
+ commandBoundary,
+ 'Parameter',
+ parameterName ? { highlightedParameterName:
parameterName } : undefined
+ );
};
editDialogReference.componentInstance.convertToParameter =
@@ -2231,6 +2250,7 @@ export class FlowEffects {
type: request.type,
postUpdateNavigation: request.postUpdateNavigation,
postUpdateNavigationBoundary:
request.postUpdateNavigationBoundary,
+ postUpdateNavigationState:
request.postUpdateNavigationState,
response
};
return FlowActions.updateProcessorSuccess({ response:
updateProcessorResponse });
@@ -2270,7 +2290,8 @@ export class FlowEffects {
],
routeBoundary:
response.postUpdateNavigationBoundary,
context: 'Processor'
- } as BackNavigation
+ } as BackNavigation,
+ ...response.postUpdateNavigationState
}
});
} else {
diff --git
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/state/shared/index.ts
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/state/shared/index.ts
index e1fafc075bf..144bbec4ca7 100644
--- a/nifi-frontend/src/main/frontend/apps/nifi/src/app/state/shared/index.ts
+++ b/nifi-frontend/src/main/frontend/apps/nifi/src/app/state/shared/index.ts
@@ -216,11 +216,13 @@ export interface UpdateComponentFailure {
export interface UpdateProcessorRequest extends UpdateComponentRequest {
postUpdateNavigation?: string[];
postUpdateNavigationBoundary?: string[];
+ postUpdateNavigationState?: PostUpdateNavigationState;
}
export interface UpdateProcessorResponse extends UpdateComponentResponse {
postUpdateNavigation?: string[];
postUpdateNavigationBoundary?: string[];
+ postUpdateNavigationState?: PostUpdateNavigationState;
}
export interface UpdateConnectionRequest extends UpdateComponentRequest {
@@ -330,6 +332,7 @@ export interface UpdateControllerServiceRequest {
payload: any;
postUpdateNavigation?: string[];
postUpdateNavigationBoundary?: string[];
+ postUpdateNavigationState?: PostUpdateNavigationState;
}
export interface SetEnableControllerServiceDialogRequest {
diff --git
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/component-dialogs/edit-processor/edit-processor.component.spec.ts
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/component-dialogs/edit-processor/edit-processor.component.spec.ts
index f0a7c261c88..1a68a2546d1 100644
---
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/component-dialogs/edit-processor/edit-processor.component.spec.ts
+++
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/component-dialogs/edit-processor/edit-processor.component.spec.ts
@@ -763,6 +763,21 @@ describe('EditProcessor', () => {
'The number of tasks that should be concurrently scheduled for
this processor. Must be an integer greater than 0.'
);
});
+
+ it('should forward postUpdateNavigationState from submitForm to the edit
action', () => {
+ vi.spyOn(component.editProcessor, 'next');
+ component.submitForm(['/parameter-contexts', 'ctx-1', 'edit'],
['/parameter-contexts'], {
+ highlightedParameterName: 'my-param'
+ });
+
+ expect(component.editProcessor.next).toHaveBeenCalledWith(
+ expect.objectContaining({
+ postUpdateNavigation: ['/parameter-contexts', 'ctx-1', 'edit'],
+ postUpdateNavigationBoundary: ['/parameter-contexts'],
+ postUpdateNavigationState: { highlightedParameterName:
'my-param' }
+ })
+ );
+ });
});
describe('EditProcessor with TriggerSerially', () => {
diff --git
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/component-dialogs/edit-processor/edit-processor.component.ts
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/component-dialogs/edit-processor/edit-processor.component.ts
index 1a8e832521d..5870d6bac92 100644
---
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/component-dialogs/edit-processor/edit-processor.component.ts
+++
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/component-dialogs/edit-processor/edit-processor.component.ts
@@ -45,6 +45,7 @@ import {
InlineServiceCreationRequest,
InlineServiceCreationResponse,
ParameterContextEntity,
+ PostUpdateNavigationState,
Property,
StartComponentRequest,
StopComponentRequest,
@@ -416,7 +417,11 @@ export class EditProcessor extends TabbedDialog {
);
}
- submitForm(postUpdateNavigation?: string[], postUpdateNavigationBoundary?:
string[]) {
+ submitForm(
+ postUpdateNavigation?: string[],
+ postUpdateNavigationBoundary?: string[],
+ postUpdateNavigationState?: PostUpdateNavigationState
+ ) {
const relationshipConfiguration: RelationshipConfiguration =
this.editProcessorForm.get('relationshipConfiguration')?.value;
const autoTerminated: string[] =
relationshipConfiguration.relationships
@@ -481,6 +486,7 @@ export class EditProcessor extends TabbedDialog {
errorStrategy: 'banner',
postUpdateNavigation,
postUpdateNavigationBoundary,
+ postUpdateNavigationState,
payload
});
}
diff --git
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/controller-service/edit-controller-service/edit-controller-service.component.spec.ts
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/controller-service/edit-controller-service/edit-controller-service.component.spec.ts
index 87b19e3a094..20f08fc71bd 100644
---
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/controller-service/edit-controller-service/edit-controller-service.component.spec.ts
+++
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/controller-service/edit-controller-service/edit-controller-service.component.spec.ts
@@ -621,6 +621,29 @@ describe('EditControllerService', () => {
});
});
+ it('should forward postUpdateNavigationState from submitForm to the edit
action', () => {
+ const mockFormData = {
+ name: 'service-name',
+ bulletinLevel: 'DEBUG',
+ comments: 'service-comments',
+ properties: []
+ };
+
+ vi.spyOn(component.editControllerService, 'next');
+ component.editControllerServiceForm.setValue(mockFormData);
+ component.submitForm(['/parameter-contexts', 'ctx-1', 'edit'],
['/parameter-contexts'], {
+ highlightedParameterName: 'my-param'
+ });
+
+ expect(component.editControllerService.next).toHaveBeenCalledWith(
+ expect.objectContaining({
+ postUpdateNavigation: ['/parameter-contexts', 'ctx-1', 'edit'],
+ postUpdateNavigationBoundary: ['/parameter-contexts'],
+ postUpdateNavigationState: { highlightedParameterName:
'my-param' }
+ })
+ );
+ });
+
describe('readonly derivation', () => {
function buildRequest(overrides: {
readonly?: boolean;
diff --git
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/controller-service/edit-controller-service/edit-controller-service.component.ts
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/controller-service/edit-controller-service/edit-controller-service.component.ts
index 643331b2417..490b11db023 100644
---
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/controller-service/edit-controller-service/edit-controller-service.component.ts
+++
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/controller-service/edit-controller-service/edit-controller-service.component.ts
@@ -27,6 +27,7 @@ import {
InlineServiceCreationRequest,
InlineServiceCreationResponse,
ParameterContextEntity,
+ PostUpdateNavigationState,
Property,
UpdateControllerServiceRequest
} from '../../../../state/shared';
@@ -179,7 +180,11 @@ export class EditControllerService extends TabbedDialog {
return this.nifiCommon.formatBundle(entity.component.bundle);
}
- submitForm(postUpdateNavigation?: string[], postUpdateNavigationBoundary?:
string[]) {
+ submitForm(
+ postUpdateNavigation?: string[],
+ postUpdateNavigationBoundary?: string[],
+ postUpdateNavigationState?: PostUpdateNavigationState
+ ) {
const payload: any = {
revision: this.client.getRevision(this.request.controllerService),
disconnectedNodeAcknowledged:
this.clusterConnectionService.isDisconnectionAcknowledged(),
@@ -203,7 +208,8 @@ export class EditControllerService extends TabbedDialog {
this.editControllerService.next({
payload,
postUpdateNavigation,
- postUpdateNavigationBoundary
+ postUpdateNavigationBoundary,
+ postUpdateNavigationState
});
}
diff --git
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/property-table/property-table.component.ts
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/property-table/property-table.component.ts
index 4abc9dcc831..bb5f03c3950 100644
---
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/property-table/property-table.component.ts
+++
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/property-table/property-table.component.ts
@@ -62,6 +62,7 @@ import { takeUntilDestroyed } from
'@angular/core/rxjs-interop';
import { MatMenu, MatMenuItem, MatMenuTrigger } from '@angular/material/menu';
import { PropertyItem } from './property-item';
import { PropertyValueTip } from
'../tooltips/property-value-tip/property-value-tip.component';
+import { PARAM_REF_REGEX } from '../utils/parameter.utils';
@Component({
selector: 'property-table',
@@ -107,8 +108,6 @@ export class PropertyTable implements AfterViewInit,
ControlValueAccessor {
@Input() propertyHistory: ComponentHistory | undefined;
@Input() supportsParameters = true;
- private static readonly PARAM_REF_REGEX: RegExp = /#{(['"]?)[a-zA-Z0-9-_.
]+\1}/;
-
private destroyRef = inject(DestroyRef);
protected readonly NfEditor = NfEditor;
@@ -229,7 +228,7 @@ export class PropertyTable implements AfterViewInit,
ControlValueAccessor {
let dependentValue = dependentItem.value;
if (dependentValue != null) {
// check if the dependent value is a parameter reference
- if (PropertyTable.PARAM_REF_REGEX.test(dependentValue)) {
+ if (PARAM_REF_REGEX.test(dependentValue)) {
// the dependent value contains parameter reference, if the
user can view
// the parameter context resolve the parameter value to see if
it
// satisfies the dependent values
@@ -517,11 +516,8 @@ export class PropertyTable implements AfterViewInit,
ControlValueAccessor {
}
canGoToParameter(item: PropertyItem): boolean {
- // TODO - currently parameter context route does not support navigating
- // directly to a specific parameter so the parameter context link
- // is not item specific.
if (this.parameterContext && this.goToParameter && item.value) {
- return this.parameterContext.permissions.canRead &&
PropertyTable.PARAM_REF_REGEX.test(item.value);
+ return this.parameterContext.permissions.canRead &&
PARAM_REF_REGEX.test(item.value);
}
return false;
@@ -543,7 +539,7 @@ export class PropertyTable implements AfterViewInit,
ControlValueAccessor {
let propertyReferencesParameter = false;
if (canUpdateParameterContext && item.value) {
- propertyReferencesParameter =
PropertyTable.PARAM_REF_REGEX.test(item.value);
+ propertyReferencesParameter = PARAM_REF_REGEX.test(item.value);
}
return canUpdateParameterContext && !propertyReferencesParameter;
diff --git
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/utils/parameter.utils.spec.ts
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/utils/parameter.utils.spec.ts
new file mode 100644
index 00000000000..0196acfe5c1
--- /dev/null
+++
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/utils/parameter.utils.spec.ts
@@ -0,0 +1,110 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { extractParameterName, PARAM_REF_REGEX } from './parameter.utils';
+
+describe('extractParameterName', () => {
+ describe('valid unquoted references', () => {
+ it('extracts a simple name', () => {
+ expect(extractParameterName('#{my-param}')).toBe('my-param');
+ });
+
+ it('extracts a name with underscores and dots', () => {
+ expect(extractParameterName('#{my_param.v2}')).toBe('my_param.v2');
+ });
+
+ it('extracts a name with spaces', () => {
+ expect(extractParameterName('#{param with spaces}')).toBe('param
with spaces');
+ });
+
+ it('trims surrounding whitespace inside an unquoted reference', () => {
+ expect(extractParameterName('#{ my-param }')).toBe('my-param');
+ });
+
+ it('extracts a single-character name', () => {
+ expect(extractParameterName('#{x}')).toBe('x');
+ });
+ });
+
+ describe('valid single-quoted references', () => {
+ it('extracts a name wrapped in single quotes', () => {
+ expect(extractParameterName("#{'my-param'}")).toBe('my-param');
+ });
+
+ it('extracts a name with spaces wrapped in single quotes', () => {
+ expect(extractParameterName("#{'param with spaces'}")).toBe('param
with spaces');
+ });
+ });
+
+ describe('valid double-quoted references', () => {
+ it('extracts a name wrapped in double quotes', () => {
+ expect(extractParameterName('#{"my-param"}')).toBe('my-param');
+ });
+
+ it('extracts a name with spaces wrapped in double quotes', () => {
+ expect(extractParameterName('#{"param with spaces"}')).toBe('param
with spaces');
+ });
+ });
+
+ describe('non-matching values', () => {
+ it('returns undefined for a plain string', () => {
+ expect(extractParameterName('plain-value')).toBeUndefined();
+ });
+
+ it('returns undefined for an empty string', () => {
+ expect(extractParameterName('')).toBeUndefined();
+ });
+
+ it('returns undefined for #{} with no name', () => {
+ expect(extractParameterName('#{}')).toBeUndefined();
+ });
+
+ it('returns undefined for #{ } with only whitespace', () => {
+ expect(extractParameterName('#{ }')).toBeUndefined();
+ });
+
+ it('returns undefined when quotes are mismatched', () => {
+ expect(extractParameterName('#{\'mismatched"}')).toBeUndefined();
+ });
+ });
+
+ describe('references embedded in surrounding content', () => {
+ it('returns the parameter name when the reference has trailing
content', () => {
+ expect(extractParameterName('#{param} extra')).toBe('param');
+ });
+
+ it('returns the parameter name when the reference has leading
content', () => {
+ expect(extractParameterName('prefix #{param}')).toBe('param');
+ });
+
+ it('returns the first parameter name when the value contains multiple
references', () => {
+ expect(extractParameterName('#{p1}-#{p2}')).toBe('p1');
+ });
+
+ it('skips an invalid reference and extracts the first valid one', ()
=> {
+ expect(extractParameterName('#{bad:name}
#{kafka.brokers}')).toBe('kafka.brokers');
+ });
+ });
+});
+
+describe('PARAM_REF_REGEX', () => {
+ it('matches the same first valid reference that extractParameterName
returns', () => {
+ const value = '#{bad:name} #{kafka.brokers}';
+ expect(PARAM_REF_REGEX.test(value)).toBe(true);
+ expect(extractParameterName(value)).toBe('kafka.brokers');
+ });
+});
diff --git
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/utils/parameter.utils.ts
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/utils/parameter.utils.ts
new file mode 100644
index 00000000000..78adba137bb
--- /dev/null
+++
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/utils/parameter.utils.ts
@@ -0,0 +1,47 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+/**
+ * Matches a NiFi parameter reference anywhere in a property value.
+ *
+ * Supported forms:
+ * #{name}
+ * #{'name'}
+ * #{"name"}
+ *
+ * The name charset matches the historical Property Table gate: letters,
digits,
+ * hyphen, underscore, dot, and space. Group 2 captures the name.
+ */
+export const PARAM_REF_REGEX = /#{(['"]?)([a-zA-Z0-9-_. ]+)\1}/;
+
+/**
+ * Extracts the first parameter name from a NiFi parameter reference
expression.
+ *
+ * Returns the trimmed name from the first matching `#{…}` reference in the
value,
+ * or `undefined` when no reference is present or the captured name is empty
+ * after trimming (`#{}`, `#{ }`).
+ *
+ * When the value embeds a reference among other content (`prefix #{param}
suffix`)
+ * or contains multiple references (`#{p1} and #{p2}`), the name from the first
+ * matching reference is returned. A future enhancement could surface a
sub-menu
+ * for all referenced parameters (analogous to the `PropertyValueTip` tooltip),
+ * allowing the user to choose which one to navigate to.
+ */
+export function extractParameterName(value: string): string | undefined {
+ const match = PARAM_REF_REGEX.exec(value);
+ return match?.[2]?.trim() || undefined;
+}