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);
+ }
+ }
+}