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 c68df8b00ab7..0477e70fa0db 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 @@ -29,7 +29,21 @@
- + 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 a6e49cfe6175..d28f81a04e04 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 @@ -21,8 +21,10 @@ import { ComponentFixture, TestBed, fakeAsync, tick } from '@angular/core/testin import { NoopAnimationsModule } from '@angular/platform-browser/animations'; import { provideRouter, Router } from '@angular/router'; import { MatDialog, MatDialogModule } from '@angular/material/dialog'; +import { provideMockActions } from '@ngrx/effects/testing'; +import { Action } from '@ngrx/store'; import { MockStore, provideMockStore } from '@ngrx/store/testing'; -import { firstValueFrom } from 'rxjs'; +import { ReplaySubject, firstValueFrom } from 'rxjs'; import { take } from 'rxjs/operators'; import { ComponentType, selectRouteParams, selectUrl } from '@nifi/shared'; @@ -49,6 +51,7 @@ import { enterProcessGroup, leaveProcessGroup, loadConnectorFlow, + loadConnectorFlowSuccess, navigateToControllerService, navigateToControllerServices, navigateToProvenanceForComponent, @@ -94,6 +97,15 @@ class MockReusableCanvasComponent { contextMenuOpened = output(); centerOnSelection = vi.fn(); centerOnComponent = vi.fn(); + getBirdseyeComponentData = vi.fn().mockReturnValue([]); + getCanvasDimensions = vi.fn().mockReturnValue({ width: 1024, height: 768 }); + onZoomIn = vi.fn(); + onZoomOut = vi.fn(); + onZoomFit = vi.fn(); + onZoomActual = vi.fn(); + setViewportPosition = vi.fn(); + birdseyeDragStart = vi.fn(); + birdseyeDragEnd = vi.fn(); } @Component({ @@ -140,6 +152,18 @@ class MockConnectorCanvasFooterComponent { class MockConnectorGraphControls { connectorEntity = input(null); entitySaving = input(false); + birdseyeComponents = input([]); + birdseyeTransform = input({ translate: { x: 0, y: 0 }, scale: 1 }); + canvasDimensions = input({ width: 0, height: 0 }); + canNavigateToParent = input(false); + viewportChange = output<{ x: number; y: number }>(); + birdseyeDragStart = output(); + birdseyeDragEnd = output(); + zoomIn = output(); + zoomOut = output(); + zoomFit = output(); + zoomActual = output(); + leaveGroup = output(); } @Component({ @@ -241,13 +265,17 @@ function createMockStorage() { }; } +let actions$: ReplaySubject; + function configureConnectorCanvasTestBed(options: SetupOptions = {}, storage?: ReturnType) { TestBed.resetTestingModule(); const storageMock = storage ?? createMockStorage(); + actions$ = new ReplaySubject(1); TestBed.configureTestingModule({ imports: [ConnectorCanvasComponent, NoopAnimationsModule, MatDialogModule], providers: [ provideRouter([]), + provideMockActions(() => actions$), provideMockStore({ initialState: {}, selectors: buildMockSelectors(options) }), { provide: Storage, useValue: storageMock } ] @@ -299,6 +327,21 @@ function dispatchWindowKeydown(key: string, modifiers: { ctrlKey?: boolean; meta window.dispatchEvent(event); } +function getCanvasMock(fixture: ComponentFixture): MockReusableCanvasComponent { + return fixture.debugElement.query((el) => el.name === 'reusable-canvas') + .componentInstance as MockReusableCanvasComponent; +} + +// viewChild.required(CanvasComponent) cannot resolve to the test mock because the mock has a +// different class identity than the real CanvasComponent. Stubbing the signal lets the SUT's +// birdseye / navigation handlers call into the mock directly. +function attachCanvasMock(component: ConnectorCanvasComponent, mock: MockReusableCanvasComponent): void { + Object.defineProperty(component, 'canvasComponent', { + configurable: true, + value: () => mock + }); +} + describe('ConnectorCanvasComponent', () => { beforeEach(() => { vi.clearAllMocks(); @@ -346,7 +389,7 @@ describe('ConnectorCanvasComponent', () => { // Should only dispatch setConfiguration, NOT loadConnectorFlow const loadFlowDispatches = dispatchSpy.mock.calls.filter( - (call) => (call[0] as { type: string }).type === loadConnectorFlow.type + (call: unknown[]) => (call[0] as { type: string }).type === loadConnectorFlow.type ); expect(loadFlowDispatches).toHaveLength(0); })); @@ -444,7 +487,7 @@ describe('ConnectorCanvasComponent', () => { component.leaveGroupAction(); const leaveDispatches = dispatchSpy.mock.calls.filter( - (call) => (call[0] as { type: string }).type === leaveProcessGroup.type + (call: unknown[]) => (call[0] as { type: string }).type === leaveProcessGroup.type ); expect(leaveDispatches).toHaveLength(0); })); @@ -479,7 +522,7 @@ describe('ConnectorCanvasComponent', () => { dispatchWindowKeydown('Escape'); const leaveDispatches = dispatchSpy.mock.calls.filter( - (call) => (call[0] as { type: string }).type === leaveProcessGroup.type + (call: unknown[]) => (call[0] as { type: string }).type === leaveProcessGroup.type ); expect(leaveDispatches).toHaveLength(0); @@ -514,7 +557,7 @@ describe('ConnectorCanvasComponent', () => { dispatchWindowKeydown('r', { ctrlKey: true }); const loadFlowDispatches = dispatchSpy.mock.calls.filter( - (call) => (call[0] as { type: string }).type === loadConnectorFlow.type + (call: unknown[]) => (call[0] as { type: string }).type === loadConnectorFlow.type ); expect(loadFlowDispatches).toHaveLength(0); @@ -537,7 +580,7 @@ describe('ConnectorCanvasComponent', () => { searchEl.dispatchEvent(escapeEvent); const leaveDispatches = dispatchSpy.mock.calls.filter( - (call) => (call[0] as { type: string }).type === leaveProcessGroup.type + (call: unknown[]) => (call[0] as { type: string }).type === leaveProcessGroup.type ); expect(leaveDispatches).toHaveLength(0); @@ -546,7 +589,7 @@ describe('ConnectorCanvasComponent', () => { searchEl.dispatchEvent(refreshEvent); const loadFlowDispatches = dispatchSpy.mock.calls.filter( - (call) => (call[0] as { type: string }).type === loadConnectorFlow.type + (call: unknown[]) => (call[0] as { type: string }).type === loadConnectorFlow.type ); expect(loadFlowDispatches).toHaveLength(0); @@ -568,7 +611,9 @@ describe('ConnectorCanvasComponent', () => { inputEl.dispatchEvent(escapeEvent); expect( - dispatchSpy.mock.calls.filter((call) => (call[0] as { type: string }).type === leaveProcessGroup.type) + dispatchSpy.mock.calls.filter( + (call: unknown[]) => (call[0] as { type: string }).type === leaveProcessGroup.type + ) ).toHaveLength(0); dispatchSpy.mockClear(); @@ -576,7 +621,9 @@ describe('ConnectorCanvasComponent', () => { inputEl.dispatchEvent(refreshEvent); expect( - dispatchSpy.mock.calls.filter((call) => (call[0] as { type: string }).type === loadConnectorFlow.type) + dispatchSpy.mock.calls.filter( + (call: unknown[]) => (call[0] as { type: string }).type === loadConnectorFlow.type + ) ).toHaveLength(0); document.body.removeChild(inputEl); @@ -664,6 +711,7 @@ describe('ConnectorCanvasComponent', () => { }); fixture.detectChanges(); tick(); + attachCanvasMock(component, getCanvasMock(fixture)); dispatchSpy.mockClear(); component.onCanvasInitialized(); @@ -675,6 +723,7 @@ describe('ConnectorCanvasComponent', () => { const { fixture, component, dispatchSpy } = setup(); fixture.detectChanges(); tick(); + attachCanvasMock(component, getCanvasMock(fixture)); dispatchSpy.mockClear(); component.onCanvasInitialized(); @@ -1875,4 +1924,204 @@ describe('ConnectorCanvasComponent', () => { expect(canvasDebugEl.componentInstance.menuProvider()).toBeTruthy(); })); }); + + describe('Navigation control / birdseye wiring', () => { + function getGraphControlsMock(fixture: ComponentFixture): MockConnectorGraphControls { + return fixture.debugElement.query((el) => el.name === 'connector-graph-controls') + .componentInstance as MockConnectorGraphControls; + } + + it('should seed birdseyeComponents and canvasDimensions from the canvas on initialization', fakeAsync(() => { + const { fixture, component } = setup(); + fixture.detectChanges(); + tick(); + + const canvas = getCanvasMock(fixture); + attachCanvasMock(component, canvas); + const seededComponents = [ + { + id: 'p1', + type: ComponentType.Processor, + position: { x: 1, y: 2 }, + dimensions: { width: 10, height: 20 } + } + ]; + canvas.getBirdseyeComponentData.mockReturnValue(seededComponents); + canvas.getCanvasDimensions.mockReturnValue({ width: 1024, height: 768 }); + + component.onCanvasInitialized(); + + expect(canvas.getBirdseyeComponentData).toHaveBeenCalled(); + expect(canvas.getCanvasDimensions).toHaveBeenCalled(); + expect(component.birdseyeComponents()).toEqual(seededComponents); + expect(component.canvasDimensions()).toEqual({ width: 1024, height: 768 }); + })); + + it('should update birdseyeTransform when the canvas emits transformChange', fakeAsync(() => { + const { fixture, component } = setup(); + fixture.detectChanges(); + tick(); + + const transform = { translate: { x: -100, y: -50 }, scale: 0.5 }; + component.onTransformChange(transform); + + expect(component.birdseyeTransform()).toEqual(transform); + })); + + it('should refresh birdseye components when loadConnectorFlowSuccess is dispatched', async () => { + const { fixture, component } = setup(); + fixture.detectChanges(); + + const canvas = getCanvasMock(fixture); + attachCanvasMock(component, canvas); + component.onCanvasInitialized(); + + const refreshedComponents = [ + { + id: 'p2', + type: ComponentType.Processor, + position: { x: 5, y: 6 }, + dimensions: { width: 30, height: 40 } + } + ]; + canvas.getBirdseyeComponentData.mockReturnValue(refreshedComponents); + + actions$.next( + loadConnectorFlowSuccess({ + connectorId: DEFAULT_CONNECTOR_ID, + processGroupId: DEFAULT_PROCESS_GROUP_ID, + parentProcessGroupId: null, + breadcrumb: null, + labels: [], + funnels: [], + inputPorts: [], + outputPorts: [], + remoteProcessGroups: [], + processGroups: [], + processors: [], + connections: [] + }) + ); + + // queueMicrotask defers the refresh; await a microtask to let it run + await Promise.resolve(); + + expect(component.birdseyeComponents()).toEqual(refreshedComponents); + }); + + it('should delegate zoom buttons to the canvas', fakeAsync(() => { + const { fixture, component } = setup(); + fixture.detectChanges(); + tick(); + + const canvas = getCanvasMock(fixture); + attachCanvasMock(component, canvas); + + component.onNavigationZoomIn(); + component.onNavigationZoomOut(); + component.onNavigationZoomFit(); + component.onNavigationZoomActual(); + + expect(canvas.onZoomIn).toHaveBeenCalledTimes(1); + expect(canvas.onZoomOut).toHaveBeenCalledTimes(1); + expect(canvas.onZoomFit).toHaveBeenCalledTimes(1); + expect(canvas.onZoomActual).toHaveBeenCalledTimes(1); + })); + + it('should delegate leave group via leaveGroupAction', fakeAsync(() => { + const { fixture, component, dispatchSpy } = setup({ parentProcessGroupId: 'parent-pg' }); + fixture.detectChanges(); + tick(); + dispatchSpy.mockClear(); + + component.onNavigationLeaveGroup(); + + expect(dispatchSpy).toHaveBeenCalledWith(leaveProcessGroup()); + })); + + it('should delegate birdseye viewport change to the canvas', fakeAsync(() => { + const { fixture, component } = setup(); + fixture.detectChanges(); + tick(); + + const canvas = getCanvasMock(fixture); + attachCanvasMock(component, canvas); + + component.onBirdseyeViewportChange({ x: 42, y: 84 }); + + expect(canvas.setViewportPosition).toHaveBeenCalledWith(42, 84, false); + })); + + it('should delegate birdseye drag start/end to the canvas', fakeAsync(() => { + const { fixture, component } = setup(); + fixture.detectChanges(); + tick(); + + const canvas = getCanvasMock(fixture); + attachCanvasMock(component, canvas); + + component.onBirdseyeDragStart(); + component.onBirdseyeDragEnd(); + + expect(canvas.birdseyeDragStart).toHaveBeenCalledTimes(1); + expect(canvas.birdseyeDragEnd).toHaveBeenCalledTimes(1); + })); + + it('should refresh canvasDimensions when the window resizes after canvas init', fakeAsync(() => { + const { fixture, component } = setup(); + fixture.detectChanges(); + tick(); + + const canvas = getCanvasMock(fixture); + attachCanvasMock(component, canvas); + component.onCanvasInitialized(); + canvas.getCanvasDimensions.mockReturnValue({ width: 1600, height: 900 }); + + component.handleWindowResize(); + + expect(component.canvasDimensions()).toEqual({ width: 1600, height: 900 }); + })); + + it('should not call into the canvas on resize before initialization', fakeAsync(() => { + const { fixture, component } = setup(); + fixture.detectChanges(); + tick(); + + const canvas = getCanvasMock(fixture); + canvas.getCanvasDimensions.mockClear(); + + component.handleWindowResize(); + + expect(canvas.getCanvasDimensions).not.toHaveBeenCalled(); + })); + + it('should pass birdseye signals through to connector-graph-controls', fakeAsync(() => { + const { fixture, component } = setup({ parentProcessGroupId: 'parent-pg' }); + fixture.detectChanges(); + tick(); + + const canvas = getCanvasMock(fixture); + attachCanvasMock(component, canvas); + const seeded = [ + { + id: 'p1', + type: ComponentType.Processor, + position: { x: 0, y: 0 }, + dimensions: { width: 10, height: 10 } + } + ]; + canvas.getBirdseyeComponentData.mockReturnValue(seeded); + canvas.getCanvasDimensions.mockReturnValue({ width: 800, height: 600 }); + component.onCanvasInitialized(); + component.onTransformChange({ translate: { x: -10, y: -20 }, scale: 0.75 }); + fixture.detectChanges(); + + const graphControls = getGraphControlsMock(fixture); + + expect(graphControls.birdseyeComponents()).toEqual(seeded); + expect(graphControls.birdseyeTransform()).toEqual({ translate: { x: -10, y: -20 }, scale: 0.75 }); + expect(graphControls.canvasDimensions()).toEqual({ width: 800, height: 600 }); + expect(graphControls.canNavigateToParent()).toBe(true); + })); + }); }); 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 bcc1a9f57abe..84e762ea1eeb 100644 --- a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/ui/connector-canvas/connector-canvas.component.ts +++ b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/ui/connector-canvas/connector-canvas.component.ts @@ -15,13 +15,24 @@ * limitations under the License. */ -import { Component, computed, OnDestroy, OnInit, DestroyRef, inject, HostListener, viewChild } from '@angular/core'; +import { + Component, + computed, + OnDestroy, + OnInit, + DestroyRef, + inject, + HostListener, + signal, + viewChild +} from '@angular/core'; import { CommonModule } from '@angular/common'; import { Router } from '@angular/router'; import { MatButton } from '@angular/material/button'; import { MatDialog } from '@angular/material/dialog'; import { MatSidenav, MatSidenavContainer, MatSidenavContent } from '@angular/material/sidenav'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { Actions, ofType } from '@ngrx/effects'; import { Store } from '@ngrx/store'; import { canOperateConnector, @@ -38,7 +49,8 @@ import { selectCurrentUser } from '../../../../state/current-user/current-user.s import { CanvasConfiguration } from '../../../../state/canvas-ui'; import { setConfiguration } from '../../../../state/canvas-ui/canvas-ui.actions'; import { CanvasComponent } from '../../../../ui/common/canvas/canvas.component'; -import { ContextMenuContext } from '../../../../ui/common/canvas/canvas.types'; +import { BirdseyeComponentData, BirdseyeTransform } from '../../../../ui/common/birdseye/birdseye.types'; +import { ContextMenuContext, Dimension, Position } from '../../../../ui/common/canvas/canvas.types'; import { ContextMenuDefinition, ContextMenuDefinitionProvider, @@ -80,6 +92,7 @@ import { getComponentStateAndOpenDialog } from '../../../../state/component-stat }) export class ConnectorCanvasComponent implements OnInit, OnDestroy { private store = inject(Store); + private actions$ = inject(Actions); private destroyRef = inject(DestroyRef); private router = inject(Router); private dialog = inject(MatDialog); @@ -97,6 +110,12 @@ export class ConnectorCanvasComponent implements OnInit, OnDestroy { skipTransform = this.store.selectSignal(ConnectorCanvasSelectors.selectSkipTransform); graphControlsOpen = true; + birdseyeComponents = signal([]); + birdseyeTransform = signal({ translate: { x: 0, y: 0 }, scale: 1 }); + canvasDimensions = signal({ width: 0, height: 0 }); + + private canvasReady = false; + connectorEntity = this.store.selectSignal(selectConnectorCanvasEntity); entitySaving = this.store.selectSignal(selectConnectorCanvasEntitySaving); @@ -360,6 +379,18 @@ export class ConnectorCanvasComponent implements OnInit, OnDestroy { .subscribe((parentProcessGroupId) => { this.canNavigateToParent = parentProcessGroupId != null; }); + + // Refresh birdseye geometry whenever a flow load completes (initial load or refresh). + // Without this, the minimap would only reflect the data captured during the very first + // canvas initialization and would go stale after subsequent refreshes / process-group + // navigations performed against an already-mounted canvas. + this.actions$ + .pipe(ofType(ConnectorCanvasActions.loadConnectorFlowSuccess), takeUntilDestroyed(this.destroyRef)) + .subscribe(() => { + // Defer one tick so the entity arrays flow through async pipes into the canvas + // and the canvas's internal signals (read by getBirdseyeComponentData) update. + queueMicrotask(() => this.refreshBirdseye()); + }); } ngOnDestroy(): void { @@ -367,8 +398,15 @@ export class ConnectorCanvasComponent implements OnInit, OnDestroy { this.store.dispatch(ConnectorCanvasEntityActions.resetConnectorCanvasEntityState()); } - onTransformChange(_event: { translate: { x: number; y: number }; scale: number }): void { - // placeholder for future viewport persistence + @HostListener('window:resize') + handleWindowResize(): void { + if (this.canvasReady) { + this.canvasDimensions.set(this.canvasComponent().getCanvasDimensions()); + } + } + + onTransformChange(event: BirdseyeTransform): void { + this.birdseyeTransform.set(event); } onProcessGroupDoubleClick(event: { processGroupId: string }): void { @@ -407,6 +445,9 @@ export class ConnectorCanvasComponent implements OnInit, OnDestroy { } onCanvasInitialized(): void { + this.canvasReady = true; + this.refreshBirdseye(); + if (this.selectedComponentIds.length > 0) { if (this.skipTransform()) { this.store.dispatch(ConnectorCanvasActions.setSkipTransform({ skipTransform: false })); @@ -416,6 +457,51 @@ export class ConnectorCanvasComponent implements OnInit, OnDestroy { } } + private refreshBirdseye(): void { + if (!this.canvasReady) { + return; + } + const canvas = this.canvasComponent(); + this.birdseyeComponents.set(canvas.getBirdseyeComponentData()); + this.canvasDimensions.set(canvas.getCanvasDimensions()); + } + + // ========================================================================= + // Navigation Control / Birdseye delegation + // ========================================================================= + + onNavigationZoomIn(): void { + this.canvasComponent().onZoomIn(); + } + + onNavigationZoomOut(): void { + this.canvasComponent().onZoomOut(); + } + + onNavigationZoomFit(): void { + this.canvasComponent().onZoomFit(); + } + + onNavigationZoomActual(): void { + this.canvasComponent().onZoomActual(); + } + + onNavigationLeaveGroup(): void { + this.leaveGroupAction(); + } + + onBirdseyeViewportChange(event: Position): void { + this.canvasComponent().setViewportPosition(event.x, event.y, false); + } + + onBirdseyeDragStart(): void { + this.canvasComponent().birdseyeDragStart(); + } + + onBirdseyeDragEnd(): void { + this.canvasComponent().birdseyeDragEnd(); + } + // ========================================================================= // Shared Actions (used by both context menu and keyboard shortcuts) // ========================================================================= diff --git a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/ui/connector-canvas/graph-controls/connector-graph-controls.component.html b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/ui/connector-canvas/graph-controls/connector-graph-controls.component.html index 85fd960e8f54..4ecfbe94ec13 100644 --- a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/ui/connector-canvas/graph-controls/connector-graph-controls.component.html +++ b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/ui/connector-canvas/graph-controls/connector-graph-controls.component.html @@ -17,7 +17,21 @@
- + + diff --git a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/ui/connector-canvas/graph-controls/connector-graph-controls.component.spec.ts b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/ui/connector-canvas/graph-controls/connector-graph-controls.component.spec.ts index 9e4d86095526..96b60bc05cde 100644 --- a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/ui/connector-canvas/graph-controls/connector-graph-controls.component.spec.ts +++ b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/ui/connector-canvas/graph-controls/connector-graph-controls.component.spec.ts @@ -23,6 +23,8 @@ import { NoopAnimationsModule } from '@angular/platform-browser/animations'; import { ConnectorGraphControls } from './connector-graph-controls.component'; import { ConnectorInfoControl } from './connector-info-control/connector-info-control.component'; import { ConnectorEntity } from '@nifi/shared'; +import { BirdseyeComponentData, BirdseyeTransform } from '../../../../../ui/common/birdseye/birdseye.types'; +import { Dimension } from '../../../../../ui/common/canvas/canvas.types'; @Component({ selector: 'connector-info-control', @@ -35,7 +37,15 @@ class MockConnectorInfoControl { entitySaving = input(false); } -async function setup(inputs: { connectorEntity?: ConnectorEntity | null; entitySaving?: boolean } = {}) { +interface SetupInputs { + connectorEntity?: ConnectorEntity | null; + entitySaving?: boolean; + birdseyeComponents?: BirdseyeComponentData[]; + birdseyeTransform?: BirdseyeTransform; + canvasDimensions?: Dimension; +} + +async function setup(inputs: SetupInputs = {}) { await TestBed.configureTestingModule({ imports: [ConnectorGraphControls, NoopAnimationsModule] }) @@ -48,6 +58,12 @@ async function setup(inputs: { connectorEntity?: ConnectorEntity | null; entityS const fixture: ComponentFixture = TestBed.createComponent(ConnectorGraphControls); fixture.componentRef.setInput('connectorEntity', inputs.connectorEntity ?? null); fixture.componentRef.setInput('entitySaving', inputs.entitySaving ?? false); + fixture.componentRef.setInput('birdseyeComponents', inputs.birdseyeComponents ?? []); + fixture.componentRef.setInput( + 'birdseyeTransform', + inputs.birdseyeTransform ?? { translate: { x: 0, y: 0 }, scale: 1 } + ); + fixture.componentRef.setInput('canvasDimensions', inputs.canvasDimensions ?? { width: 0, height: 0 }); fixture.detectChanges(); return { fixture, component: fixture.componentInstance }; diff --git a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/ui/connector-canvas/graph-controls/connector-graph-controls.component.ts b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/ui/connector-canvas/graph-controls/connector-graph-controls.component.ts index a04443a6d34f..b5d6dcbcc403 100644 --- a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/ui/connector-canvas/graph-controls/connector-graph-controls.component.ts +++ b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/ui/connector-canvas/graph-controls/connector-graph-controls.component.ts @@ -15,18 +15,36 @@ * limitations under the License. */ -import { Component, input } from '@angular/core'; +import { Component, input, output } from '@angular/core'; import { ConnectorEntity } from '@nifi/shared'; +import { CanvasNavigationControl } from '../../../../../ui/common/navigation-control/canvas-navigation-control.component'; +import { BirdseyeComponentData, BirdseyeTransform } from '../../../../../ui/common/birdseye/birdseye.types'; +import { Dimension, Position } from '../../../../../ui/common/canvas/canvas.types'; import { ConnectorInfoControl } from './connector-info-control/connector-info-control.component'; @Component({ selector: 'connector-graph-controls', standalone: true, - imports: [ConnectorInfoControl], + imports: [CanvasNavigationControl, ConnectorInfoControl], templateUrl: './connector-graph-controls.component.html', styleUrls: ['./connector-graph-controls.component.scss'] }) export class ConnectorGraphControls { connectorEntity = input(null); entitySaving = input(false); + + birdseyeComponents = input.required(); + birdseyeTransform = input.required(); + canvasDimensions = input.required(); + canNavigateToParent = input(false); + + viewportChange = output(); + birdseyeDragStart = output(); + birdseyeDragEnd = output(); + + zoomIn = output(); + zoomOut = output(); + zoomFit = output(); + zoomActual = output(); + leaveGroup = output(); } diff --git a/nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/birdseye/birdseye.component.html b/nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/birdseye/birdseye.component.html new file mode 100644 index 000000000000..5ef811d7f064 --- /dev/null +++ b/nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/birdseye/birdseye.component.html @@ -0,0 +1,18 @@ + + +
diff --git a/nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/birdseye/birdseye.component.scss b/nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/birdseye/birdseye.component.scss new file mode 100644 index 000000000000..11213b7dfc9d --- /dev/null +++ b/nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/birdseye/birdseye.component.scss @@ -0,0 +1,55 @@ +/* + * 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. + */ + +// THEMED STYLES (internal mixin) +// These styles use CSS variables for colors. This mixin is included twice: +// once for light mode and once under .darkMode for dark mode, mirroring the +// pattern used by canvas.component.scss. +@mixin generate-theme() { + .birdseye-container { + background: var(--mat-sys-background); + width: 100%; + height: 175px; + overflow: hidden; + box-sizing: content-box; + + canvas, + svg { + position: absolute; + overflow: hidden; + } + + rect.birdseye-brush { + stroke: var(--mat-sys-primary); + fill: transparent; + } + } +} + +// ::ng-deep is required because the canvas, svg, and rect elements are created dynamically in +// birdseye.component.ts via document.createElement() and D3, NOT declared in the component +// template. Angular's ViewEncapsulation.Emulated only adds scoping attributes to template-defined +// elements; dynamically created elements don't receive those attributes, so normal scoped styles +// won't match them. ::ng-deep disables the encapsulation requirement for descendant selectors. +:host ::ng-deep { + @include generate-theme(); + + // Dark mode styles (same styles, scoped to .darkMode) + .darkMode { + @include generate-theme(); + } +} diff --git a/nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/birdseye/birdseye.component.spec.ts b/nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/birdseye/birdseye.component.spec.ts new file mode 100644 index 000000000000..ac7a04e20dfe --- /dev/null +++ b/nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/birdseye/birdseye.component.spec.ts @@ -0,0 +1,420 @@ +/* + * 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 { ComponentType } from '@nifi/shared'; +import { CanvasBirdseyeComponent } from './birdseye.component'; +import { BirdseyeComponentData, BirdseyeTransform } from './birdseye.types'; +import { Dimension } from '../canvas/canvas.types'; + +function createMockComponent( + options: { + id?: string; + type?: ComponentType; + x?: number; + y?: number; + width?: number; + height?: number; + fillColor?: string; + } = {} +): BirdseyeComponentData { + return { + id: options.id || `component-${Math.random().toString(36).substring(2, 11)}`, + type: options.type || ComponentType.Processor, + position: { + x: options.x ?? 100, + y: options.y ?? 100 + }, + dimensions: { + width: options.width ?? 352, + height: options.height ?? 128 + }, + ...(options.fillColor !== undefined ? { fillColor: options.fillColor } : {}) + }; +} + +function createMockTransform( + options: { + translateX?: number; + translateY?: number; + scale?: number; + } = {} +): BirdseyeTransform { + return { + translate: { + x: options.translateX ?? 0, + y: options.translateY ?? 0 + }, + scale: options.scale ?? 1 + }; +} + +function createMockDimensions( + options: { + width?: number; + height?: number; + } = {} +): Dimension { + return { + width: options.width ?? 1000, + height: options.height ?? 800 + }; +} + +interface SetupOptions { + components?: BirdseyeComponentData[]; + transform?: BirdseyeTransform; + canvasDimensions?: Dimension; +} + +async function setup(options: SetupOptions = {}) { + await TestBed.configureTestingModule({ + imports: [CanvasBirdseyeComponent] + }).compileComponents(); + + const fixture = TestBed.createComponent(CanvasBirdseyeComponent); + const component = fixture.componentInstance; + + fixture.componentRef.setInput('components', options.components ?? []); + fixture.componentRef.setInput( + 'transform', + options.transform ?? { + translate: { x: 0, y: 0 }, + scale: 1 + } + ); + fixture.componentRef.setInput('canvasDimensions', options.canvasDimensions ?? { width: 1000, height: 800 }); + + fixture.detectChanges(); + + const containerElement = fixture.nativeElement.querySelector('.birdseye-container'); + const canvasElement = fixture.nativeElement.querySelector('.birdseye-canvas'); + const svgElement = fixture.nativeElement.querySelector('.birdseye-svg'); + const brushElement = fixture.nativeElement.querySelector('.birdseye-brush'); + + return { + fixture, + component, + containerElement, + canvasElement, + svgElement, + brushElement + }; +} + +describe('CanvasBirdseyeComponent', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('Component initialization', () => { + it('should create', async () => { + const { component } = await setup(); + expect(component).toBeTruthy(); + }); + + it('should render canvas element for component visualization', async () => { + const { canvasElement } = await setup(); + expect(canvasElement).toBeTruthy(); + expect(canvasElement.tagName.toLowerCase()).toBe('canvas'); + }); + + it('should render SVG element for interactive brush', async () => { + const { svgElement } = await setup(); + expect(svgElement).toBeTruthy(); + expect(svgElement.tagName.toLowerCase()).toBe('svg'); + }); + + it('should render viewport brush element', async () => { + const components = [createMockComponent()]; + const { brushElement } = await setup({ components }); + expect(brushElement).toBeTruthy(); + expect(brushElement.tagName.toLowerCase()).toBe('rect'); + }); + + it('should set default canvas style dimensions', async () => { + const { canvasElement } = await setup(); + expect(canvasElement.style.width).toBe('200px'); + expect(canvasElement.style.height).toBe('150px'); + }); + + it('should set default SVG dimensions', async () => { + const { svgElement } = await setup(); + expect(svgElement.getAttribute('width')).toBe('200'); + expect(svgElement.getAttribute('height')).toBe('150'); + }); + }); + + describe('Component rendering', () => { + it('should render with components present', async () => { + const components = [ + createMockComponent({ id: 'proc-1', type: ComponentType.Processor, x: 100, y: 100 }), + createMockComponent({ id: 'proc-2', type: ComponentType.Processor, x: 500, y: 300 }) + ]; + const { canvasElement } = await setup({ components }); + expect(canvasElement).toBeTruthy(); + }); + + it('should handle empty components array', async () => { + const { canvasElement, component } = await setup({ components: [] }); + expect(canvasElement).toBeTruthy(); + expect(component).toBeTruthy(); + }); + + it('should accept all supported component types without error', async () => { + const componentTypes: ComponentType[] = [ + ComponentType.Processor, + ComponentType.ProcessGroup, + ComponentType.RemoteProcessGroup, + ComponentType.InputPort, + ComponentType.OutputPort, + ComponentType.Funnel, + ComponentType.Label, + ComponentType.Connection + ]; + + const components = componentTypes.map((type, index) => createMockComponent({ type, x: index * 200, y: 0 })); + const { component } = await setup({ components }); + expect(component).toBeTruthy(); + }); + }); + + describe('Viewport brush', () => { + it('should position brush via a transform attribute', async () => { + const components = [createMockComponent({ x: 0, y: 0 })]; + const transform = createMockTransform({ translateX: -100, translateY: -50, scale: 1 }); + const { brushElement } = await setup({ components, transform }); + + const transformAttr = brushElement.getAttribute('transform'); + expect(transformAttr).toContain('translate'); + }); + + it('should size brush based on canvas dimensions and scale', async () => { + const components = [createMockComponent({ x: 0, y: 0, width: 2000, height: 1500 })]; + const transform = createMockTransform({ scale: 0.5 }); + const canvasDimensions = createMockDimensions({ width: 1000, height: 800 }); + const { brushElement } = await setup({ components, transform, canvasDimensions }); + + const width = brushElement.getAttribute('width'); + const height = brushElement.getAttribute('height'); + expect(parseFloat(width!)).toBeGreaterThan(0); + expect(parseFloat(height!)).toBeGreaterThan(0); + }); + + it('should enforce minimum brush size of 10', async () => { + const components = [createMockComponent({ x: 0, y: 0, width: 10000, height: 10000 })]; + const transform = createMockTransform({ scale: 0.1 }); + const canvasDimensions = createMockDimensions({ width: 100, height: 80 }); + const { brushElement } = await setup({ components, transform, canvasDimensions }); + + const width = parseFloat(brushElement.getAttribute('width')!); + const height = parseFloat(brushElement.getAttribute('height')!); + expect(width).toBeGreaterThanOrEqual(10); + expect(height).toBeGreaterThanOrEqual(10); + }); + + it('should reflect a panned viewport in the brush transform', async () => { + const components = [createMockComponent({ x: 0, y: 0 }), createMockComponent({ x: 1000, y: 800 })]; + const transform = createMockTransform({ translateX: -500, translateY: -400, scale: 1 }); + const { brushElement } = await setup({ components, transform }); + + const transformAttr = brushElement.getAttribute('transform'); + expect(transformAttr).toContain('translate'); + expect(transformAttr).not.toBe('translate(0, 0)'); + }); + }); + + describe('Bounds calculation', () => { + it('should accept components at extreme coordinates', async () => { + const components = [ + createMockComponent({ x: -500, y: -300, width: 100, height: 50 }), + createMockComponent({ x: 1000, y: 800, width: 100, height: 50 }) + ]; + const { component } = await setup({ components }); + expect(component).toBeTruthy(); + }); + + it('should accept a viewport panned far from any component', async () => { + const components = [createMockComponent({ x: 0, y: 0 })]; + const transform = createMockTransform({ translateX: -5000, translateY: -3000 }); + const canvasDimensions = createMockDimensions({ width: 1000, height: 800 }); + const { component } = await setup({ components, transform, canvasDimensions }); + expect(component).toBeTruthy(); + }); + }); + + describe('Output events', () => { + it('should expose viewportChange', async () => { + const { component } = await setup({ components: [createMockComponent()] }); + expect(component.viewportChange).toBeDefined(); + }); + + it('should expose dragStart', async () => { + const { component } = await setup({ components: [createMockComponent()] }); + expect(component.dragStart).toBeDefined(); + }); + + it('should expose dragEnd', async () => { + const { component } = await setup({ components: [createMockComponent()] }); + expect(component.dragEnd).toBeDefined(); + }); + }); + + describe('Component palette', () => { + // The default test environment does not implement CanvasRenderingContext2D, so the + // component's rendering code path is normally a no-op. To exercise the paint logic we + // install a stub 2D context on HTMLCanvasElement that records every value assigned to + // fillStyle and strokeStyle while satisfying the methods the component invokes during + // initializeBirdseye(), renderComponents(), and updateBirdseyeSize(). + function installPaintTracker() { + const fillColors: string[] = []; + const strokeColors: string[] = []; + + const ctx: any = { + _fillStyle: '', + _strokeStyle: '', + set fillStyle(value: string) { + fillColors.push(String(value)); + this._fillStyle = value; + }, + get fillStyle() { + return this._fillStyle; + }, + set strokeStyle(value: string) { + strokeColors.push(String(value)); + this._strokeStyle = value; + }, + get strokeStyle() { + return this._strokeStyle; + }, + scale: vi.fn(), + clearRect: vi.fn(), + save: vi.fn(), + restore: vi.fn(), + translate: vi.fn(), + fillRect: vi.fn(), + strokeRect: vi.fn(), + setTransform: vi.fn() + }; + + const spy = vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue(ctx as any); + + return { + fillColors, + strokeColors, + restore: () => spy.mockRestore() + }; + } + + it('should fill processors with the default palette color and a contrast stroke', async () => { + const tracker = installPaintTracker(); + try { + const components = [createMockComponent({ type: ComponentType.Processor })]; + const { fixture } = await setup({ components }); + await fixture.whenStable(); + + expect(tracker.fillColors).toContain('#dde4eb'); + expect(tracker.strokeColors).toContain('#000000'); + } finally { + tracker.restore(); + } + }); + + it('should fill labels with the default label palette color', async () => { + const tracker = installPaintTracker(); + try { + const components = [createMockComponent({ type: ComponentType.Label })]; + const { fixture } = await setup({ components }); + await fixture.whenStable(); + + expect(tracker.fillColors).toContain('#fff7d7'); + } finally { + tracker.restore(); + } + }); + + it('should honor a user-configured fillColor override', async () => { + const tracker = installPaintTracker(); + try { + const components = [createMockComponent({ type: ComponentType.Processor, fillColor: '#123456' })]; + const { fixture } = await setup({ components }); + await fixture.whenStable(); + + expect(tracker.fillColors).toContain('#123456'); + // #123456 is dark, so the contrast-derived stroke should be white. + expect(tracker.strokeColors).toContain('#ffffff'); + } finally { + tracker.restore(); + } + }); + + it('should derive a black stroke for light user-configured fills', async () => { + const tracker = installPaintTracker(); + try { + const components = [createMockComponent({ type: ComponentType.Label, fillColor: '#fefefe' })]; + const { fixture } = await setup({ components }); + await fixture.whenStable(); + + expect(tracker.fillColors).toContain('#fefefe'); + expect(tracker.strokeColors).toContain('#000000'); + } finally { + tracker.restore(); + } + }); + }); + + describe('Cleanup', () => { + it('should not throw when destroyed after rendering', async () => { + const { fixture } = await setup({ components: [createMockComponent()] }); + expect(() => fixture.destroy()).not.toThrow(); + }); + }); + + describe('Edge cases', () => { + it('should handle a single component', async () => { + const { component } = await setup({ components: [createMockComponent()] }); + expect(component).toBeTruthy(); + }); + + it('should handle components at negative coordinates', async () => { + const components = [createMockComponent({ x: -1000, y: -500 }), createMockComponent({ x: -500, y: -250 })]; + const { component } = await setup({ components }); + expect(component).toBeTruthy(); + }); + + it('should handle very small scale', async () => { + const components = [createMockComponent()]; + const transform = createMockTransform({ scale: 0.1 }); + const { component } = await setup({ components, transform }); + expect(component).toBeTruthy(); + }); + + it('should handle very large scale', async () => { + const components = [createMockComponent()]; + const transform = createMockTransform({ scale: 8 }); + const { component } = await setup({ components, transform }); + expect(component).toBeTruthy(); + }); + + it('should handle zero-size canvas dimensions gracefully', async () => { + const components = [createMockComponent()]; + const canvasDimensions = createMockDimensions({ width: 0, height: 0 }); + const { component } = await setup({ components, canvasDimensions }); + expect(component).toBeTruthy(); + }); + }); +}); diff --git a/nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/birdseye/birdseye.component.ts b/nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/birdseye/birdseye.component.ts new file mode 100644 index 000000000000..9867f7ac8b83 --- /dev/null +++ b/nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/birdseye/birdseye.component.ts @@ -0,0 +1,496 @@ +/* + * 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 { + AfterViewInit, + ChangeDetectionStrategy, + Component, + ElementRef, + OnDestroy, + effect, + input, + output, + viewChild +} from '@angular/core'; +import * as d3 from 'd3'; +import { ComponentType } from '@nifi/shared'; +import { Dimension, Position } from '../canvas/canvas.types'; +import { BirdseyeBounds, BirdseyeComponentData, BirdseyeTransform } from './birdseye.types'; + +/** + * Canvas Birdseye Component + * + * A minimap component that provides an overview of the entire canvas + * and allows users to navigate by dragging the viewport brush. + * + * Architecture: Hybrid Canvas + SVG + * - Canvas: Renders component representations (efficient for thousands of components) + * - SVG: Renders the interactive viewport brush (easy drag handling) + * + * This hybrid approach provides optimal performance: + * - Canvas handles static component rendering without DOM overhead + * - SVG handles interactive brush with native D3 drag behavior + * + * Features: + * - Renders simplified representations of all canvas components + * - Shows current viewport position as a draggable brush + * - Drag brush to pan the canvas (delta-based translation) + * - Automatically scales to fit all components + * - Scales efficiently to thousands of components + * + * Usage: + * ```html + * + * + * ``` + */ +@Component({ + selector: 'canvas-birdseye', + standalone: true, + templateUrl: './birdseye.component.html', + styleUrls: ['./birdseye.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class CanvasBirdseyeComponent implements AfterViewInit, OnDestroy { + private birdseyeContainer = viewChild.required>('birdseyeContainer'); + + components = input.required(); + + transform = input.required(); + + canvasDimensions = input.required(); + + birdseyeDimensions = input({ width: 200, height: 150 }); + + // Emitted during drag with the new translate the canvas should adopt. + viewportChange = output(); + + // Parent should call canvas.birdseyeDragStart() to skip visibility updates. + dragStart = output(); + + // Parent should call canvas.birdseyeDragEnd() to update visibility. + dragEnd = output(); + + private canvas: HTMLCanvasElement | null = null; + private canvasContext: CanvasRenderingContext2D | null = null; + + private svg: d3.Selection | null = null; + private brushGroup: d3.Selection | null = null; + private brush: d3.Selection | null = null; + + private readonly DPR = window.devicePixelRatio || 1; + + private birdseyeScale = 1; + private offsetX = 0; + private offsetY = 0; + private bounds: BirdseyeBounds = { minX: 0, minY: 0, maxX: 100, maxY: 100 }; + + private isDragging = false; + + private destroyed = false; + + private resizeObserver: ResizeObserver | null = null; + + private effectiveWidth = 200; + + // Default palette mapped per ComponentType. Process groups and remote process groups share a + // fill so the two visually merge into a single "group" band on the minimap. Only processors + // and labels render a stroke because they are the only component types whose fill can be + // overridden by a user-configured background color; the stroke is derived from the resolved + // fill so it keeps adequate contrast against any choice. Connection is intentionally omitted + // because getBirdseyeComponentData() does not emit connections, so any unmapped type + // (current or future) falls through to DEFAULT_COLOR. + private readonly COMPONENT_COLORS: Partial> = { + [ComponentType.Processor]: { fill: '#dde4eb', hasStroke: true }, + [ComponentType.ProcessGroup]: { fill: '#728e9b', hasStroke: false }, + [ComponentType.RemoteProcessGroup]: { fill: '#728e9b', hasStroke: false }, + [ComponentType.InputPort]: { fill: '#bbdcde', hasStroke: false }, + [ComponentType.OutputPort]: { fill: '#bbdcde', hasStroke: false }, + [ComponentType.Funnel]: { fill: '#ad9897', hasStroke: false }, + [ComponentType.Label]: { fill: '#fff7d7', hasStroke: true } + }; + + private readonly DEFAULT_COLOR = { fill: '#dde4eb', hasStroke: false }; + + constructor() { + effect(() => { + const components = this.components(); + if (this.canvasContext && components && !this.isDragging) { + this.renderComponents(components); + } + }); + + // Recalculates bounds to account for viewport position beyond flow bounds. + // Skipped during drag because the brush position is updated directly in the drag handler. + effect(() => { + const transform = this.transform(); + const canvasDims = this.canvasDimensions(); + if (this.canvasContext && this.brush && transform && canvasDims && !this.isDragging) { + this.refresh(); + } + }); + + effect(() => { + const dims = this.birdseyeDimensions(); + if (this.canvas && this.svg && this.canvasContext) { + this.updateBirdseyeSize({ width: this.effectiveWidth, height: dims.height }); + } + }); + } + + ngAfterViewInit(): void { + this.initializeBirdseye(); + + const container = this.birdseyeContainer().nativeElement; + this.resizeObserver = new ResizeObserver((entries) => { + for (const entry of entries) { + const width = entry.contentRect.width; + if (width > 0 && this.canvas && this.svg && this.canvasContext) { + this.updateBirdseyeSize({ width, height: this.birdseyeDimensions().height }); + } + } + }); + this.resizeObserver.observe(container); + } + + ngOnDestroy(): void { + this.destroyed = true; + + if (this.resizeObserver) { + this.resizeObserver.disconnect(); + this.resizeObserver = null; + } + + // Remove the D3 drag behavior so events do not fire after the component is destroyed. + if (this.brush) { + this.brush.on('.drag', null); + } + + if (this.svg) { + this.svg.remove(); + this.svg = null; + } + if (this.canvas) { + this.canvas.remove(); + this.canvas = null; + } + } + + /** + * Initialize the birdseye view with hybrid Canvas + SVG approach + */ + private initializeBirdseye(): void { + const container = this.birdseyeContainer().nativeElement; + + // Use the container's actual width (CSS-driven) instead of the input default. + const containerWidth = container.clientWidth || this.birdseyeDimensions().width; + this.effectiveWidth = containerWidth; + const height = this.birdseyeDimensions().height; + + this.canvas = document.createElement('canvas'); + this.canvas.width = containerWidth * this.DPR; + this.canvas.height = height * this.DPR; + this.canvas.style.width = `${containerWidth}px`; + this.canvas.style.height = `${height}px`; + this.canvas.className = 'birdseye-canvas'; + container.appendChild(this.canvas); + + this.canvasContext = this.canvas.getContext('2d'); + if (this.canvasContext) { + this.canvasContext.scale(this.DPR, this.DPR); + } + + this.svg = d3 + .select(container) + .append('svg') + .attr('class', 'birdseye-svg') + .attr('width', containerWidth) + .attr('height', height); + + this.brushGroup = this.svg.append('g').attr('class', 'birdseye-brush-container'); + + this.createBrush(); + + this.renderComponents(this.components()); + this.updateBrush(this.transform(), this.canvasDimensions()); + } + + /** + * Create the draggable viewport brush. Translation is delta-based: each drag event applies + * an incremental offset to the current canvas translate rather than computing an absolute + * viewport position. This keeps the brush in lock-step with incremental canvas pans and + * avoids cumulative rounding drift when the user holds and drags continuously. + */ + private createBrush(): void { + if (!this.brushGroup) return; + + const drag = d3 + .drag() + .subject((_event, d) => { + return { x: d.x, y: d.y }; + }) + .on('start', (_event, _d) => { + if (this.destroyed) { + return; + } + this.isDragging = true; + this.dragStart.emit(); + }) + .on('drag', (event, d) => { + if (this.destroyed) { + return; + } + + d.x += event.dx; + d.y += event.dy; + + if (this.brush) { + this.brush.attr('transform', `translate(${d.x}, ${d.y})`); + } + + // Translate the canvas by the inverse delta scaled into canvas coordinates. + const currentTransform = this.transform(); + const scaledDx = event.dx / this.birdseyeScale; + const scaledDy = event.dy / this.birdseyeScale; + + const newTranslateX = currentTransform.translate.x - scaledDx * currentTransform.scale; + const newTranslateY = currentTransform.translate.y - scaledDy * currentTransform.scale; + + this.viewportChange.emit({ + x: newTranslateX, + y: newTranslateY + }); + }) + .on('end', (_event, _d) => { + if (this.destroyed) { + return; + } + + this.isDragging = false; + + this.dragEnd.emit(); + + // Recalculate bounds so the brush stays within the birdseye area after dragging. + this.refresh(); + }); + + const brushContainer = this.brushGroup + .append('g') + .attr('pointer-events', 'all') + .attr('class', 'birdseye-brush-container'); + + this.brush = brushContainer.append('rect').attr('class', 'birdseye-brush').datum({ x: 0, y: 0 }).call(drag); + } + + /** + * Returns the effective birdseye dimensions, using the container's actual width + * (auto-sized via CSS) and the configured height from the input. + */ + private getEffectiveDimensions(): Dimension { + return { width: this.effectiveWidth, height: this.birdseyeDimensions().height }; + } + + /** + * Render component representations using Canvas (efficient for many components). + */ + private renderComponents(components: BirdseyeComponentData[]): void { + if (!this.canvasContext) return; + + const ctx = this.canvasContext; + + const dims = this.getEffectiveDimensions(); + ctx.clearRect(0, 0, dims.width, dims.height); + + if (!components || components.length === 0) { + return; + } + + const componentBounds = this.calculateComponentBounds(components); + + // Calculate viewport bounds in canvas coordinates. + const transform = this.transform(); + const canvasDims = this.canvasDimensions(); + const viewportLeft = -transform.translate.x / transform.scale; + const viewportTop = -transform.translate.y / transform.scale; + const viewportRight = viewportLeft + canvasDims.width / transform.scale; + const viewportBottom = viewportTop + canvasDims.height / transform.scale; + + // Total bounds = union of component bounds and viewport bounds so the birdseye + // shows both content AND the viewport even if the viewport is panned away. + this.bounds = { + minX: Math.min(componentBounds.minX, viewportLeft), + minY: Math.min(componentBounds.minY, viewportTop), + maxX: Math.max(componentBounds.maxX, viewportRight), + maxY: Math.max(componentBounds.maxY, viewportBottom) + }; + + const contentWidth = this.bounds.maxX - this.bounds.minX; + const contentHeight = this.bounds.maxY - this.bounds.minY; + + const padding = 5; + const availableWidth = dims.width - padding * 2; + const availableHeight = dims.height - padding * 2; + + const scaleX = contentWidth > 0 ? availableWidth / contentWidth : 1; + const scaleY = contentHeight > 0 ? availableHeight / contentHeight : 1; + // Don't scale up beyond 1. + this.birdseyeScale = Math.min(scaleX, scaleY, 1); + + const scaledWidth = contentWidth * this.birdseyeScale; + const scaledHeight = contentHeight * this.birdseyeScale; + this.offsetX = (dims.width - scaledWidth) / 2; + this.offsetY = (dims.height - scaledHeight) / 2; + + ctx.save(); + + // Apply the transformation chain in this order: + // translate(offset) -> scale(birdseyeScale) -> translate(-bounds.min) + // Scaling before drawing means strokes scale uniformly with the content, so a single + // strokeStyle assignment produces a visually consistent border for every component. + // Translating by -bounds.min lets the loop pass each component's raw canvas position + // and dimensions without the caller having to map them into birdseye coordinates. + ctx.translate(this.offsetX, this.offsetY); + ctx.scale(this.birdseyeScale, this.birdseyeScale); + ctx.translate(-this.bounds.minX, -this.bounds.minY); + + for (const component of components) { + const color = this.COMPONENT_COLORS[component.type] || this.DEFAULT_COLOR; + // Honor a user-configured background color (set by Change Color on processors and + // labels) when present; otherwise fall back to the type-based palette entry. + const fill = component.fillColor || color.fill; + + ctx.fillStyle = fill; + ctx.fillRect( + component.position.x, + component.position.y, + component.dimensions.width, + component.dimensions.height + ); + + if (color.hasStroke) { + ctx.strokeStyle = this.determineContrastColor(fill); + ctx.strokeRect( + component.position.x, + component.position.y, + component.dimensions.width, + component.dimensions.height + ); + } + } + + ctx.restore(); + } + + /** + * Returns black or white depending on whether the supplied hex color is light or dark, so + * the stroke remains legible against any user-configured fill. Uses a simple luminance + * threshold (`parseInt(hex, 16) > 0xffffff / 1.5`) which is cheap to compute on every + * paint and gives correct contrast for the limited fill palette this component renders. + */ + private determineContrastColor(fill: string): string { + const hex = fill.startsWith('#') ? fill.substring(1) : fill; + if (parseInt(hex, 16) > 0xffffff / 1.5) { + return '#000000'; + } + return '#ffffff'; + } + + /** + * Update the viewport brush position and size. + */ + private updateBrush(transform: BirdseyeTransform, canvasDimensions: Dimension): void { + if (!this.brush) return; + + const components = this.components(); + if (!components || components.length === 0) return; + + const viewportX = -transform.translate.x / transform.scale; + const viewportY = -transform.translate.y / transform.scale; + + const viewportWidth = canvasDimensions.width / transform.scale; + const viewportHeight = canvasDimensions.height / transform.scale; + + const brushX = (viewportX - this.bounds.minX) * this.birdseyeScale + this.offsetX; + const brushY = (viewportY - this.bounds.minY) * this.birdseyeScale + this.offsetY; + const brushWidth = viewportWidth * this.birdseyeScale; + const brushHeight = viewportHeight * this.birdseyeScale; + + this.brush.datum({ x: brushX, y: brushY }); + + this.brush + .attr('transform', `translate(${brushX}, ${brushY})`) + .attr('width', Math.max(brushWidth, 10)) + .attr('height', Math.max(brushHeight, 10)); + } + + /** + * Calculate the bounding box of all components. + */ + private calculateComponentBounds(components: BirdseyeComponentData[]): BirdseyeBounds { + if (!components || components.length === 0) { + return { minX: 0, minY: 0, maxX: 100, maxY: 100 }; + } + + let minX = Infinity; + let minY = Infinity; + let maxX = -Infinity; + let maxY = -Infinity; + + for (const component of components) { + minX = Math.min(minX, component.position.x); + minY = Math.min(minY, component.position.y); + maxX = Math.max(maxX, component.position.x + component.dimensions.width); + maxY = Math.max(maxY, component.position.y + component.dimensions.height); + } + + return { minX, minY, maxX, maxY }; + } + + /** + * Recalculate bounds and redraw everything. Called after drag ends so the brush + * stays within the bounds of the new viewport. + */ + private refresh(): void { + this.renderComponents(this.components()); + this.updateBrush(this.transform(), this.canvasDimensions()); + } + + /** + * Update birdseye canvas and SVG sizes when dimensions change. + */ + private updateBirdseyeSize(dims: Dimension): void { + if (!this.canvas || !this.svg || !this.canvasContext) return; + + this.effectiveWidth = dims.width; + + this.canvas.width = dims.width * this.DPR; + this.canvas.height = dims.height * this.DPR; + this.canvas.style.width = `${dims.width}px`; + this.canvas.style.height = `${dims.height}px`; + + this.canvasContext.setTransform(1, 0, 0, 1, 0, 0); + this.canvasContext.scale(this.DPR, this.DPR); + + this.svg.attr('width', dims.width).attr('height', dims.height); + + this.refresh(); + } +} diff --git a/nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/birdseye/birdseye.types.ts b/nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/birdseye/birdseye.types.ts new file mode 100644 index 000000000000..432933327763 --- /dev/null +++ b/nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/birdseye/birdseye.types.ts @@ -0,0 +1,54 @@ +/* + * 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 { ComponentType } from '@nifi/shared'; +import { Dimension, Position } from '../canvas/canvas.types'; + +/** + * Simplified component data for birdseye rendering + */ +export interface BirdseyeComponentData { + id: string; + type: ComponentType; + position: Position; + dimensions: Dimension; + /** + * Optional hex color (e.g. '#aabbcc') used to fill this component on the birdseye. When + * present it overrides the type-based palette so a user-configured background color on a + * processor or label is reflected on the minimap, keeping the overview visually + * consistent with what the user sees on the canvas. + */ + fillColor?: string; +} + +/** + * Canvas transform state + */ +export interface BirdseyeTransform { + translate: Position; + scale: number; +} + +/** + * Bounding box for component calculations + */ +export interface BirdseyeBounds { + minX: number; + minY: number; + maxX: number; + maxY: number; +} diff --git a/nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/canvas/canvas.component.spec.ts b/nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/canvas/canvas.component.spec.ts index c987e3eebce8..8ccb9219400f 100644 --- a/nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/canvas/canvas.component.spec.ts +++ b/nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/canvas/canvas.component.spec.ts @@ -22,7 +22,7 @@ import { ReplaySubject } from 'rxjs'; import { Action } from '@ngrx/store'; import { outputToObservable } from '@angular/core/rxjs-interop'; import { CanvasComponent } from './canvas.component'; -import { BirdseyeComponentData } from './canvas.component'; +import { BirdseyeComponentData } from '../birdseye/birdseye.types'; import { canvasUiFeatureKey, initialCanvasUiState } from '../../../state/canvas-ui'; import { ComponentType } from '@nifi/shared'; @@ -384,6 +384,48 @@ describe('CanvasComponent', () => { expect(types).toContain(ComponentType.Funnel); expect(types).toContain(ComponentType.ProcessGroup); }); + + it('should expose the user-configured background color for a readable processor', async () => { + const processor = createMockProcessor({ id: 'proc-1' }); + processor.component.style = { 'background-color': '#abcdef' }; + const { component } = await setup({ processors: [processor] }); + + const data = component.getBirdseyeComponentData(); + + expect(data[0].fillColor).toBe('#abcdef'); + }); + + it('should leave fillColor undefined for an unauthorized processor even when a style is present', async () => { + const processor = createMockProcessor({ id: 'proc-1' }); + processor.permissions = { canRead: false, canWrite: false }; + processor.component.style = { 'background-color': '#abcdef' }; + const { component } = await setup({ processors: [processor] }); + + const data = component.getBirdseyeComponentData(); + + expect(data[0].fillColor).toBeUndefined(); + }); + + it('should expose the user-configured background color for a readable label', async () => { + const label = createMockLabel({ id: 'label-1' }); + label.component.style = { 'background-color': '#fedcba' }; + const { component } = await setup({ labels: [label] }); + + const data = component.getBirdseyeComponentData(); + + expect(data[0].fillColor).toBe('#fedcba'); + }); + + it('should leave fillColor undefined for an unauthorized label even when a style is present', async () => { + const label = createMockLabel({ id: 'label-1' }); + label.permissions = { canRead: false, canWrite: false }; + label.component.style = { 'background-color': '#fedcba' }; + const { component } = await setup({ labels: [label] }); + + const data = component.getBirdseyeComponentData(); + + expect(data[0].fillColor).toBeUndefined(); + }); }); describe('Birdseye API - setViewportPosition', () => { diff --git a/nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/canvas/canvas.component.ts b/nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/canvas/canvas.component.ts index 16632bafb9bc..c903ab3f7b6b 100644 --- a/nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/canvas/canvas.component.ts +++ b/nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/canvas/canvas.component.ts @@ -74,18 +74,7 @@ import { OverlapDetectionConnection, wouldRemovalCauseOverlap } from '../overlap-detection.utils'; - -export interface BirdseyeComponentData { - id: string; - type: ComponentType; - position: Position; - dimensions: Dimension; -} - -export interface BirdseyeTransform { - translate: Position; - scale: number; -} +import { BirdseyeComponentData, BirdseyeTransform } from '../birdseye/birdseye.types'; /** * Interface for viewport transform storage @@ -2979,11 +2968,18 @@ export class CanvasComponent implements OnInit, AfterViewInit, OnDestroy { // Add processors this.internalProcessors().forEach((p) => { + // Without canRead the caller is not authorized to view the component definition + // (including its configured style), so fall back to the default palette fill rather + // than leaking the user-configured background color through the minimap. + const fillColor = p.entity.permissions?.canRead + ? p.entity.component?.style?.['background-color'] || undefined + : undefined; components.push({ id: p.entity.id, type: ComponentType.Processor, position: { x: p.entity.position.x, y: p.entity.position.y }, - dimensions: { width: p.ui.dimensions.width, height: p.ui.dimensions.height } + dimensions: { width: p.ui.dimensions.width, height: p.ui.dimensions.height }, + fillColor }); }); @@ -3030,11 +3026,18 @@ export class CanvasComponent implements OnInit, AfterViewInit, OnDestroy { // Add labels this.internalLabels().forEach((l) => { + // Same authorization rule as processors above: a caller without canRead has no + // authority to view the label's configured style, so fall back to the default + // palette fill rather than leaking the user-configured background color. + const fillColor = l.entity.permissions?.canRead + ? l.entity.component?.style?.['background-color'] || undefined + : undefined; components.push({ id: l.entity.id, type: ComponentType.Label, position: { x: l.entity.position.x, y: l.entity.position.y }, - dimensions: { width: l.ui.dimensions.width, height: l.ui.dimensions.height } + dimensions: { width: l.ui.dimensions.width, height: l.ui.dimensions.height }, + fillColor }); }); diff --git a/nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/navigation-control/canvas-navigation-control.component.html b/nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/navigation-control/canvas-navigation-control.component.html new file mode 100644 index 000000000000..cd846f01b375 --- /dev/null +++ b/nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/navigation-control/canvas-navigation-control.component.html @@ -0,0 +1,97 @@ + + + + + +
+
+
Navigation
+
+
+
+
+
+
+ + + + +
+ @if (canNavigateToParent()) { + + } +
+ + +
+
diff --git a/nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/navigation-control/canvas-navigation-control.component.scss b/nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/navigation-control/canvas-navigation-control.component.scss new file mode 100644 index 000000000000..2944f9819474 --- /dev/null +++ b/nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/navigation-control/canvas-navigation-control.component.scss @@ -0,0 +1,16 @@ +/* + * 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. + */ diff --git a/nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/navigation-control/canvas-navigation-control.component.spec.ts b/nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/navigation-control/canvas-navigation-control.component.spec.ts new file mode 100644 index 000000000000..3c6c93505192 --- /dev/null +++ b/nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/navigation-control/canvas-navigation-control.component.spec.ts @@ -0,0 +1,237 @@ +/* + * 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 { ComponentFixture, TestBed } from '@angular/core/testing'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { MockComponent } from 'ng-mocks'; +import { Storage } from '@nifi/shared'; +import { CanvasNavigationControl } from './canvas-navigation-control.component'; +import { CanvasBirdseyeComponent } from '../birdseye/birdseye.component'; + +const mockStorage = { + getItem: vi.fn().mockReturnValue(null), + setItem: vi.fn(), + removeItem: vi.fn(), + hasItem: vi.fn(), + getItemExpiration: vi.fn() +}; + +interface SetupOptions { + canNavigateToParent?: boolean; + storageKey?: string; + storedVisibility?: { [key: string]: boolean } | null; +} + +async function setup(options: SetupOptions = {}) { + mockStorage.getItem.mockReturnValue(options.storedVisibility ?? null); + + await TestBed.configureTestingModule({ + imports: [CanvasNavigationControl, MockComponent(CanvasBirdseyeComponent), NoopAnimationsModule], + providers: [{ provide: Storage, useValue: mockStorage }] + }).compileComponents(); + + const fixture: ComponentFixture = TestBed.createComponent(CanvasNavigationControl); + const component = fixture.componentInstance; + + fixture.componentRef.setInput('birdseyeComponents', []); + fixture.componentRef.setInput('birdseyeTransform', { translate: { x: 0, y: 0 }, scale: 1 }); + fixture.componentRef.setInput('canvasDimensions', { width: 800, height: 600 }); + fixture.componentRef.setInput('canNavigateToParent', options.canNavigateToParent ?? false); + + if (options.storageKey) { + fixture.componentRef.setInput('storageKey', options.storageKey); + } + + fixture.detectChanges(); + + return { fixture, component }; +} + +describe('CanvasNavigationControl', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('Component initialization', () => { + it('should create', async () => { + const { component } = await setup(); + expect(component).toBeTruthy(); + }); + + it('should default to expanded state', async () => { + const { component } = await setup(); + expect(component.navigationCollapsed).toBe(false); + }); + + it('should restore collapsed state from storage', async () => { + const { component } = await setup({ + storedVisibility: { 'canvas-navigation-control': false } + }); + expect(component.navigationCollapsed).toBe(true); + }); + }); + + describe('Zoom buttons', () => { + it('should render the zoom in button', async () => { + const { fixture } = await setup(); + expect(fixture.nativeElement.querySelector('[data-qa="zoom-in-button"]')).toBeTruthy(); + }); + + it('should render the zoom out button', async () => { + const { fixture } = await setup(); + expect(fixture.nativeElement.querySelector('[data-qa="zoom-out-button"]')).toBeTruthy(); + }); + + it('should render the zoom fit button', async () => { + const { fixture } = await setup(); + expect(fixture.nativeElement.querySelector('[data-qa="zoom-fit-button"]')).toBeTruthy(); + }); + + it('should render the zoom actual button', async () => { + const { fixture } = await setup(); + expect(fixture.nativeElement.querySelector('[data-qa="zoom-actual-button"]')).toBeTruthy(); + }); + + it('should emit zoomIn when the zoom in button is clicked', async () => { + const { fixture, component } = await setup(); + const emitSpy = vi.spyOn(component.zoomIn, 'emit'); + + fixture.nativeElement.querySelector('[data-qa="zoom-in-button"]').click(); + + expect(emitSpy).toHaveBeenCalledTimes(1); + }); + + it('should emit zoomOut when the zoom out button is clicked', async () => { + const { fixture, component } = await setup(); + const emitSpy = vi.spyOn(component.zoomOut, 'emit'); + + fixture.nativeElement.querySelector('[data-qa="zoom-out-button"]').click(); + + expect(emitSpy).toHaveBeenCalledTimes(1); + }); + + it('should emit zoomFit when the zoom fit button is clicked', async () => { + const { fixture, component } = await setup(); + const emitSpy = vi.spyOn(component.zoomFit, 'emit'); + + fixture.nativeElement.querySelector('[data-qa="zoom-fit-button"]').click(); + + expect(emitSpy).toHaveBeenCalledTimes(1); + }); + + it('should emit zoomActual when the zoom actual button is clicked', async () => { + const { fixture, component } = await setup(); + const emitSpy = vi.spyOn(component.zoomActual, 'emit'); + + fixture.nativeElement.querySelector('[data-qa="zoom-actual-button"]').click(); + + expect(emitSpy).toHaveBeenCalledTimes(1); + }); + }); + + describe('Leave group button', () => { + it('should be hidden when canNavigateToParent is false', async () => { + const { fixture } = await setup({ canNavigateToParent: false }); + expect(fixture.nativeElement.querySelector('[data-qa="leave-group-button"]')).toBeNull(); + }); + + it('should be shown when canNavigateToParent is true', async () => { + const { fixture } = await setup({ canNavigateToParent: true }); + expect(fixture.nativeElement.querySelector('[data-qa="leave-group-button"]')).toBeTruthy(); + }); + + it('should emit leaveGroup when clicked', async () => { + const { fixture, component } = await setup({ canNavigateToParent: true }); + const emitSpy = vi.spyOn(component.leaveGroup, 'emit'); + + fixture.nativeElement.querySelector('[data-qa="leave-group-button"]').click(); + + expect(emitSpy).toHaveBeenCalledTimes(1); + }); + }); + + describe('Birdseye embedding', () => { + it('should render the embedded birdseye component', async () => { + const { fixture } = await setup(); + expect(fixture.nativeElement.querySelector('canvas-birdseye')).toBeTruthy(); + }); + }); + + describe('Collapse persistence', () => { + it('should save collapsed state under the default key', async () => { + const { component } = await setup(); + + component.toggleCollapsed(true); + + expect(mockStorage.setItem).toHaveBeenCalledWith('graph-control-visibility', { + 'canvas-navigation-control': false + }); + }); + + it('should save expanded state under the default key', async () => { + const { component } = await setup(); + + component.toggleCollapsed(false); + + expect(mockStorage.setItem).toHaveBeenCalledWith('graph-control-visibility', { + 'canvas-navigation-control': true + }); + }); + + it('should use a custom storageKey for persistence', async () => { + const { component } = await setup({ storageKey: 'connector-navigation-control' }); + + component.toggleCollapsed(true); + + expect(mockStorage.setItem).toHaveBeenCalledWith('graph-control-visibility', { + 'connector-navigation-control': false + }); + }); + + it('should restore collapsed state using a custom storageKey', async () => { + const { component } = await setup({ + storageKey: 'connector-navigation-control', + storedVisibility: { 'connector-navigation-control': false } + }); + + expect(component.navigationCollapsed).toBe(true); + }); + + it('should not cross-pollinate state between different storage keys', async () => { + const { component } = await setup({ + storageKey: 'connector-navigation-control', + storedVisibility: { 'navigation-control': false } + }); + + expect(component.navigationCollapsed).toBe(false); + }); + + it('should preserve other entries when persisting its key', async () => { + const { component } = await setup({ + storageKey: 'connector-navigation-control', + storedVisibility: { 'navigation-control': true } + }); + + component.toggleCollapsed(true); + + expect(mockStorage.setItem).toHaveBeenCalledWith('graph-control-visibility', { + 'navigation-control': true, + 'connector-navigation-control': false + }); + }); + }); +}); diff --git a/nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/navigation-control/canvas-navigation-control.component.ts b/nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/navigation-control/canvas-navigation-control.component.ts new file mode 100644 index 000000000000..9489d95e34db --- /dev/null +++ b/nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/navigation-control/canvas-navigation-control.component.ts @@ -0,0 +1,100 @@ +/* + * 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, inject, input, OnInit, output } from '@angular/core'; +import { MatButtonModule } from '@angular/material/button'; +import { MatExpansionPanel, MatExpansionPanelHeader, MatExpansionPanelTitle } from '@angular/material/expansion'; +import { MatTooltip } from '@angular/material/tooltip'; +import { Storage } from '@nifi/shared'; +import { CanvasBirdseyeComponent } from '../birdseye/birdseye.component'; +import { BirdseyeComponentData, BirdseyeTransform } from '../birdseye/birdseye.types'; +import { Dimension, Position } from '../canvas/canvas.types'; + +@Component({ + selector: 'canvas-navigation-control', + standalone: true, + imports: [ + CanvasBirdseyeComponent, + MatButtonModule, + MatExpansionPanel, + MatExpansionPanelHeader, + MatExpansionPanelTitle, + MatTooltip + ], + templateUrl: './canvas-navigation-control.component.html', + styleUrls: ['./canvas-navigation-control.component.scss'] +}) +export class CanvasNavigationControl implements OnInit { + private storage = inject(Storage); + + private static readonly CONTROL_VISIBILITY_KEY = 'graph-control-visibility'; + + /** + * Unique key for persisting the collapsed state in localStorage. Each consumer should + * provide a distinct key to avoid state collisions when multiple instances of this + * component exist across different pages. + */ + storageKey = input('canvas-navigation-control'); + + navigationCollapsed = false; + + birdseyeComponents = input.required(); + birdseyeTransform = input.required(); + canvasDimensions = input.required(); + canNavigateToParent = input(false); + + viewportChange = output(); + birdseyeDragStart = output(); + birdseyeDragEnd = output(); + + zoomIn = output(); + zoomOut = output(); + zoomFit = output(); + zoomActual = output(); + leaveGroup = output(); + + ngOnInit(): void { + try { + const item: { [key: string]: boolean } | null = this.storage.getItem( + CanvasNavigationControl.CONTROL_VISIBILITY_KEY + ); + if (item) { + this.navigationCollapsed = item[this.storageKey()] === false; + } + } catch (_e) { + // likely could not parse item... ignoring + } + } + + opened(): void { + this.toggleCollapsed(false); + } + + toggleCollapsed(collapsed: boolean): void { + this.navigationCollapsed = collapsed; + + let item: { [key: string]: boolean } | null = this.storage.getItem( + CanvasNavigationControl.CONTROL_VISIBILITY_KEY + ); + if (item == null) { + item = {}; + } + + item[this.storageKey()] = !this.navigationCollapsed; + this.storage.setItem(CanvasNavigationControl.CONTROL_VISIBILITY_KEY, item); + } +}