This is an automated email from the ASF dual-hosted git repository.

rfellows pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/nifi.git


The following commit(s) were added to refs/heads/main by this push:
     new 39bce0e8717 NIFI-15824: Introducing the back/search header and 
breadcrumbs footer to the connector canvas (#11142)
39bce0e8717 is described below

commit 39bce0e871766fb01d9583484dbbc5be886e04a3
Author: Matt Gilman <[email protected]>
AuthorDate: Thu Apr 16 14:14:45 2026 -0400

    NIFI-15824: Introducing the back/search header and breadcrumbs footer to 
the connector canvas (#11142)
    
    * NIFI-15824: Introducing the back/search header and breadcrumbs footer to 
the connector canvas.
    
    * NIFI-15824: Code clean up.
    
    * NIFI-15824: Addressing review feedback.
    
    * NIFI-15824: Aligning casing in the canvas header/footer.
    
    This closes #11142
---
 .../pages/connectors/service/connector.service.ts  |   8 +
 .../connector-canvas.component.html                |   9 +
 .../connector-canvas.component.spec.ts             | 158 ++++++++-
 .../connector-canvas/connector-canvas.component.ts |  41 ++-
 .../connector-canvas/footer/footer.component.html  |  25 ++
 .../connector-canvas/footer/footer.component.scss} |  10 -
 .../footer/footer.component.spec.ts                | 170 +++++++++
 .../connector-canvas}/footer/footer.component.ts   |  22 +-
 .../connector-canvas-header-bar.component.html     |  46 +++
 .../connector-canvas-header-bar.component.scss}    |  10 +-
 .../connector-canvas-header-bar.component.spec.ts  | 205 +++++++++++
 .../connector-canvas-header-bar.component.ts       |  59 ++++
 .../pages/flow-designer/service/search.service.ts  |  34 +-
 .../ui/canvas/footer/footer.component.ts           |   2 +-
 .../ui/canvas/header/search/search.component.ts    |   4 +-
 .../controller-services.component.spec.ts          |   2 +-
 .../controller-services.module.ts                  |   2 +-
 .../manage-remote-ports.module.ts                  |   2 +-
 .../apps/nifi/src/app/state/shared/index.ts        |  33 ++
 .../common/breadcrumbs/breadcrumbs.component.html  |   7 +-
 .../common/breadcrumbs/breadcrumbs.component.scss  |   0
 .../breadcrumbs/breadcrumbs.component.spec.ts      |   0
 .../ui/common/breadcrumbs/breadcrumbs.component.ts |  22 +-
 .../_canvas-header-search.component-theme.scss}    |  24 +-
 .../canvas-header-search.component.html            | 230 ++++++++++++
 .../canvas-header-search.component.scss}           |  14 +-
 .../canvas-header-search.component.spec.ts         | 388 +++++++++++++++++++++
 .../canvas-header-search.component.ts              | 184 ++++++++++
 28 files changed, 1620 insertions(+), 91 deletions(-)

diff --git 
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/service/connector.service.ts
 
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/service/connector.service.ts
index 62bac03ca31..5ec83958c43 100644
--- 
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/service/connector.service.ts
+++ 
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/service/connector.service.ts
@@ -22,6 +22,7 @@ import { Client } from '../../../service/client.service';
 import { ClusterConnectionService } from 
'../../../service/cluster-connection.service';
 import { ConnectorsResponse, CreateConnectorRequest } from '../state';
 import { ConnectorEntity } from '@nifi/shared';
+import { SearchResultsEntity } from '../../../state/shared';
 import { DropRequestEntity } from '../../flow-designer/state/queue';
 
 @Injectable({ providedIn: 'root' })
@@ -116,6 +117,13 @@ export class ConnectorService {
         );
     }
 
+    searchConnector(connectorId: string, query: string): 
Observable<SearchResultsEntity> {
+        return this.httpClient.get<SearchResultsEntity>(
+            `${ConnectorService.API}/connectors/${connectorId}/search-results`,
+            { params: { q: query } }
+        );
+    }
+
     // 
========================================================================================
     // Purge Methods
     // 
========================================================================================
diff --git 
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/ui/connector-canvas/connector-canvas.component.html
 
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/ui/connector-canvas/connector-canvas.component.html
index 0805c6f72ed..0abbee93469 100644
--- 
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/ui/connector-canvas/connector-canvas.component.html
+++ 
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/ui/connector-canvas/connector-canvas.component.html
@@ -18,6 +18,14 @@
     <header class="nifi-header">
         <navigation></navigation>
     </header>
+    <connector-canvas-header-bar
+        [connectorId]="currentConnectorId"
+        [selectedComponentId]="selectedComponentIds.length === 1 ? 
selectedComponentIds[0] : null"
+        [graphControlsOpen]="graphControlsOpen"
+        (backToConnectors)="returnToConnectorListing()"
+        (goToComponent)="onSearchGoToComponent($event)"
+        (toggleGraphControls)="toggleGraphControls()">
+    </connector-canvas-header-bar>
     <div class="relative flex-1 min-h-0">
         @if ((hasError$ | async) === false) {
             <reusable-canvas
@@ -51,4 +59,5 @@
             </div>
         }
     </div>
+    <connector-canvas-footer 
[connectorId]="currentConnectorId"></connector-canvas-footer>
 </div>
diff --git 
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/ui/connector-canvas/connector-canvas.component.spec.ts
 
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/ui/connector-canvas/connector-canvas.component.spec.ts
index 9e320370e03..6e8e8278578 100644
--- 
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/ui/connector-canvas/connector-canvas.component.spec.ts
+++ 
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/ui/connector-canvas/connector-canvas.component.spec.ts
@@ -19,7 +19,7 @@ import { Component, input, output } from '@angular/core';
 import { CommonModule } from '@angular/common';
 import { ComponentFixture, TestBed, fakeAsync, tick } from 
'@angular/core/testing';
 import { NoopAnimationsModule } from '@angular/platform-browser/animations';
-import { provideRouter } from '@angular/router';
+import { provideRouter, Router } from '@angular/router';
 import { MatDialog, MatDialogModule } from '@angular/material/dialog';
 import { MockStore, provideMockStore } from '@ngrx/store/testing';
 import { firstValueFrom } from 'rxjs';
@@ -29,6 +29,8 @@ import { ComponentType, selectRouteParams, selectUrl } from 
'@nifi/shared';
 import { ConnectorCanvasComponent } from './connector-canvas.component';
 import { CanvasComponent } from 
'../../../../ui/common/canvas/canvas.component';
 import { Navigation } from 
'../../../../ui/common/navigation/navigation.component';
+import { ConnectorCanvasHeaderBarComponent } from 
'./header-bar/connector-canvas-header-bar.component';
+import { ConnectorCanvasFooterComponent } from './footer/footer.component';
 import { setConfiguration } from 
'../../../../state/canvas-ui/canvas-ui.actions';
 import * as ConnectorCanvasSelectors from 
'../../state/connector-canvas/connector-canvas.selectors';
 import { selectParentProcessGroupId } from 
'../../state/connector-canvas/connector-canvas.selectors';
@@ -68,6 +70,7 @@ class MockReusableCanvasComponent {
     deselectAll = output<void>();
     initialized = output<void>();
     centerOnSelection = vi.fn();
+    centerOnComponent = vi.fn();
 }
 
 @Component({
@@ -80,6 +83,31 @@ class MockNavigationComponent {
     heading = input<string>('');
 }
 
+@Component({
+    selector: 'connector-canvas-header-bar',
+    standalone: true,
+    imports: [CommonModule],
+    template: ''
+})
+class MockConnectorCanvasHeaderBarComponent {
+    connectorId = input.required<string>();
+    selectedComponentId = input<string | null>(null);
+    graphControlsOpen = input<boolean>(true);
+    backToConnectors = output<void>();
+    goToComponent = output<{ id: string; type: ComponentType; groupId: string 
}>();
+    toggleGraphControls = output<void>();
+}
+
+@Component({
+    selector: 'connector-canvas-footer',
+    standalone: true,
+    imports: [CommonModule],
+    template: ''
+})
+class MockConnectorCanvasFooterComponent {
+    connectorId = input.required<string>();
+}
+
 @Component({
     selector: 'mock-blocking-dialog',
     standalone: true,
@@ -134,8 +162,17 @@ function configureConnectorCanvasTestBed(options: 
SetupOptions = {}) {
         imports: [ConnectorCanvasComponent, NoopAnimationsModule, 
MatDialogModule],
         providers: [provideRouter([]), provideMockStore({ initialState: {}, 
selectors: buildMockSelectors(options) })]
     }).overrideComponent(ConnectorCanvasComponent, {
-        remove: { imports: [CanvasComponent, Navigation] },
-        add: { imports: [MockReusableCanvasComponent, MockNavigationComponent] 
}
+        remove: {
+            imports: [CanvasComponent, Navigation, 
ConnectorCanvasHeaderBarComponent, ConnectorCanvasFooterComponent]
+        },
+        add: {
+            imports: [
+                MockReusableCanvasComponent,
+                MockNavigationComponent,
+                MockConnectorCanvasHeaderBarComponent,
+                MockConnectorCanvasFooterComponent
+            ]
+        }
     });
 }
 
@@ -544,4 +581,119 @@ describe('ConnectorCanvasComponent', () => {
             expect(dispatchSpy).not.toHaveBeenCalled();
         }));
     });
