You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@nifi.apache.org by sc...@apache.org on 2026/08/26 19:22:48 UTC

(nifi) branch main updated: NIFI-16240 - Navigate to the referenced parameter from property table Go to Parameter (#11580)

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 <ro...@gmail.com>
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;
+}