+
+    describe('Header bar and footer', () => {
+        it('should render the header bar component', fakeAsync(() => {
+            const { fixture } = setup();
+            fixture.detectChanges();
+            tick();
+
+            const headerBar = 
fixture.nativeElement.querySelector('connector-canvas-header-bar');
+            expect(headerBar).toBeTruthy();
+        }));
+
+        it('should render the footer component', fakeAsync(() => {
+            const { fixture } = setup();
+            fixture.detectChanges();
+            tick();
+
+            const footer = 
fixture.nativeElement.querySelector('connector-canvas-footer');
+            expect(footer).toBeTruthy();
+        }));
+    });
+
+    describe('Graph controls toggle', () => {
+        it('should default graphControlsOpen to true', () => {
+            localStorage.removeItem('connector-graph-controls');
+            const { component } = setup();
+            expect(component.graphControlsOpen).toBe(true);
+        });
+
+        it('should toggle graphControlsOpen and persist to localStorage', () 
=> {
+            localStorage.removeItem('connector-graph-controls');
+            const { component } = setup();
+
+            component.toggleGraphControls();
+            expect(component.graphControlsOpen).toBe(false);
+            
expect(localStorage.getItem('connector-graph-controls')).toBe('false');
+
+            component.toggleGraphControls();
+            expect(component.graphControlsOpen).toBe(true);
+            
expect(localStorage.getItem('connector-graph-controls')).toBe('true');
+        });
+    });
+
+    describe('Search navigation', () => {
+        it('should select components when search result is in the current 
process group', fakeAsync(() => {
+            const { fixture, component, dispatchSpy } = setup({ 
processGroupId: DEFAULT_PROCESS_GROUP_ID });
+            fixture.detectChanges();
+            tick();
+            dispatchSpy.mockClear();
+
+            const selectSpy = vi.spyOn(component, 'onSelectComponents');
+            try {
+                component.onSearchGoToComponent({
+                    id: 'proc-1',
+                    type: ComponentType.Processor,
+                    groupId: DEFAULT_PROCESS_GROUP_ID
+                });
+            } catch (_e: unknown) {
+                // NG0951: viewChild.required for CanvasComponent is not 
resolvable in tests with mock overrides
+            }
+
+            expect(selectSpy).toHaveBeenCalledWith([{ id: 'proc-1', type: 
ComponentType.Processor }]);
+            expect(dispatchSpy).toHaveBeenCalledWith(
+                selectComponents({
+                    request: {
+                        components: [{ id: 'proc-1', componentType: 
ComponentType.Processor }]
+                    }
+                })
+            );
+        }));
+
+        it('should navigate to settings when search result is a 
ParameterProvider', fakeAsync(() => {
+            const { fixture, component, dispatchSpy } = setup();
+            fixture.detectChanges();
+            tick();
+            dispatchSpy.mockClear();
+
+            const router = TestBed.inject(Router);
+            const navigateSpy = vi.spyOn(router, 'navigate');
+
+            component.onSearchGoToComponent({
+                id: 'pp-1',
+                type: ComponentType.ParameterProvider,
+                groupId: DEFAULT_PROCESS_GROUP_ID
+            });
+
+            expect(navigateSpy).toHaveBeenCalledWith(['/settings', 
'parameter-providers', 'pp-1']);
+            expect(dispatchSpy).not.toHaveBeenCalled();
+        }));
+
+        it('should dispatch skipTransform and navigate when search result is 
in a different process group', fakeAsync(() => {
+            const { fixture, component, dispatchSpy } = setup();
+            fixture.detectChanges();
+            tick();
+            dispatchSpy.mockClear();
+
+            const router = TestBed.inject(Router);
+            const navigateSpy = vi.spyOn(router, 'navigate');
+
+            component.onSearchGoToComponent({
+                id: 'proc-2',
+                type: ComponentType.Processor,
+                groupId: 'different-pg'
+            });
+
+            expect(dispatchSpy).toHaveBeenCalledWith(setSkipTransform({ 
skipTransform: false }));
+            expect(navigateSpy).toHaveBeenCalledWith([
+                '/connectors',
+                DEFAULT_CONNECTOR_ID,
+                'canvas',
+                'different-pg',
+                ComponentType.Processor,
+                'proc-2'
+            ]);
+        }));
+    });
 });
diff --git 
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/ui/connector-canvas/connector-canvas.component.ts
 
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/ui/connector-canvas/connector-canvas.component.ts
index 79bca15cc88..e7b540a6e9f 100644
--- 
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/ui/connector-canvas/connector-canvas.component.ts
+++ 
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/ui/connector-canvas/connector-canvas.component.ts
@@ -30,14 +30,25 @@ import { CanvasConfiguration } from 
'../../../../state/canvas-ui';
 import { setConfiguration } from 
'../../../../state/canvas-ui/canvas-ui.actions';
 import { CanvasComponent } from 
'../../../../ui/common/canvas/canvas.component';
 import { Navigation } from 
'../../../../ui/common/navigation/navigation.component';
+import { ConnectorCanvasHeaderBarComponent } from 
'./header-bar/connector-canvas-header-bar.component';
+import { ConnectorCanvasFooterComponent } from './footer/footer.component';
 import * as ConnectorCanvasActions from 
'../../state/connector-canvas/connector-canvas.actions';
 import * as ConnectorCanvasSelectors from 
'../../state/connector-canvas/connector-canvas.selectors';
 import * as ConnectorCanvasEntityActions from 
'../../state/connector-canvas-entity/connector-canvas-entity.actions';
 
+const GRAPH_CONTROLS_STORAGE_KEY = 'connector-graph-controls';
+
 @Component({
     selector: 'connector-canvas',
     standalone: true,
-    imports: [CommonModule, CanvasComponent, MatButton, Navigation],
+    imports: [
+        CommonModule,
+        CanvasComponent,
+        MatButton,
+        Navigation,
+        ConnectorCanvasHeaderBarComponent,
+        ConnectorCanvasFooterComponent
+    ],
     templateUrl: './connector-canvas.component.html'
 })
 export class ConnectorCanvasComponent implements OnInit, OnDestroy {
@@ -53,6 +64,7 @@ export class ConnectorCanvasComponent implements OnInit, 
OnDestroy {
     selectedComponentIds: string[] = [];
     canNavigateToParent = false;
     skipTransform = 
this.store.selectSignal(ConnectorCanvasSelectors.selectSkipTransform);
+    graphControlsOpen = localStorage.getItem(GRAPH_CONTROLS_STORAGE_KEY) !== 
'false';
 
     // Subscribe to connector canvas state (flow data)
     labels$: Observable<unknown[]> = 
this.store.select(ConnectorCanvasSelectors.selectLabels);
@@ -224,6 +236,33 @@ export class ConnectorCanvasComponent implements OnInit, 
OnDestroy {
         return !['input', 'textarea', 'select'].includes(tagName);
     }
 
+    toggleGraphControls(): void {
+        this.graphControlsOpen = !this.graphControlsOpen;
+        localStorage.setItem(GRAPH_CONTROLS_STORAGE_KEY, 
String(this.graphControlsOpen));
+    }
+
+    onSearchGoToComponent(event: { id: string; type: ComponentType; groupId: 
string }): void {
+        if (event.type === ComponentType.ParameterProvider) {
+            this.router.navigate(['/settings', 'parameter-providers', 
event.id]);
+            return;
+        }
+
+        if (event.groupId === this.currentProcessGroupId) {
+            this.onSelectComponents([{ id: event.id, type: event.type }]);
+            this.canvasComponent().centerOnComponent(event.id, event.type);
+        } else {
+            this.store.dispatch(ConnectorCanvasActions.setSkipTransform({ 
skipTransform: false }));
+            this.router.navigate([
+                '/connectors',
+                this.currentConnectorId,
+                'canvas',
+                event.groupId,
+                event.type,
+                event.id
+            ]);
+        }
+    }
+
     returnToConnectorListing(): void {
         this.router.navigate(['/connectors']);
     }
diff --git 
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/ui/connector-canvas/footer/footer.component.html
 
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/ui/connector-canvas/footer/footer.component.html
new file mode 100644
index 00000000000..854f692e98d
--- /dev/null
+++ 
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/ui/connector-canvas/footer/footer.component.html
@@ -0,0 +1,25 @@
+<!--
+  ~ 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.
+  -->
+
+<footer>
+    <div class="breadcrumb-container border-t z-[3]">
+        <breadcrumbs
+            [entity]="(breadcrumbs$ | async)!"
+            [currentProcessGroupId]="(currentProcessGroupId$ | async)!"
+            [routeGenerator]="routeGenerator()"></breadcrumbs>
+    </div>
+</footer>
diff --git 
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/common/breadcrumbs/breadcrumbs.component.scss
 
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/ui/connector-canvas/footer/footer.component.scss
similarity index 86%
copy from 
nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/common/breadcrumbs/breadcrumbs.component.scss
copy to 
nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/ui/connector-canvas/footer/footer.component.scss
index 8a3b7264bd4..2944f981947 100644
--- 
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/common/breadcrumbs/breadcrumbs.component.scss
+++ 
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/ui/connector-canvas/footer/footer.component.scss
@@ -14,13 +14,3 @@
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
-
-.breadcrumbs {
-    a {
-        white-space: nowrap;
-    }
-
-    a.current-process-group {
-        text-decoration: none;
-    }
-}
diff --git 
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/ui/connector-canvas/footer/footer.component.spec.ts
 
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/ui/connector-canvas/footer/footer.component.spec.ts
new file mode 100644
index 00000000000..3e7430dd591
--- /dev/null
+++ 
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/ui/connector-canvas/footer/footer.component.spec.ts
@@ -0,0 +1,170 @@
+/*
+ * 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 { Component, input } from '@angular/core';
+import { TestBed } from '@angular/core/testing';
+import { provideMockStore, MockStore } from '@ngrx/store/testing';
+import { provideRouter } from '@angular/router';
+import { ConnectorCanvasFooterComponent } from './footer.component';
+import { BreadcrumbEntity } from '../../../../flow-designer/state/shared';
+import * as ConnectorCanvasSelectors from 
'../../../state/connector-canvas/connector-canvas.selectors';
+
+@Component({
+    standalone: true,
+    imports: [ConnectorCanvasFooterComponent],
+    template: `<connector-canvas-footer 
[connectorId]="connectorId()"></connector-canvas-footer>`
+})
+class TestHostComponent {
+    connectorId = input('connector-123');
+}
+
+interface SetupOptions {
+    connectorId?: string;
+    breadcrumb?: BreadcrumbEntity | null;
+    currentProcessGroupId?: string | null;
+}
+
+function createMockBreadcrumb(overrides: Partial<BreadcrumbEntity> = {}): 
BreadcrumbEntity {
+    return {
+        id: 'pg-123',
+        permissions: { canRead: true, canWrite: true },
+        versionedFlowState: '',
+        breadcrumb: {
+            id: 'pg-123',
+            name: 'Test Process Group'
+        },
+        ...overrides
+    };
+}
+
+async function setup(options: SetupOptions = {}) {
+    const { connectorId = 'connector-123', breadcrumb = null, 
currentProcessGroupId = 'pg-123' } = options;
+
+    await TestBed.configureTestingModule({
+        imports: [TestHostComponent],
+        providers: [
+            provideRouter([]),
+            provideMockStore({
+                selectors: [
+                    { selector: ConnectorCanvasSelectors.selectBreadcrumbs, 
value: breadcrumb },
+                    { selector: ConnectorCanvasSelectors.selectProcessGroupId, 
value: currentProcessGroupId }
+                ]
+            })
+        ]
+    }).compileComponents();
+
+    const store = TestBed.inject(MockStore);
+    const hostFixture = TestBed.createComponent(TestHostComponent);
+    hostFixture.componentRef.setInput('connectorId', connectorId);
+    hostFixture.detectChanges();
+
+    const footerDebugEl = hostFixture.debugElement.children[0];
+    const component = footerDebugEl.componentInstance as 
ConnectorCanvasFooterComponent;
+
+    return { fixture: hostFixture, component, store };
+}
+
+describe('ConnectorCanvasFooterComponent', () => {
+    beforeEach(() => {
+        vi.clearAllMocks();
+    });
+
+    describe('Component initialization', () => {
+        it('should create', async () => {
+            const { component } = await setup();
+            expect(component).toBeTruthy();
+        });
+
+        it('should have connectorId input', async () => {
+            const { component } = await setup({ connectorId: 'test-connector' 
});
+            expect(component.connectorId()).toBe('test-connector');
+        });
+    });
+
+    describe('Route generation', () => {
+        it('should generate correct route for connector canvas breadcrumbs', 
async () => {
+            const { component } = await setup({ connectorId: 'my-connector' });
+            const routeGenerator = component.routeGenerator();
+
+            const route = routeGenerator('process-group-abc');
+
+            expect(route).toEqual(['/connectors', 'my-connector', 'canvas', 
'process-group-abc']);
+        });
+
+        it('should use current connectorId in route generation', async () => {
+            const { fixture } = await setup({ connectorId: 'initial-connector' 
});
+
+            fixture.componentRef.setInput('connectorId', 'updated-connector');
+            fixture.detectChanges();
+
+            const footerDebugEl = fixture.debugElement.children[0];
+            const component = footerDebugEl.componentInstance as 
ConnectorCanvasFooterComponent;
+            const routeGenerator = component.routeGenerator();
+            const route = routeGenerator('pg-456');
+
+            expect(route).toEqual(['/connectors', 'updated-connector', 
'canvas', 'pg-456']);
+        });
+    });
+
+    describe('Breadcrumb display', () => {
+        it('should render breadcrumbs container', async () => {
+            const { fixture } = await setup({
+                breadcrumb: createMockBreadcrumb()
+            });
+
+            const breadcrumbContainer = 
fixture.nativeElement.querySelector('.breadcrumb-container');
+            expect(breadcrumbContainer).toBeTruthy();
+        });
+
+        it('should render footer element', async () => {
+            const { fixture } = await setup();
+
+            const footer = fixture.nativeElement.querySelector('footer');
+            expect(footer).toBeTruthy();
+        });
+
+        it('should include breadcrumbs component', async () => {
+            const { fixture } = await setup({
+                breadcrumb: createMockBreadcrumb()
+            });
+
+            const breadcrumbs = 
fixture.nativeElement.querySelector('breadcrumbs');
+            expect(breadcrumbs).toBeTruthy();
+        });
+    });
+
+    describe('Store selectors', () => {
+        it('should select breadcrumbs from store', async () => {
+            const mockBreadcrumb = createMockBreadcrumb({ id: 'test-pg' });
+            const { component } = await setup({ breadcrumb: mockBreadcrumb });
+
+            let breadcrumbs: BreadcrumbEntity | null = null;
+            component.breadcrumbs$.subscribe((b) => (breadcrumbs = b));
+
+            expect(breadcrumbs).toEqual(mockBreadcrumb);
+        });
+
+        it('should select currentProcessGroupId from store', async () => {
+            const { component } = await setup({ currentProcessGroupId: 
'current-pg-id' });
+
+            let processGroupId: string | null = null;
+            component.currentProcessGroupId$.subscribe((id) => (processGroupId 
= id));
+
+            expect(processGroupId).toBe('current-pg-id');
+        });
+    });
+});
diff --git 
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/canvas/footer/footer.component.ts
 
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/ui/connector-canvas/footer/footer.component.ts
similarity index 59%
copy from 
nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/canvas/footer/footer.component.ts
copy to 
nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/ui/connector-canvas/footer/footer.component.ts
index 9dcfeb690c5..c5336403822 100644
--- 
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/canvas/footer/footer.component.ts
+++ 
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/ui/connector-canvas/footer/footer.component.ts
@@ -15,22 +15,28 @@
  * limitations under the License.
  */
 
-import { Component, inject } from '@angular/core';
-import { selectBreadcrumbs, selectCurrentProcessGroupId } from 
'../../../state/flow/flow.selectors';
+import { Component, computed, inject, input } from '@angular/core';
 import { Store } from '@ngrx/store';
-import { CanvasState } from '../../../state';
-import { Breadcrumbs } from '../../common/breadcrumbs/breadcrumbs.component';
 import { AsyncPipe } from '@angular/common';
+import { Breadcrumbs, BreadcrumbRouteGenerator } from 
'../../../../../ui/common/breadcrumbs/breadcrumbs.component';
+import { selectBreadcrumbs, selectProcessGroupId } from 
'../../../state/connector-canvas/connector-canvas.selectors';
 
 @Component({
-    selector: 'fd-footer',
+    selector: 'connector-canvas-footer',
     templateUrl: './footer.component.html',
     imports: [Breadcrumbs, AsyncPipe],
     styleUrls: ['./footer.component.scss']
 })
-export class FooterComponent {
-    private store = inject<Store<CanvasState>>(Store);
+export class ConnectorCanvasFooterComponent {
+    private store = inject(Store);
+
+    connectorId = input.required<string>();
 
     breadcrumbs$ = this.store.select(selectBreadcrumbs);
-    currentProcessGroupId$ = this.store.select(selectCurrentProcessGroupId);
+    currentProcessGroupId$ = this.store.select(selectProcessGroupId);
+
+    routeGenerator = computed<BreadcrumbRouteGenerator>(() => {
+        const id = this.connectorId();
+        return (processGroupId: string) => ['/connectors', id, 'canvas', 
processGroupId];
+    });
 }
diff --git 
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/ui/connector-canvas/header-bar/connector-canvas-header-bar.component.html
 
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/ui/connector-canvas/header-bar/connector-canvas-header-bar.component.html
new file mode 100644
index 00000000000..1efad8f05f8
--- /dev/null
+++ 
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/ui/connector-canvas/header-bar/connector-canvas-header-bar.component.html
@@ -0,0 +1,46 @@
+<!--
+  ~ 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.
+  -->
+
+<div class="h-8 connector-header-bar border-b select-none">
+    <div class="flex items-center h-full justify-between">
+        <div class="flex items-center h-full">
+            <button
+                class="h-8 w-8 border-r pointer"
+                data-qa="toggle-graph-controls"
+                (click)="toggleGraphControls.emit()"
+                [title]="graphControlsOpen() ? 'Close graph controls' : 'Open 
graph controls'">
+                <i
+                    class="primary-color fa"
+                    [ngClass]="{
+                        'fa-chevron-right': !graphControlsOpen(),
+                        'fa-chevron-left': graphControlsOpen()
+                    }"></i>
+            </button>
+            <div class="flex items-center px-5">
+                <a class="primary-color cursor-pointer" 
data-qa="back-to-connectors" (click)="backToConnectors.emit()">
+                    <i class="fa fa-arrow-left mr-2"></i>Installed Connectors
+                </a>
+            </div>
+        </div>
+        <canvas-header-search
+            class="self-stretch"
+            [searchFn]="searchFn"
+            [selectedComponentId]="selectedComponentId()"
+            (goToComponent)="onGoToComponent($event)">
+        </canvas-header-search>
+    </div>
+</div>
diff --git 
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/common/breadcrumbs/breadcrumbs.component.scss
 
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/ui/connector-canvas/header-bar/connector-canvas-header-bar.component.scss
similarity index 86%
copy from 
nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/common/breadcrumbs/breadcrumbs.component.scss
copy to 
nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/ui/connector-canvas/header-bar/connector-canvas-header-bar.component.scss
index 8a3b7264bd4..8c7178efc65 100644
--- 
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/common/breadcrumbs/breadcrumbs.component.scss
+++ 
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/ui/connector-canvas/header-bar/connector-canvas-header-bar.component.scss
@@ -15,12 +15,6 @@
  * limitations under the License.
  */
 
-.breadcrumbs {
-    a {
-        white-space: nowrap;
-    }
-
-    a.current-process-group {
-        text-decoration: none;
-    }
+.connector-header-bar {
+    background-color: var(--mat-sys-background);
 }
diff --git 
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/ui/connector-canvas/header-bar/connector-canvas-header-bar.component.spec.ts
 
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/ui/connector-canvas/header-bar/connector-canvas-header-bar.component.spec.ts
new file mode 100644
index 00000000000..faeb1de9160
--- /dev/null
+++ 
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/ui/connector-canvas/header-bar/connector-canvas-header-bar.component.spec.ts
@@ -0,0 +1,205 @@
+/*
+ * 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 { TestBed } from '@angular/core/testing';
+import { Component } from '@angular/core';
+import { ConnectorCanvasHeaderBarComponent } from 
'./connector-canvas-header-bar.component';
+import { ConnectorService } from '../../../service/connector.service';
+import { HttpClient } from '@angular/common/http';
+import { ComponentType } from '@nifi/shared';
+
+@Component({
+    standalone: true,
+    imports: [ConnectorCanvasHeaderBarComponent],
+    template: `
+        <connector-canvas-header-bar
+            [connectorId]="connectorId"
+            [selectedComponentId]="selectedComponentId"
+            [graphControlsOpen]="graphControlsOpen"
+            (backToConnectors)="onBack()"
+            (goToComponent)="onGoTo($event)"
+            (toggleGraphControls)="onToggleGraphControls()">
+        </connector-canvas-header-bar>
+    `
+})
+class TestHostComponent {
+    connectorId = 'test-connector-id';
+    selectedComponentId: string | null = null;
+    graphControlsOpen = true;
+    onBack = vi.fn();
+    onGoTo = vi.fn();
+    onToggleGraphControls = vi.fn();
+}
+
+interface SetupOptions {
+    connectorId?: string;
+    selectedComponentId?: string | null;
+    graphControlsOpen?: boolean;
+}
+
+async function setup(options: SetupOptions = {}) {
+    const mockConnectorService = {
+        searchConnector: vi.fn()
+    };
+
+    await TestBed.configureTestingModule({
+        imports: [TestHostComponent],
+        providers: [
+            { provide: ConnectorService, useValue: mockConnectorService },
+            { provide: HttpClient, useValue: {} }
+        ]
+    }).compileComponents();
+
+    const hostFixture = TestBed.createComponent(TestHostComponent);
+    const host = hostFixture.componentInstance;
+
+    if (options.connectorId !== undefined) {
+        host.connectorId = options.connectorId;
+    }
+    if (options.selectedComponentId !== undefined) {
+        host.selectedComponentId = options.selectedComponentId;
+    }
+    if (options.graphControlsOpen !== undefined) {
+        host.graphControlsOpen = options.graphControlsOpen;
+    }
+
+    hostFixture.detectChanges();
+
+    return { hostFixture, host, mockConnectorService };
+}
+
+describe('ConnectorCanvasHeaderBarComponent', () => {
+    beforeEach(() => {
+        vi.clearAllMocks();
+    });
+
+    describe('Component initialization', () => {
+        it('should create', async () => {
+            const { hostFixture } = await setup();
+            const headerBar = 
hostFixture.nativeElement.querySelector('.connector-header-bar');
+            expect(headerBar).toBeTruthy();
+        });
+
+        it('should render the search component', async () => {
+            const { hostFixture } = await setup();
+            const search = 
hostFixture.nativeElement.querySelector('canvas-header-search');
+            expect(search).toBeTruthy();
+        });
+    });
+
+    describe('Back link', () => {
+        it('should render the back link with data-qa attribute', async () => {
+            const { hostFixture } = await setup();
+            const backLink = 
hostFixture.nativeElement.querySelector('[data-qa="back-to-connectors"]');
+            expect(backLink).toBeTruthy();
+        });
+
+        it('should display "Installed Connectors" text', async () => {
+            const { hostFixture } = await setup();
+            const backLink = 
hostFixture.nativeElement.querySelector('[data-qa="back-to-connectors"]');
+            expect(backLink.textContent).toContain('Installed Connectors');
+        });
+
+        it('should render an arrow-left icon', async () => {
+            const { hostFixture } = await setup();
+            const icon = 
hostFixture.nativeElement.querySelector('[data-qa="back-to-connectors"] 
.fa-arrow-left');
+            expect(icon).toBeTruthy();
+        });
+
+        it('should call host onBack when the link is clicked', async () => {
+            const { hostFixture, host } = await setup();
+            const backLink = 
hostFixture.nativeElement.querySelector('[data-qa="back-to-connectors"]');
+            backLink.click();
+            expect(host.onBack).toHaveBeenCalledTimes(1);
+        });
+    });
+
+    describe('goToComponent output', () => {
+        it('should map onGoToComponent using parentGroup.id when available', 
async () => {
+            const { hostFixture, host } = await setup();
+
+            const headerBarEl = hostFixture.debugElement.children[0];
+            const headerBarComponent = headerBarEl.componentInstance as 
ConnectorCanvasHeaderBarComponent;
+
+            const result = {
+                id: 'p1',
+                groupId: 'fallback-pg',
+                parentGroup: { id: 'parent-pg', name: 'Parent' },
+                versionedGroup: { id: '', name: '' },
+                name: 'MyProcessor',
+                matches: []
+            };
+            headerBarComponent.onGoToComponent({ result, type: 
ComponentType.Processor });
+
+            expect(host.onGoTo).toHaveBeenCalledWith({
+                id: 'p1',
+                type: ComponentType.Processor,
+                groupId: 'parent-pg'
+            });
+        });
+
+        it('should fall back to groupId when parentGroup is null', async () => 
{
+            const { hostFixture, host } = await setup();
+
+            const headerBarEl = hostFixture.debugElement.children[0];
+            const headerBarComponent = headerBarEl.componentInstance as 
ConnectorCanvasHeaderBarComponent;
+
+            const result = {
+                id: 'p2',
+                groupId: 'fallback-pg',
+                parentGroup: null as any,
+                versionedGroup: { id: '', name: '' },
+                name: 'AnotherProcessor',
+                matches: []
+            };
+            headerBarComponent.onGoToComponent({ result, type: 
ComponentType.Processor });
+
+            expect(host.onGoTo).toHaveBeenCalledWith({
+                id: 'p2',
+                type: ComponentType.Processor,
+                groupId: 'fallback-pg'
+            });
+        });
+    });
+
+    describe('Graph controls toggle', () => {
+        it('should render the toggle button', async () => {
+            const { hostFixture } = await setup();
+            const toggleBtn = 
hostFixture.nativeElement.querySelector('[data-qa="toggle-graph-controls"]');
+            expect(toggleBtn).toBeTruthy();
+        });
+
+        it('should show chevron-left icon when controls are open', async () => 
{
+            const { hostFixture } = await setup({ graphControlsOpen: true });
+            const icon = 
hostFixture.nativeElement.querySelector('[data-qa="toggle-graph-controls"] 
.fa-chevron-left');
+            expect(icon).toBeTruthy();
+        });
+
+        it('should show chevron-right icon when controls are closed', async () 
=> {
+            const { hostFixture } = await setup({ graphControlsOpen: false });
+            const icon = 
hostFixture.nativeElement.querySelector('[data-qa="toggle-graph-controls"] 
.fa-chevron-right');
+            expect(icon).toBeTruthy();
+        });
+
+        it('should call host onToggleGraphControls when toggle button is 
clicked', async () => {
+            const { hostFixture, host } = await setup();
+            const toggleBtn = 
hostFixture.nativeElement.querySelector('[data-qa="toggle-graph-controls"]');
+            toggleBtn.click();
+            expect(host.onToggleGraphControls).toHaveBeenCalledTimes(1);
+        });
+    });
+});
diff --git 
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/ui/connector-canvas/header-bar/connector-canvas-header-bar.component.ts
 
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/ui/connector-canvas/header-bar/connector-canvas-header-bar.component.ts
new file mode 100644
index 00000000000..c5074acdcfa
--- /dev/null
+++ 
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/ui/connector-canvas/header-bar/connector-canvas-header-bar.component.ts
@@ -0,0 +1,59 @@
+/*
+ * 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 { NgClass } from '@angular/common';
+import { Component, inject, input, output } from '@angular/core';
+import { EMPTY, Observable } from 'rxjs';
+import { ComponentSearchResult, SearchResultsEntity } from 
'../../../../../state/shared';
+import { ComponentType } from '@nifi/shared';
+import { ConnectorService } from '../../../service/connector.service';
+import { CanvasHeaderSearchComponent } from 
'../../../../../ui/common/canvas-header-search/canvas-header-search.component';
+
+@Component({
+    selector: 'connector-canvas-header-bar',
+    standalone: true,
+    imports: [NgClass, CanvasHeaderSearchComponent],
+    templateUrl: './connector-canvas-header-bar.component.html',
+    styleUrls: ['./connector-canvas-header-bar.component.scss']
+})
+export class ConnectorCanvasHeaderBarComponent {
+    private connectorService = inject(ConnectorService);
+
+    connectorId = input.required<string>();
+    selectedComponentId = input<string | null>(null);
+    graphControlsOpen = input<boolean>(true);
+
+    backToConnectors = output<void>();
+    goToComponent = output<{ id: string; type: ComponentType; groupId: string 
}>();
+    toggleGraphControls = output<void>();
+
+    protected searchFn = (query: string): Observable<SearchResultsEntity> => {
+        const connectorId = this.connectorId();
+        if (!connectorId) {
+            return EMPTY;
+        }
+        return this.connectorService.searchConnector(connectorId, query);
+    };
+
+    onGoToComponent(event: { result: ComponentSearchResult; type: 
ComponentType }): void {
+        this.goToComponent.emit({
+            id: event.result.id,
+            type: event.type,
+            groupId: event.result.parentGroup?.id ?? event.result.groupId
+        });
+    }
+}
diff --git 
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/service/search.service.ts
 
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/service/search.service.ts
index 4fe6db54d64..a3c3d1898b3 100644
--- 
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/service/search.service.ts
+++ 
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/service/search.service.ts
@@ -18,39 +18,7 @@
 import { Injectable, inject } from '@angular/core';
 import { Observable } from 'rxjs';
 import { HttpClient } from '@angular/common/http';
-
-export interface SearchResultGroup {
-    id: string;
-    name: string;
-}
-
-export interface ComponentSearchResult {
-    id: string;
-    groupId: string;
-    parentGroup: SearchResultGroup;
-    versionedGroup: SearchResultGroup;
-    name: string;
-    matches: string[];
-}
-
-export interface SearchResults {
-    processorResults: ComponentSearchResult[];
-    connectionResults: ComponentSearchResult[];
-    processGroupResults: ComponentSearchResult[];
-    inputPortResults: ComponentSearchResult[];
-    outputPortResults: ComponentSearchResult[];
-    remoteProcessGroupResults: ComponentSearchResult[];
-    funnelResults: ComponentSearchResult[];
-    labelResults: ComponentSearchResult[];
-    controllerServiceNodeResults: ComponentSearchResult[];
-    parameterContextResults: ComponentSearchResult[];
-    parameterProviderNodeResults: ComponentSearchResult[];
-    parameterResults: ComponentSearchResult[];
-}
-
-export interface SearchResultsEntity {
-    searchResultsDTO: SearchResults;
-}
+import { SearchResultsEntity } from '../../../state/shared';
 
 @Injectable({ providedIn: 'root' })
 export class SearchService {
diff --git 
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/canvas/footer/footer.component.ts
 
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/canvas/footer/footer.component.ts
index 9dcfeb690c5..4acc4b3de9c 100644
--- 
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/canvas/footer/footer.component.ts
+++ 
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/canvas/footer/footer.component.ts
@@ -19,7 +19,7 @@ import { Component, inject } from '@angular/core';
 import { selectBreadcrumbs, selectCurrentProcessGroupId } from 
'../../../state/flow/flow.selectors';
 import { Store } from '@ngrx/store';
 import { CanvasState } from '../../../state';
-import { Breadcrumbs } from '../../common/breadcrumbs/breadcrumbs.component';
+import { Breadcrumbs } from 
'../../../../../ui/common/breadcrumbs/breadcrumbs.component';
 import { AsyncPipe } from '@angular/common';
 
 @Component({
diff --git 
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/canvas/header/search/search.component.ts
 
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/canvas/header/search/search.component.ts
index be8432e0129..75939912651 100644
--- 
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/canvas/header/search/search.component.ts
+++ 
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/canvas/header/search/search.component.ts
@@ -19,7 +19,7 @@ import { Component, DestroyRef, ElementRef, inject, Input, 
OnInit, ViewChild } f
 import { FormBuilder, FormGroup, ReactiveFormsModule } from '@angular/forms';
 import { initialState } from '../../../../state/flow/flow.reducer';
 import { debounceTime, filter, take, tap } from 'rxjs';
-import { ComponentSearchResult, SearchService } from 
'../../../../service/search.service';
+import { SearchService } from '../../../../service/search.service';
 import {
     CdkConnectedOverlay,
     CdkOverlayOrigin,
@@ -27,7 +27,7 @@ import {
     OriginConnectionPosition,
     OverlayConnectionPosition
 } from '@angular/cdk/overlay';
-import { SearchMatchTipInput } from '../../../../../../state/shared';
+import { ComponentSearchResult, SearchMatchTipInput } from 
'../../../../../../state/shared';
 import { NgTemplateOutlet } from '@angular/common';
 import { RouterLink } from '@angular/router';
 import { MatFormFieldModule } from '@angular/material/form-field';
diff --git 
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/controller-service/controller-services.component.spec.ts
 
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/controller-service/controller-services.component.spec.ts
index a5590fd07e2..9c5f7ca4553 100644
--- 
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/controller-service/controller-services.component.spec.ts
+++ 
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/controller-service/controller-services.component.spec.ts
@@ -37,7 +37,7 @@ import { BreadcrumbEntity } from '../../state/shared';
 import { FormsModule } from '@angular/forms';
 import { MatCheckboxModule } from '@angular/material/checkbox';
 import { ControllerServicesState } from '../../state/controller-services';
-import { Breadcrumbs } from '../common/breadcrumbs/breadcrumbs.component';
+import { Breadcrumbs } from 
'../../../../ui/common/breadcrumbs/breadcrumbs.component';
 import { ControllerServiceTable } from 
'../../../../ui/common/controller-service/controller-service-table/controller-service-table.component';
 
 describe('ControllerServices', () => {
diff --git 
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/controller-service/controller-services.module.ts
 
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/controller-service/controller-services.module.ts
index 452d6d6f32f..0474f59ae44 100644
--- 
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/controller-service/controller-services.module.ts
+++ 
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/controller-service/controller-services.module.ts
@@ -22,7 +22,7 @@ import { ControllerServices } from 
'./controller-services.component';
 import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
 import { ControllerServiceTable } from 
'../../../../ui/common/controller-service/controller-service-table/controller-service-table.component';
 import { ControllerServicesRoutingModule } from 
'./controller-services-routing.module';
-import { Breadcrumbs } from '../common/breadcrumbs/breadcrumbs.component';
+import { Breadcrumbs } from 
'../../../../ui/common/breadcrumbs/breadcrumbs.component';
 import { Navigation } from 
'../../../../ui/common/navigation/navigation.component';
 import { MatButtonModule } from '@angular/material/button';
 import { MatCheckboxModule } from '@angular/material/checkbox';
diff --git 
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/manage-remote-ports/manage-remote-ports.module.ts
 
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/manage-remote-ports/manage-remote-ports.module.ts
index 9652a5ca8ec..da3e8f2451d 100644
--- 
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/manage-remote-ports/manage-remote-ports.module.ts
+++ 
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/manage-remote-ports/manage-remote-ports.module.ts
@@ -21,7 +21,7 @@ import { ManageRemotePorts } from 
'./manage-remote-ports.component';
 import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader';
 import { ControllerServiceTable } from 
'../../../../ui/common/controller-service/controller-service-table/controller-service-table.component';
 import { ManageRemotePortsRoutingModule } from 
'./manage-remote-ports-routing.module';
-import { Breadcrumbs } from '../common/breadcrumbs/breadcrumbs.component';
+import { Breadcrumbs } from 
'../../../../ui/common/breadcrumbs/breadcrumbs.component';
 import { Navigation } from 
'../../../../ui/common/navigation/navigation.component';
 import { MatTableModule } from '@angular/material/table';
 import { MatSortModule } from '@angular/material/sort';
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 1376f2b15a9..2ca5618e14a 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
@@ -230,6 +230,39 @@ export interface SearchMatchTipInput {
     matches: string[];
 }
 
+export interface SearchResultGroup {
+    id: string;
+    name: string;
+}
+
+export interface ComponentSearchResult {
+    id: string;
+    groupId: string;
+    parentGroup: SearchResultGroup;
+    versionedGroup: SearchResultGroup;
+    name: string;
+    matches: string[];
+}
+
+export interface SearchResults {
+    processorResults: ComponentSearchResult[];
+    connectionResults: ComponentSearchResult[];
+    processGroupResults: ComponentSearchResult[];
+    inputPortResults: ComponentSearchResult[];
+    outputPortResults: ComponentSearchResult[];
+    remoteProcessGroupResults: ComponentSearchResult[];
+    funnelResults: ComponentSearchResult[];
+    labelResults: ComponentSearchResult[];
+    controllerServiceNodeResults: ComponentSearchResult[];
+    parameterContextResults: ComponentSearchResult[];
+    parameterProviderNodeResults: ComponentSearchResult[];
+    parameterResults: ComponentSearchResult[];
+}
+
+export interface SearchResultsEntity {
+    searchResultsDTO: SearchResults;
+}
+
 export interface ControllerServiceApi {
     type: string;
     bundle: Bundle;
diff --git 
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/common/breadcrumbs/breadcrumbs.component.html
 
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/breadcrumbs/breadcrumbs.component.html
similarity index 86%
rename from 
nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/common/breadcrumbs/breadcrumbs.component.html
rename to 
nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/breadcrumbs/breadcrumbs.component.html
index 0070c5da681..9dab1178013 100644
--- 
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/common/breadcrumbs/breadcrumbs.component.html
+++ 
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/breadcrumbs/breadcrumbs.component.html
@@ -28,14 +28,11 @@
                     [title]="getVersionControlTooltip(breadcrumb)"></div>
             }
             @if (isCurrentProcessGroupBreadcrumb(breadcrumb)) {
-                <a
-                    #currentProcessGroup
-                    class="current-process-group font-bold"
-                    [routerLink]="['/process-groups', breadcrumb.id]">
+                <a #currentProcessGroup class="current-process-group 
font-bold" [routerLink]="getRoute(breadcrumb)">
                     {{ getBreadcrumbLabel(breadcrumb) }}
                 </a>
             } @else {
-                <a [routerLink]="['/process-groups', breadcrumb.id]">
+                <a [routerLink]="getRoute(breadcrumb)">
                     {{ getBreadcrumbLabel(breadcrumb) }}
                 </a>
             }
diff --git 
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/common/breadcrumbs/breadcrumbs.component.scss
 
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/breadcrumbs/breadcrumbs.component.scss
similarity index 100%
copy from 
nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/common/breadcrumbs/breadcrumbs.component.scss
copy to 
nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/breadcrumbs/breadcrumbs.component.scss
diff --git 
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/common/breadcrumbs/breadcrumbs.component.spec.ts
 
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/breadcrumbs/breadcrumbs.component.spec.ts
similarity index 100%
rename from 
nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/common/breadcrumbs/breadcrumbs.component.spec.ts
rename to 
nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/breadcrumbs/breadcrumbs.component.spec.ts
diff --git 
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/common/breadcrumbs/breadcrumbs.component.ts
 
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/breadcrumbs/breadcrumbs.component.ts
similarity index 86%
rename from 
nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/common/breadcrumbs/breadcrumbs.component.ts
rename to 
nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/breadcrumbs/breadcrumbs.component.ts
index 4e4998f0184..d4a9f857e47 100644
--- 
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/common/breadcrumbs/breadcrumbs.component.ts
+++ 
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/breadcrumbs/breadcrumbs.component.ts
@@ -16,12 +16,14 @@
  */
 
 import { Component, ElementRef, Input, ViewChild, inject } from 
'@angular/core';
-import { initialState } from '../../../state/flow/flow.reducer';
-
 import { RouterLink } from '@angular/router';
-import { BreadcrumbEntity } from '../../../state/shared';
+import { BreadcrumbEntity } from '../../../pages/flow-designer/state/shared';
 import { Title } from '@angular/platform-browser';
 
+export type BreadcrumbRouteGenerator = (processGroupId: string) => string[];
+
+const defaultRouteGenerator: BreadcrumbRouteGenerator = (processGroupId: 
string) => ['/process-groups', processGroupId];
+
 @Component({
     selector: 'breadcrumbs',
     templateUrl: './breadcrumbs.component.html',
@@ -31,13 +33,13 @@ import { Title } from '@angular/platform-browser';
 export class Breadcrumbs {
     private title = inject(Title);
 
-    @Input() entity: BreadcrumbEntity = 
initialState.flow.processGroupFlow.breadcrumb;
-    @Input() currentProcessGroupId: string = initialState.id;
+    @Input() entity: BreadcrumbEntity | null = null;
+    @Input() currentProcessGroupId = '';
+    @Input() routeGenerator: BreadcrumbRouteGenerator = defaultRouteGenerator;
 
     private scrolledToProcessGroupId = '';
 
     @ViewChild('currentProcessGroup') set 
currentProcessGroupBreadcrumb(currentProcessGroupBreadcrumb: ElementRef) {
-        // only auto scroll to the breadcrumb for the current pg once as the 
user may have manually scrolled since
         if (currentProcessGroupBreadcrumb && this.scrolledToProcessGroupId != 
this.currentProcessGroupId) {
             currentProcessGroupBreadcrumb.nativeElement.scrollIntoView();
             this.scrolledToProcessGroupId = this.currentProcessGroupId;
@@ -45,6 +47,9 @@ export class Breadcrumbs {
     }
 
     prepareBreadcrumbs(): BreadcrumbEntity[] {
+        if (!this.entity) {
+            return [];
+        }
         const breadcrumbs: BreadcrumbEntity[] = [];
         this.prepareBreadcrumb(breadcrumbs, this.entity);
         return breadcrumbs.reverse();
@@ -84,7 +89,6 @@ export class Breadcrumbs {
             } else if (vciState === 'LOCALLY_MODIFIED') {
                 return 'locally-modified neutral-color fa fa-asterisk';
             } else {
-                // up to date
                 return 'up-to-date success-color-default fa fa-check';
             }
         } else {
@@ -107,4 +111,8 @@ export class Breadcrumbs {
 
         return breadcrumbEntity.id;
     }
+
+    getRoute(breadcrumbEntity: BreadcrumbEntity): string[] {
+        return this.routeGenerator(breadcrumbEntity.id);
+    }
 }
diff --git 
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/common/breadcrumbs/breadcrumbs.component.scss
 
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/canvas-header-search/_canvas-header-search.component-theme.scss
similarity index 59%
copy from 
nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/common/breadcrumbs/breadcrumbs.component.scss
copy to 
nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/canvas-header-search/_canvas-header-search.component-theme.scss
index 8a3b7264bd4..b28eeaea559 100644
--- 
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/common/breadcrumbs/breadcrumbs.component.scss
+++ 
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/canvas-header-search/_canvas-header-search.component-theme.scss
@@ -15,12 +15,26 @@
  * limitations under the License.
  */
 
-.breadcrumbs {
-    a {
-        white-space: nowrap;
+@mixin generate-theme() {
+    .search-container {
+        &:hover,
+        &.open {
+            background-color: var(--mat-sys-background);
+        }
+
+        .search-input {
+            background-color: var(--mat-sys-background);
+        }
     }
 
-    a.current-process-group {
-        text-decoration: none;
+    .search-results {
+        margin-top: 4px;
+        padding: 4px 6px;
+        box-shadow:
+            0 3px 5px -1px rgba(0, 0, 0, 0.2),
+            0 6px 10px 0 rgba(0, 0, 0, 0.14),
+            0 1px 18px 0 rgba(0, 0, 0, 0.12);
+        background-color: var(--mat-sys-surface);
+        border-radius: 4px;
     }
 }
diff --git 
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/canvas-header-search/canvas-header-search.component.html
 
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/canvas-header-search/canvas-header-search.component.html
new file mode 100644
index 00000000000..55f7b543847
--- /dev/null
+++ 
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/canvas-header-search/canvas-header-search.component.html
@@ -0,0 +1,230 @@
+<!--
+  ~ 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.
+  -->
+
+<div class="h-full flex justify-around search-container border-l" 
[class.open]="searchInputVisible()">
+    <button
+        class="w-8"
+        aria-label="Search canvas"
+        [attr.aria-expanded]="searchInputVisible()"
+        (click)="toggleSearchVisibility()">
+        <i class="fa fa-search primary-color"></i>
+    </button>
+    <ng-template
+        cdkConnectedOverlay
+        [cdkConnectedOverlayDisableClose]="true"
+        [cdkConnectedOverlayOrigin]="searchInput"
+        [cdkConnectedOverlayOpen]="searching() || searchingResultsVisible()"
+        [cdkConnectedOverlayPositions]="positions"
+        [cdkConnectedOverlayHasBackdrop]="true"
+        [cdkConnectedOverlayBackdropClass]="'cdk-overlay-transparent-backdrop'"
+        (overlayOutsideClick)="backdropClicked($event)">
+        <div class="search-results w-96 border p-2 text-base max-h-96 
overflow-y-auto">
+            @if (searching()) {
+                <div class="unset neutral-color italic" 
role="status">Searching</div>
+            } @else {
+                @if (hasResults()) {
+                    <ul role="listbox" aria-label="Search results">
+                        @if (results().processorResults.length > 0) {
+                            <ng-container
+                                *ngTemplateOutlet="
+                                    renderResults;
+                                    context: {
+                                        $implicit: results().processorResults,
+                                        header: 'Processors',
+                                        icon: 'icon-processor',
+                                        path: ComponentType.Processor
+                                    }
+                                "></ng-container>
+                        }
+                        @if (results().connectionResults.length > 0) {
+                            <ng-container
+                                *ngTemplateOutlet="
+                                    renderResults;
+                                    context: {
+                                        $implicit: results().connectionResults,
+                                        header: 'Connections',
+                                        icon: 'icon-connect',
+                                        path: ComponentType.Connection
+                                    }
+                                "></ng-container>
+                        }
+                        @if (results().processGroupResults.length > 0) {
+                            <ng-container
+                                *ngTemplateOutlet="
+                                    renderResults;
+                                    context: {
+                                        $implicit: 
results().processGroupResults,
+                                        header: 'Process Groups',
+                                        icon: 'icon-group',
+                                        path: ComponentType.ProcessGroup
+                                    }
+                                "></ng-container>
+                        }
+                        @if (results().remoteProcessGroupResults.length > 0) {
+                            <ng-container
+                                *ngTemplateOutlet="
+                                    renderResults;
+                                    context: {
+                                        $implicit: 
results().remoteProcessGroupResults,
+                                        header: 'Remote Process Groups',
+                                        icon: 'icon-group-remote',
+                                        path: ComponentType.RemoteProcessGroup
+                                    }
+                                "></ng-container>
+                        }
+                        @if (results().inputPortResults.length > 0) {
+                            <ng-container
+                                *ngTemplateOutlet="
+                                    renderResults;
+                                    context: {
+                                        $implicit: results().inputPortResults,
+                                        header: 'Input Ports',
+                                        icon: 'icon-port-in',
+                                        path: ComponentType.InputPort
+                                    }
+                                "></ng-container>
+                        }
+                        @if (results().outputPortResults.length > 0) {
+                            <ng-container
+                                *ngTemplateOutlet="
+                                    renderResults;
+                                    context: {
+                                        $implicit: results().outputPortResults,
+                                        header: 'Output Ports',
+                                        icon: 'icon-port-out',
+                                        path: ComponentType.OutputPort
+                                    }
+                                "></ng-container>
+                        }
+                        @if (results().funnelResults.length > 0) {
+                            <ng-container
+                                *ngTemplateOutlet="
+                                    renderResults;
+                                    context: {
+                                        $implicit: results().funnelResults,
+                                        header: 'Funnels',
+                                        icon: 'icon-funnel',
+                                        path: ComponentType.Funnel
+                                    }
+                                "></ng-container>
+                        }
+                        @if (results().labelResults.length > 0) {
+                            <ng-container
+                                *ngTemplateOutlet="
+                                    renderResults;
+                                    context: {
+                                        $implicit: results().labelResults,
+                                        header: 'Labels',
+                                        icon: 'icon-label',
+                                        path: ComponentType.Label
+                                    }
+                                "></ng-container>
+                        }
+                        @if (results().controllerServiceNodeResults.length > 
0) {
+                            <ng-container
+                                *ngTemplateOutlet="
+                                    renderResults;
+                                    context: {
+                                        $implicit: 
results().controllerServiceNodeResults,
+                                        header: 'Controller Services',
+                                        icon: '',
+                                        path: ComponentType.ControllerService
+                                    }
+                                "></ng-container>
+                        }
+                        @if (results().parameterProviderNodeResults.length > 
0) {
+                            <ng-container
+                                *ngTemplateOutlet="
+                                    renderResults;
+                                    context: {
+                                        $implicit: 
results().parameterProviderNodeResults,
+                                        header: 'Parameter Providers',
+                                        icon: '',
+                                        path: ComponentType.ParameterProvider
+                                    }
+                                "></ng-container>
+                        }
+                        @if (results().parameterContextResults.length > 0) {
+                            <ng-container
+                                *ngTemplateOutlet="
+                                    renderResults;
+                                    context: {
+                                        $implicit: 
results().parameterContextResults,
+                                        header: 'Parameter Contexts',
+                                        icon: '',
+                                        path: ComponentType.Flow
+                                    }
+                                "></ng-container>
+                        }
+                        @if (results().parameterResults.length > 0) {
+                            <ng-container
+                                *ngTemplateOutlet="
+                                    renderResults;
+                                    context: {
+                                        $implicit: results().parameterResults,
+                                        header: 'Parameters',
+                                        icon: '',
+                                        path: ComponentType.Flow
+                                    }
+                                "></ng-container>
+                        }
+                    </ul>
+                } @else {
+                    <div class="unset neutral-color italic" role="status">No 
results matched the search terms</div>
+                }
+            }
+        </div>
+    </ng-template>
+    <input
+        type="text"
+        matInput
+        placeholder="Search"
+        aria-label="Search canvas components"
+        class="search-input neutral-contrast"
+        (keydown)="onKeydown($event)"
+        [class.open]="searchInputVisible()"
+        [formControl]="searchControl"
+        cdkOverlayOrigin
+        #searchInput="cdkOverlayOrigin" />
+    <ng-template #renderResults let-results let-header="header" 
let-icon="icon" let-path="path">
+        @if (results.length > 0) {
+            <li class="flex items-center" role="presentation">
+                @if (icon) {
+                    <span class="icon mr-1 tertiary-color" 
[class]="icon"></span>
+                }
+                <span class="font-medium">{{ header }}</span>
+            </li>
+            @for (result of results; track $index) {
+                <li class="ml-4 py-1 flex gap-x-2 items-center" role="option">
+                    <i
+                        class="fa fa-info-circle"
+                        nifiTooltip
+                        [tooltipComponentType]="SearchMatchTip"
+                        [tooltipInputData]="getSearchMatchTipInput(result)"
+                        [delayClose]="true"></i>
+                    <a
+                        class="doc-link w-full overflow-ellipsis 
overflow-hidden whitespace-nowrap cursor-pointer"
+                        [title]="result.name"
+                        [class.selected]="result.id === selectedComponentId()"
+                        (click)="resultClicked(result, path)">
+                        {{ result.name ? result.name : result.id }}
+                    </a>
+                </li>
+            }
+        }
+    </ng-template>
+</div>
diff --git 
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/common/breadcrumbs/breadcrumbs.component.scss
 
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/canvas-header-search/canvas-header-search.component.scss
similarity index 77%
rename from 
nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/common/breadcrumbs/breadcrumbs.component.scss
rename to 
nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/canvas-header-search/canvas-header-search.component.scss
index 8a3b7264bd4..f17568f9295 100644
--- 
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/common/breadcrumbs/breadcrumbs.component.scss
+++ 
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/canvas-header-search/canvas-header-search.component.scss
@@ -15,12 +15,16 @@
  * limitations under the License.
  */
 
-.breadcrumbs {
-    a {
-        white-space: nowrap;
+.search-container {
+    .search-input {
+        width: 0;
+        height: 100%;
+        outline: none;
+        transition: width 400ms ease-in-out;
     }
 
-    a.current-process-group {
-        text-decoration: none;
+    .search-input.open {
+        width: 200px;
+        transition: width 400ms ease-in-out;
     }
 }
diff --git 
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/canvas-header-search/canvas-header-search.component.spec.ts
 
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/canvas-header-search/canvas-header-search.component.spec.ts
new file mode 100644
index 00000000000..4c6555ad703
--- /dev/null
+++ 
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/canvas-header-search/canvas-header-search.component.spec.ts
@@ -0,0 +1,388 @@
+/*
+ * 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 { TestBed, fakeAsync, tick, discardPeriodicTasks } from 
'@angular/core/testing';
+import { Component, Directive, Input } from '@angular/core';
+import { Observable, of, throwError } from 'rxjs';
+import { HttpErrorResponse } from '@angular/common/http';
+import { CdkConnectedOverlay } from '@angular/cdk/overlay';
+import { CanvasHeaderSearchComponent } from './canvas-header-search.component';
+import { ComponentType } from '@nifi/shared';
+import { ComponentSearchResult, SearchResultsEntity } from 
'../../../state/shared';
+
+@Directive({ selector: '[cdkConnectedOverlay]', standalone: true })
+class MockCdkConnectedOverlay {
+    @Input() cdkConnectedOverlayOpen: any;
+    @Input() cdkConnectedOverlayOrigin: any;
+    @Input() cdkConnectedOverlayPositions: any;
+    @Input() cdkConnectedOverlayHasBackdrop: any;
+    @Input() cdkConnectedOverlayBackdropClass: any;
+    @Input() cdkConnectedOverlayDisableClose: any;
+}
+
+function createMockResult(overrides: Partial<ComponentSearchResult> = {}): 
ComponentSearchResult {
+    return {
+        id: 'result-1',
+        groupId: 'pg-1',
+        parentGroup: { id: 'pg-1', name: 'Root' },
+        versionedGroup: { id: '', name: '' },
+        name: 'Test Component',
+        matches: ['Name: Test Component'],
+        ...overrides
+    };
+}
+
+function createEmptySearchResults(): SearchResultsEntity {
+    return {
+        searchResultsDTO: {
+            processorResults: [],
+            connectionResults: [],
+            processGroupResults: [],
+            inputPortResults: [],
+            outputPortResults: [],
+            remoteProcessGroupResults: [],
+            funnelResults: [],
+            labelResults: [],
+            controllerServiceNodeResults: [],
+            parameterContextResults: [],
+            parameterProviderNodeResults: [],
+            parameterResults: []
+        }
+    };
+}
+
+@Component({
+    standalone: true,
+    imports: [CanvasHeaderSearchComponent],
+    template: `
+        <canvas-header-search
+            [searchFn]="searchFn"
+            [selectedComponentId]="selectedComponentId"
+            (goToComponent)="onGoToComponent($event)">
+        </canvas-header-search>
+    `
+})
+class TestHostComponent {
+    searchFn: (query: string) => Observable<SearchResultsEntity> = () => 
of(createEmptySearchResults());
+    selectedComponentId: string | null = null;
+    onGoToComponent = vi.fn();
+}
+
+interface SetupOptions {
+    selectedComponentId?: string | null;
+    searchResponse?: SearchResultsEntity;
+    searchError?: HttpErrorResponse;
+}
+
+async function setup(options: SetupOptions = {}) {
+    const mockSearchFn = vi.fn().mockReturnValue(of(options.searchResponse ?? 
createEmptySearchResults()));
+
+    if (options.searchError) {
+        mockSearchFn.mockReturnValue(throwError(() => options.searchError));
+    }
+
+    await TestBed.configureTestingModule({
+        imports: [TestHostComponent]
+    })
+        .overrideComponent(CanvasHeaderSearchComponent, {
+            remove: { imports: [CdkConnectedOverlay] },
+            add: { imports: [MockCdkConnectedOverlay] }
+        })
+        .compileComponents();
+
+    const hostFixture = TestBed.createComponent(TestHostComponent);
+    const host = hostFixture.componentInstance;
+
+    host.searchFn = mockSearchFn;
+
+    if (options.selectedComponentId !== undefined) {
+        host.selectedComponentId = options.selectedComponentId;
+    }
+
+    hostFixture.detectChanges();
+
+    const searchEl = hostFixture.debugElement.children[0];
+    const searchComponent = searchEl.componentInstance as 
CanvasHeaderSearchComponent;
+
+    return { hostFixture, host, searchComponent, mockSearchFn };
+}
+
+describe('CanvasHeaderSearchComponent', () => {
+    beforeEach(() => {
+        vi.clearAllMocks();
+    });
+
+    describe('Component initialization', () => {
+        it('should create', async () => {
+            const { searchComponent } = await setup();
+            expect(searchComponent).toBeTruthy();
+        });
+
+        it('should render the search container', async () => {
+            const { hostFixture } = await setup();
+            const container = 
hostFixture.nativeElement.querySelector('.search-container');
+            expect(container).toBeTruthy();
+        });
+
+        it('should render the search toggle button', async () => {
+            const { hostFixture } = await setup();
+            const button = 
hostFixture.nativeElement.querySelector('.search-container button');
+            expect(button).toBeTruthy();
+        });
+
+        it('should have search input hidden by default', async () => {
+            const { searchComponent } = await setup();
+            expect(searchComponent.searchInputVisible()).toBe(false);
+        });
+    });
+
+    describe('Search visibility toggle', () => {
+        it('should show search input when toggle button is clicked', async () 
=> {
+            const { hostFixture, searchComponent } = await setup();
+            const button = 
hostFixture.nativeElement.querySelector('.search-container button');
+            button.click();
+            expect(searchComponent.searchInputVisible()).toBe(true);
+        });
+
+        it('should hide search input when toggle button is clicked twice', 
async () => {
+            const { hostFixture, searchComponent } = await setup();
+            const button = 
hostFixture.nativeElement.querySelector('.search-container button');
+            button.click();
+            button.click();
+            expect(searchComponent.searchInputVisible()).toBe(false);
+        });
+    });
+
+    describe('Search execution', () => {
+        it('should call searchFn after debounce when input has value', 
fakeAsync(async () => {
+            const { searchComponent, mockSearchFn } = await setup();
+
+            searchComponent.searchControl.setValue('test query');
+            tick(500);
+
+            expect(mockSearchFn).toHaveBeenCalledWith('test query');
+        }));
+
+        it('should not call searchFn for empty input', fakeAsync(async () => {
+            const { searchComponent, mockSearchFn } = await setup();
+
+            searchComponent.searchControl.setValue('   ');
+            tick(500);
+
+            expect(mockSearchFn).not.toHaveBeenCalled();
+        }));
+
+        it('should set searching to false after request completes', 
fakeAsync(async () => {
+            const { searchComponent } = await setup();
+
+            searchComponent.searchControl.setValue('test');
+            tick(500);
+
+            expect(searchComponent.searching()).toBe(false);
+        }));
+
+        it('should populate results on successful search', fakeAsync(async () 
=> {
+            const processor = createMockResult({ id: 'p1', name: 'MyProcessor' 
});
+            const searchResponse = createEmptySearchResults();
+            searchResponse.searchResultsDTO.processorResults = [processor];
+
+            const { searchComponent } = await setup({ searchResponse });
+
+            searchComponent.searchControl.setValue('My');
+            tick(500);
+
+            
expect(searchComponent.results().processorResults).toEqual([processor]);
+            expect(searchComponent.searchingResultsVisible()).toBe(true);
+        }));
+
+        it('should populate all result categories', fakeAsync(async () => {
+            const searchResponse = createEmptySearchResults();
+            searchResponse.searchResultsDTO.processorResults = 
[createMockResult({ id: 'p1' })];
+            searchResponse.searchResultsDTO.connectionResults = 
[createMockResult({ id: 'c1' })];
+            searchResponse.searchResultsDTO.processGroupResults = 
[createMockResult({ id: 'pg1' })];
+            searchResponse.searchResultsDTO.controllerServiceNodeResults = 
[createMockResult({ id: 'cs1' })];
+
+            const { searchComponent } = await setup({ searchResponse });
+
+            searchComponent.searchControl.setValue('test');
+            tick(500);
+
+            expect(searchComponent.results().processorResults.length).toBe(1);
+            expect(searchComponent.results().connectionResults.length).toBe(1);
+            
expect(searchComponent.results().processGroupResults.length).toBe(1);
+            
expect(searchComponent.results().controllerServiceNodeResults.length).toBe(1);
+        }));
+
+        it('should hide results and stop searching on error', fakeAsync(async 
() => {
+            const { searchComponent } = await setup({
+                searchError: new HttpErrorResponse({ status: 500, statusText: 
'Server Error' })
+            });
+
+            searchComponent.searchControl.setValue('fail');
+            tick(500);
+
+            expect(searchComponent.searchingResultsVisible()).toBe(false);
+            expect(searchComponent.searching()).toBe(false);
+        }));
+    });
+
+    describe('hasResults', () => {
+        it('should return false when all results are empty', async () => {
+            const { searchComponent } = await setup();
+            expect(searchComponent.hasResults()).toBe(false);
+        });
+
+        it('should return true when processorResults has entries', 
fakeAsync(async () => {
+            const processor = createMockResult();
+            const searchResponse = createEmptySearchResults();
+            searchResponse.searchResultsDTO.processorResults = [processor];
+
+            const { searchComponent } = await setup({ searchResponse });
+
+            searchComponent.searchControl.setValue('test');
+            tick(500);
+
+            expect(searchComponent.hasResults()).toBe(true);
+        }));
+
+        it('should return true when controllerServiceNodeResults has entries', 
fakeAsync(async () => {
+            const searchResponse = createEmptySearchResults();
+            searchResponse.searchResultsDTO.controllerServiceNodeResults = 
[createMockResult({ id: 'cs1' })];
+
+            const { searchComponent } = await setup({ searchResponse });
+
+            searchComponent.searchControl.setValue('test');
+            tick(500);
+
+            expect(searchComponent.hasResults()).toBe(true);
+        }));
+    });
+
+    describe('Result click', () => {
+        it('should emit goToComponent with result and type when a result is 
clicked', fakeAsync(async () => {
+            const processor = createMockResult({ id: 'p1', groupId: 'pg-1' });
+            const searchResponse = createEmptySearchResults();
+            searchResponse.searchResultsDTO.processorResults = [processor];
+
+            const { host, searchComponent } = await setup({ searchResponse });
+
+            searchComponent.searchControl.setValue('test');
+            tick(500);
+
+            searchComponent.resultClicked(processor, ComponentType.Processor);
+
+            expect(host.onGoToComponent).toHaveBeenCalledWith({
+                result: processor,
+                type: ComponentType.Processor
+            });
+        }));
+    });
+
+    describe('Backdrop click', () => {
+        it('should clear results on backdrop click', fakeAsync(async () => {
+            const processor = createMockResult();
+            const searchResponse = createEmptySearchResults();
+            searchResponse.searchResultsDTO.processorResults = [processor];
+
+            const { searchComponent } = await setup({ searchResponse });
+
+            searchComponent.searchControl.setValue('test');
+            tick(500);
+            expect(searchComponent.searchingResultsVisible()).toBe(true);
+
+            const mockEvent = { stopPropagation: vi.fn(), preventDefault: 
vi.fn() } as unknown as MouseEvent;
+            searchComponent.backdropClicked(mockEvent);
+
+            expect(searchComponent.searchingResultsVisible()).toBe(false);
+            expect(searchComponent.results().processorResults).toEqual([]);
+            expect(searchComponent.searchControl.value).toBe('');
+            discardPeriodicTasks();
+        }));
+    });
+
+    describe('Keyboard handling', () => {
+        it('should hide results on Escape key', fakeAsync(async () => {
+            const processor = createMockResult();
+            const searchResponse = createEmptySearchResults();
+            searchResponse.searchResultsDTO.processorResults = [processor];
+
+            const { searchComponent } = await setup({ searchResponse });
+
+            searchComponent.searchControl.setValue('test');
+            tick(500);
+
+            searchComponent.onKeydown({ key: 'Escape' } as KeyboardEvent);
+
+            expect(searchComponent.searchingResultsVisible()).toBe(false);
+            discardPeriodicTasks();
+        }));
+
+        it('should not hide results on non-Escape key', fakeAsync(async () => {
+            const processor = createMockResult();
+            const searchResponse = createEmptySearchResults();
+            searchResponse.searchResultsDTO.processorResults = [processor];
+
+            const { searchComponent } = await setup({ searchResponse });
+
+            searchComponent.searchControl.setValue('test');
+            tick(500);
+
+            searchComponent.onKeydown({ key: 'Enter' } as KeyboardEvent);
+
+            expect(searchComponent.searchingResultsVisible()).toBe(true);
+        }));
+    });
+
+    describe('getSearchMatchTipInput', () => {
+        it('should return matches from the result', async () => {
+            const { searchComponent } = await setup();
+            const result = createMockResult({ matches: ['Name: Foo', 'Type: 
Bar'] });
+
+            const tipInput = searchComponent.getSearchMatchTipInput(result);
+
+            expect(tipInput).toEqual({ matches: ['Name: Foo', 'Type: Bar'] });
+        });
+    });
+
+    describe('Accessibility', () => {
+        it('should have aria-label on the search button', async () => {
+            const { hostFixture } = await setup();
+            const button = 
hostFixture.nativeElement.querySelector('.search-container button');
+            expect(button.getAttribute('aria-label')).toBe('Search canvas');
+        });
+
+        it('should have aria-expanded="false" on the search button by 
default', async () => {
+            const { hostFixture } = await setup();
+            const button = 
hostFixture.nativeElement.querySelector('.search-container button');
+            expect(button.getAttribute('aria-expanded')).toBe('false');
+        });
+
+        it('should have aria-expanded="true" on the search button after 
clicking', async () => {
+            const { hostFixture } = await setup();
+            const button = 
hostFixture.nativeElement.querySelector('.search-container button');
+            button.click();
+            hostFixture.detectChanges();
+            expect(button.getAttribute('aria-expanded')).toBe('true');
+        });
+
+        it('should have aria-label on the search input', async () => {
+            const { hostFixture } = await setup();
+            const input = 
hostFixture.nativeElement.querySelector('.search-input');
+            expect(input.getAttribute('aria-label')).toBe('Search canvas 
components');
+        });
+    });
+});
diff --git 
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/canvas-header-search/canvas-header-search.component.ts
 
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/canvas-header-search/canvas-header-search.component.ts
new file mode 100644
index 00000000000..e36574235ec
--- /dev/null
+++ 
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/canvas-header-search/canvas-header-search.component.ts
@@ -0,0 +1,184 @@
+/*
+ * 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 {
+    afterNextRender,
+    Component,
+    DestroyRef,
+    inject,
+    Injector,
+    input,
+    output,
+    signal,
+    viewChild
+} from '@angular/core';
+import { FormControl, ReactiveFormsModule } from '@angular/forms';
+import { catchError, debounceTime, EMPTY, filter, Observable, switchMap, tap } 
from 'rxjs';
+import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
+import {
+    CdkConnectedOverlay,
+    CdkOverlayOrigin,
+    ConnectionPositionPair,
+    OriginConnectionPosition,
+    OverlayConnectionPosition
+} from '@angular/cdk/overlay';
+import { NgTemplateOutlet } from '@angular/common';
+import { MatFormFieldModule } from '@angular/material/form-field';
+import { MatInputModule } from '@angular/material/input';
+import { ComponentSearchResult, SearchMatchTipInput, SearchResults, 
SearchResultsEntity } from '../../../state/shared';
+import { ComponentType, NifiTooltipDirective } from '@nifi/shared';
+import { SearchMatchTip } from 
'../tooltips/search-match-tip/search-match-tip.component';
+
+const EMPTY_SEARCH_RESULTS: SearchResults = {
+    processorResults: [],
+    connectionResults: [],
+    processGroupResults: [],
+    inputPortResults: [],
+    outputPortResults: [],
+    remoteProcessGroupResults: [],
+    funnelResults: [],
+    labelResults: [],
+    controllerServiceNodeResults: [],
+    parameterContextResults: [],
+    parameterProviderNodeResults: [],
+    parameterResults: []
+};
+
+@Component({
+    selector: 'canvas-header-search',
+    standalone: true,
+    imports: [
+        ReactiveFormsModule,
+        CdkOverlayOrigin,
+        CdkConnectedOverlay,
+        NgTemplateOutlet,
+        MatFormFieldModule,
+        MatInputModule,
+        NifiTooltipDirective
+    ],
+    templateUrl: './canvas-header-search.component.html',
+    styleUrls: ['./canvas-header-search.component.scss']
+})
+export class CanvasHeaderSearchComponent {
+    private destroyRef = inject(DestroyRef);
+    private injector = inject(Injector);
+
+    protected readonly ComponentType = ComponentType;
+    protected readonly SearchMatchTip = SearchMatchTip;
+
+    searchFn = input.required<(query: string) => 
Observable<SearchResultsEntity>>();
+    selectedComponentId = input<string | null>(null);
+
+    goToComponent = output<{ result: ComponentSearchResult; type: 
ComponentType }>();
+
+    searchInput = viewChild.required('searchInput', { read: CdkOverlayOrigin 
});
+
+    private originPos: OriginConnectionPosition = { originX: 'end', originY: 
'bottom' };
+    private overlayPos: OverlayConnectionPosition = { overlayX: 'end', 
overlayY: 'top' };
+    private position = new ConnectionPositionPair(this.originPos, 
this.overlayPos, 0, 2);
+    positions: ConnectionPositionPair[] = [this.position];
+
+    searchControl = new FormControl('');
+    searchInputVisible = signal(false);
+    searching = signal(false);
+    searchingResultsVisible = signal(false);
+    results = signal<SearchResults>(EMPTY_SEARCH_RESULTS);
+
+    constructor() {
+        this.searchControl.valueChanges
+            .pipe(
+                takeUntilDestroyed(this.destroyRef),
+                filter((query): query is string => query !== null && 
query.trim().length > 0),
+                debounceTime(500),
+                tap(() => this.searching.set(true)),
+                switchMap((query) =>
+                    this.searchFn()(query).pipe(
+                        catchError((_err: unknown) => {
+                            this.searchingResultsVisible.set(false);
+                            this.searching.set(false);
+                            return EMPTY;
+                        })
+                    )
+                )
+            )
+            .subscribe((response) => {
+                const dto = response.searchResultsDTO;
+                this.results.set({
+                    processorResults: dto.processorResults,
+                    connectionResults: dto.connectionResults,
+                    processGroupResults: dto.processGroupResults,
+                    inputPortResults: dto.inputPortResults,
+                    outputPortResults: dto.outputPortResults,
+                    remoteProcessGroupResults: dto.remoteProcessGroupResults,
+                    funnelResults: dto.funnelResults,
+                    labelResults: dto.labelResults,
+                    controllerServiceNodeResults: 
dto.controllerServiceNodeResults ?? [],
+                    parameterContextResults: dto.parameterContextResults ?? [],
+                    parameterProviderNodeResults: 
dto.parameterProviderNodeResults ?? [],
+                    parameterResults: dto.parameterResults ?? []
+                });
+                this.searchingResultsVisible.set(true);
+                this.searching.set(false);
+            });
+    }
+
+    toggleSearchVisibility(): void {
+        this.searchInputVisible.update((v) => !v);
+
+        if (this.searchInputVisible()) {
+            afterNextRender(
+                () => {
+                    this.searchInput().elementRef.nativeElement.focus();
+                },
+                { injector: this.injector }
+            );
+        }
+    }
+
+    hasResults(): boolean {
+        return Object.values(this.results()).some((arr) => arr.length > 0);
+    }
+
+    backdropClicked(event: MouseEvent): void {
+        event.stopPropagation();
+        event.preventDefault();
+        this.clearResults();
+    }
+
+    private clearResults(): void {
+        this.searchingResultsVisible.set(false);
+        this.searchControl.setValue('');
+        this.results.set(EMPTY_SEARCH_RESULTS);
+    }
+
+    getSearchMatchTipInput(result: ComponentSearchResult): SearchMatchTipInput 
{
+        return { matches: result.matches };
+    }
+
+    resultClicked(result: ComponentSearchResult, componentType: 
ComponentType): void {
+        this.goToComponent.emit({
+            result,
+            type: componentType
+        });
+    }
+
+    onKeydown(event: KeyboardEvent): void {
+        if (event.key === 'Escape') {
+            this.searchingResultsVisible.set(false);
+        }
+    }
+}

Reply via email to