NIFI-12737: Removing all transitions when updating the Canvas transform (#8355)

* NIFI-12737:
- Recording last canvas URL in local storage.
- Using last canvas URL in global menu Canvas item.
- Conditionally applying transitions when centering components.
- Always applying transitions during zoom events (1:1, fit, zoom in/out).
- Adding support to center more than one component.

* NIFI-12737:
- Fixing bug when attempting to click on the search result of the currently selected component.
- Handling centering of a single selection different from a bulk selection as it performs betters with Connections.

This closes #8355
This commit is contained in:
Matt Gilman 2024-02-06 15:04:35 -05:00 committed by GitHub
parent 91f339bf0f
commit 13c70c0f30
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
16 changed files with 344 additions and 102 deletions

View File

@ -20,7 +20,7 @@ import { CanvasUtils } from './canvas-utils.service';
import { Store } from '@ngrx/store'; import { Store } from '@ngrx/store';
import { CanvasState } from '../state'; import { CanvasState } from '../state';
import { import {
centerSelectedComponent, centerSelectedComponents,
deleteComponents, deleteComponents,
enterProcessGroup, enterProcessGroup,
getParameterContextsAndOpenGroupComponentsDialog, getParameterContextsAndOpenGroupComponentsDialog,
@ -928,12 +928,12 @@ export class CanvasContextMenu implements ContextMenuDefinitionProvider {
}, },
{ {
condition: (selection: any) => { condition: (selection: any) => {
return selection.size() === 1 && !this.canvasUtils.isConnection(selection); return !selection.empty();
}, },
clazz: 'fa fa-crosshairs', clazz: 'fa fa-crosshairs',
text: 'Center in view', text: 'Center in view',
action: () => { action: () => {
this.store.dispatch(centerSelectedComponent()); this.store.dispatch(centerSelectedComponents({ request: { allowTransition: true } }));
} }
}, },
{ {

View File

@ -590,6 +590,28 @@ export class CanvasUtils {
}); });
} }
/**
* Returns the position for centering a connection based on the presence of bends.
*
* @param d connection data
*/
public getPositionForCenteringConnection(d: any): Position {
let x, y;
if (d.bends.length > 0) {
const i: number = Math.min(Math.max(0, d.labelIndex), d.bends.length - 1);
x = d.bends[i].x;
y = d.bends[i].y;
} else {
x = (d.start.x + d.end.x) / 2;
y = (d.start.y + d.end.y) / 2;
}
return {
x,
y
};
}
/** /**
* Returns the component id of the source of this processor. If the connection is attached * Returns the component id of the source of this processor. If the connection is attached
* to a port in a [sub|remote] group, the component id will be that of the group. Otherwise * to a port in a [sub|remote] group, the component id will be that of the group. Otherwise
@ -1484,6 +1506,7 @@ export class CanvasUtils {
}); });
return canStopTransmitting; return canStopTransmitting;
} }
/** /**
* Determines whether the components in the specified selection can be operated. * Determines whether the components in the specified selection can be operated.
* *

View File

@ -51,6 +51,7 @@ export class CanvasView {
private behavior: any; private behavior: any;
private birdseyeTranslateInProgress = false; private birdseyeTranslateInProgress = false;
private allowTransition = false;
constructor( constructor(
private store: Store<CanvasState>, private store: Store<CanvasState>,
@ -85,6 +86,10 @@ export class CanvasView {
this.svg = svg; this.svg = svg;
this.canvas = canvas; this.canvas = canvas;
this.k = INITIAL_SCALE;
this.x = INITIAL_TRANSLATE.x;
this.y = INITIAL_TRANSLATE.y;
this.labelManager.init(); this.labelManager.init();
this.funnelManager.init(); this.funnelManager.init();
this.portManager.init(viewContainerRef); this.portManager.init(viewContainerRef);
@ -118,7 +123,7 @@ export class CanvasView {
// refresh the canvas // refresh the canvas
refreshed = self.refresh({ refreshed = self.refresh({
transition: self.shouldTransition(event.sourceEvent), transition: self.shouldTransition(),
refreshComponents: false, refreshComponents: false,
refreshBirdseye: false refreshBirdseye: false
}); });
@ -170,18 +175,73 @@ export class CanvasView {
return this.birdseyeTranslateInProgress; return this.birdseyeTranslateInProgress;
} }
// see if the scale has changed during this zoom event, private shouldTransition(): boolean {
// we want to only transition when zooming in/out as running
// the transitions during pan events is undesirable
private shouldTransition(sourceEvent: any): boolean {
if (this.birdseyeTranslateInProgress) { if (this.birdseyeTranslateInProgress) {
return false; return false;
} }
if (sourceEvent) { return this.allowTransition;
return sourceEvent.type === 'wheel' || sourceEvent.type === 'mousewheel'; }
public isSelectedComponentOnScreen(): boolean {
const canvasContainer: any = document.getElementById('canvas-container');
if (canvasContainer == null) {
return false;
}
const selection: any = this.canvasUtils.getSelection();
if (selection.size() !== 1) {
return false;
}
const d = selection.datum();
let translate = [this.x, this.y];
const scale = this.k;
// scale the translation
translate = [translate[0] / scale, translate[1] / scale];
// get the normalized screen width and height
const screenWidth = canvasContainer.offsetWidth / scale;
const screenHeight = canvasContainer.offsetHeight / scale;
// calculate the screen bounds one screens worth in each direction
const screenLeft = -translate[0];
const screenTop = -translate[1];
const screenRight = screenLeft + screenWidth;
const screenBottom = screenTop + screenHeight;
if (this.canvasUtils.isConnection(selection)) {
let connectionX, connectionY;
if (d.bends.length > 0) {
const i: number = Math.min(Math.max(0, d.labelIndex), d.bends.length - 1);
connectionX = d.bends[i].x;
connectionY = d.bends[i].y;
} else { } else {
return true; connectionX = (d.start.x + d.end.x) / 2;
connectionY = (d.start.y + d.end.y) / 2;
}
return (
screenLeft < connectionX &&
screenRight > connectionX &&
screenTop < connectionY &&
screenBottom > connectionY
);
} else {
const componentLeft: number = d.position.x;
const componentTop: number = d.position.y;
const componentRight: number = componentLeft + d.dimensions.width;
const componentBottom: number = componentTop + d.dimensions.height;
// determine if the component is now visible
return (
screenLeft < componentRight &&
screenRight > componentLeft &&
screenTop < componentBottom &&
screenBottom > componentTop
);
} }
} }
@ -230,15 +290,7 @@ export class CanvasView {
return false; return false;
} }
let x, y; const { x, y } = self.canvasUtils.getPositionForCenteringConnection(d);
if (d.bends.length > 0) {
const i: number = Math.min(Math.max(0, d.labelIndex), d.bends.length - 1);
x = d.bends[i].x;
y = d.bends[i].y;
} else {
x = (d.start.x + d.end.x) / 2;
y = (d.start.y + d.end.y) / 2;
}
return screenLeft < x && screenRight > x && screenTop < y && screenBottom > y; return screenLeft < x && screenRight > x && screenTop < y && screenBottom > y;
}; };
@ -293,7 +345,7 @@ export class CanvasView {
} }
/** /**
* Whether or not a component should be rendered based solely on the current scale. * Whether a component should be rendered based solely on the current scale.
* *
* @returns {Boolean} * @returns {Boolean}
*/ */
@ -301,25 +353,38 @@ export class CanvasView {
return this.k >= CanvasView.MIN_SCALE_TO_RENDER; return this.k >= CanvasView.MIN_SCALE_TO_RENDER;
} }
public centerSelectedComponent(): void { public centerSelectedComponents(allowTransition: boolean): void {
const canvasContainer: any = document.getElementById('canvas-container');
if (canvasContainer == null) {
return;
}
const selection: any = this.canvasUtils.getSelection(); const selection: any = this.canvasUtils.getSelection();
if (selection.empty()) {
return;
}
let bbox;
if (selection.size() === 1) { if (selection.size() === 1) {
let box; bbox = this.getSingleSelectionBoundingClientRect(selection);
} else {
bbox = this.getBulkSelectionBoundingClientRect(selection, canvasContainer);
}
this.allowTransition = allowTransition;
this.centerBoundingBox(bbox);
this.allowTransition = false;
}
private getSingleSelectionBoundingClientRect(selection: any): any {
let bbox;
if (this.canvasUtils.isConnection(selection)) { if (this.canvasUtils.isConnection(selection)) {
let x, y;
const d = selection.datum(); const d = selection.datum();
// get the position of the connection label // get the position of the connection label
if (d.bends.length > 0) { const { x, y } = this.canvasUtils.getPositionForCenteringConnection(d);
const i: number = Math.min(Math.max(0, d.labelIndex), d.bends.length - 1);
x = d.bends[i].x;
y = d.bends[i].y;
} else {
x = (d.start.x + d.end.x) / 2;
y = (d.start.y + d.end.y) / 2;
}
box = { bbox = {
x: x, x: x,
y: y, y: y,
width: 1, width: 1,
@ -329,7 +394,7 @@ export class CanvasView {
const selectionData = selection.datum(); const selectionData = selection.datum();
const selectionPosition = selectionData.position; const selectionPosition = selectionData.position;
box = { bbox = {
x: selectionPosition.x, x: selectionPosition.x,
y: selectionPosition.y, y: selectionPosition.y,
width: selectionData.dimensions.width, width: selectionData.dimensions.width,
@ -337,9 +402,44 @@ export class CanvasView {
}; };
} }
// center on the component return bbox;
this.centerBoundingBox(box);
} }
/**
* Get a BoundingClientRect, normalized to the canvas, that encompasses all nodes in a given selection.
*/
private getBulkSelectionBoundingClientRect(selection: any, canvasContainer: any): any {
const canvasBoundingBox: any = canvasContainer.getBoundingClientRect();
const initialBBox: any = {
x: Number.MAX_VALUE,
y: Number.MAX_VALUE,
right: Number.MIN_VALUE,
bottom: Number.MIN_VALUE
};
const bbox = selection.nodes().reduce((aggregateBBox: any, node: any) => {
const rect = node.getBoundingClientRect();
aggregateBBox.x = Math.min(rect.x, aggregateBBox.x);
aggregateBBox.y = Math.min(rect.y, aggregateBBox.y);
aggregateBBox.right = Math.max(rect.right, aggregateBBox.right);
aggregateBBox.bottom = Math.max(rect.bottom, aggregateBBox.bottom);
return aggregateBBox;
}, initialBBox);
// normalize the bounding box with scale and translate
bbox.x = (bbox.x - this.x) / this.k;
bbox.y = (bbox.y - canvasBoundingBox.top - this.y) / this.k;
bbox.right = (bbox.right - this.x) / this.k;
bbox.bottom = (bbox.bottom - canvasBoundingBox.top - this.y) / this.k;
bbox.width = bbox.right - bbox.x;
bbox.height = bbox.bottom - bbox.y;
bbox.top = bbox.y;
bbox.left = bbox.x;
return bbox;
} }
private centerBoundingBox(boundingBox: any): void { private centerBoundingBox(boundingBox: any): void {
@ -430,14 +530,18 @@ export class CanvasView {
* Zooms in a single zoom increment. * Zooms in a single zoom increment.
*/ */
public zoomIn(): void { public zoomIn(): void {
this.allowTransition = true;
this.scale(CanvasView.INCREMENT); this.scale(CanvasView.INCREMENT);
this.allowTransition = false;
} }
/** /**
* Zooms out a single zoom increment. * Zooms out a single zoom increment.
*/ */
public zoomOut(): void { public zoomOut(): void {
this.allowTransition = true;
this.scale(1 / CanvasView.INCREMENT); this.scale(1 / CanvasView.INCREMENT);
this.allowTransition = false;
} }
/** /**
@ -476,7 +580,7 @@ export class CanvasView {
graphTop -= 50; graphTop -= 50;
} }
// center as appropriate this.allowTransition = true;
this.centerBoundingBox({ this.centerBoundingBox({
x: graphLeft - translate[0] / scale, x: graphLeft - translate[0] / scale,
y: graphTop - translate[1] / scale, y: graphTop - translate[1] / scale,
@ -484,6 +588,7 @@ export class CanvasView {
height: canvasHeight / newScale, height: canvasHeight / newScale,
scale: newScale scale: newScale
}); });
this.allowTransition = false;
} }
/** /**
@ -530,8 +635,9 @@ export class CanvasView {
}; };
} }
// center as appropriate this.allowTransition = true;
this.centerBoundingBox(box); this.centerBoundingBox(box);
this.allowTransition = false;
} }
/** /**

View File

@ -109,15 +109,7 @@ export class ConnectionManager {
private getLabelPosition(connectionLabel: any): Position { private getLabelPosition(connectionLabel: any): Position {
const d = connectionLabel.datum(); const d = connectionLabel.datum();
let x, y; let { x, y } = this.canvasUtils.getPositionForCenteringConnection(d);
if (d.bends.length > 0) {
const i: number = Math.min(Math.max(0, d.labelIndex), d.bends.length - 1);
x = d.bends[i].x;
y = d.bends[i].y;
} else {
x = (d.start.x + d.end.x) / 2;
y = (d.start.y + d.end.y) / 2;
}
// offset to account for the label dimensions // offset to account for the label dimensions
x -= ConnectionManager.DIMENSIONS.width / 2; x -= ConnectionManager.DIMENSIONS.width / 2;

View File

@ -70,7 +70,8 @@ import {
UploadProcessGroupRequest, UploadProcessGroupRequest,
NavigateToQueueListing, NavigateToQueueListing,
StartProcessGroupResponse, StartProcessGroupResponse,
StopProcessGroupResponse StopProcessGroupResponse,
CenterComponentRequest
} from './index'; } from './index';
import { StatusHistoryRequest } from '../../../../state/status-history'; import { StatusHistoryRequest } from '../../../../state/status-history';
@ -186,7 +187,10 @@ export const removeSelectedComponents = createAction(
props<{ request: SelectComponentsRequest }>() props<{ request: SelectComponentsRequest }>()
); );
export const centerSelectedComponent = createAction(`${CANVAS_PREFIX} Center Selected Component`); export const centerSelectedComponents = createAction(
`${CANVAS_PREFIX} Center Selected Components`,
props<{ request: CenterComponentRequest }>()
);
/* /*
Create Component Actions Create Component Actions
@ -425,11 +429,27 @@ export const setTransitionRequired = createAction(
props<{ transitionRequired: boolean }>() props<{ transitionRequired: boolean }>()
); );
/**
* skipTransform is used when handling URL events for loading the current PG and component [bulk] selection. since the
* URL is the source of truth we need to indicate skipTransform when the URL changes based on the user selection on
* the canvas. However, we do not want the transform skipped when using link to open or a particular part of the flow.
* In these cases, we want the transform to be applied so the viewport is restored or the component(s) is centered.
*/
export const setSkipTransform = createAction( export const setSkipTransform = createAction(
`${CANVAS_PREFIX} Set Skip Transform`, `${CANVAS_PREFIX} Set Skip Transform`,
props<{ skipTransform: boolean }>() props<{ skipTransform: boolean }>()
); );
/**
* allowTransition is a flag that can be set that indicates if a transition should be used when applying a transform.
* By default, restoring the viewport or selecting/centering components will not use a transition unless explicitly
* specified. Zoom based transforms (like fit or 1:1) will always use a transition.
*/
export const setAllowTransition = createAction(
`${CANVAS_PREFIX} Set Allow Transition`,
props<{ allowTransition: boolean }>()
);
export const navigateToComponent = createAction( export const navigateToComponent = createAction(
`${CANVAS_PREFIX} Navigate To Component`, `${CANVAS_PREFIX} Navigate To Component`,
props<{ request: NavigateToComponentRequest }>() props<{ request: NavigateToComponentRequest }>()

View File

@ -636,7 +636,12 @@ export class FlowEffects {
map((action) => action.request), map((action) => action.request),
concatLatestFrom(() => this.store.select(selectCurrentProcessGroupId)), concatLatestFrom(() => this.store.select(selectCurrentProcessGroupId)),
tap(([request, processGroupId]) => { tap(([request, processGroupId]) => {
this.router.navigate(['/process-groups', processGroupId, request.type, request.id, 'edit']); const url = ['/process-groups', processGroupId, request.type, request.id, 'edit'];
if (this.canvasView.isSelectedComponentOnScreen()) {
this.store.dispatch(FlowActions.navigateWithoutTransform({ url }));
} else {
this.router.navigate(url);
}
}) })
), ),
{ dispatch: false } { dispatch: false }
@ -1771,15 +1776,15 @@ export class FlowEffects {
{ dispatch: false } { dispatch: false }
); );
centerSelectedComponent$ = createEffect( centerSelectedComponents$ = createEffect(() =>
() =>
this.actions$.pipe( this.actions$.pipe(
ofType(FlowActions.centerSelectedComponent), ofType(FlowActions.centerSelectedComponents),
tap(() => { map((action) => action.request),
this.canvasView.centerSelectedComponent(); tap((request) => {
}) this.canvasView.centerSelectedComponents(request.allowTransition);
), }),
{ dispatch: false } switchMap(() => of(FlowActions.setAllowTransition({ allowTransition: false })))
)
); );
navigateToProvenanceForComponent$ = createEffect( navigateToProvenanceForComponent$ = createEffect(

View File

@ -41,6 +41,7 @@ import {
resetFlowState, resetFlowState,
runOnce, runOnce,
runOnceSuccess, runOnceSuccess,
setAllowTransition,
setDragging, setDragging,
setNavigationCollapsed, setNavigationCollapsed,
setOperationCollapsed, setOperationCollapsed,
@ -137,6 +138,7 @@ export const initialState: FlowState = {
saving: false, saving: false,
transitionRequired: false, transitionRequired: false,
skipTransform: false, skipTransform: false,
allowTransition: false,
navigationCollapsed: false, navigationCollapsed: false,
operationCollapsed: false, operationCollapsed: false,
error: null, error: null,
@ -297,15 +299,19 @@ export const flowReducer = createReducer(
}), }),
on(setDragging, (state, { dragging }) => ({ on(setDragging, (state, { dragging }) => ({
...state, ...state,
dragging: dragging dragging
})), })),
on(setTransitionRequired, (state, { transitionRequired }) => ({ on(setTransitionRequired, (state, { transitionRequired }) => ({
...state, ...state,
transitionRequired: transitionRequired transitionRequired
})), })),
on(setSkipTransform, (state, { skipTransform }) => ({ on(setSkipTransform, (state, { skipTransform }) => ({
...state, ...state,
skipTransform: skipTransform skipTransform
})),
on(setAllowTransition, (state, { allowTransition }) => ({
...state,
allowTransition
})), })),
on(navigateWithoutTransform, (state) => ({ on(navigateWithoutTransform, (state) => ({
...state, ...state,
@ -313,11 +319,11 @@ export const flowReducer = createReducer(
})), })),
on(setNavigationCollapsed, (state, { navigationCollapsed }) => ({ on(setNavigationCollapsed, (state, { navigationCollapsed }) => ({
...state, ...state,
navigationCollapsed: navigationCollapsed navigationCollapsed
})), })),
on(setOperationCollapsed, (state, { operationCollapsed }) => ({ on(setOperationCollapsed, (state, { operationCollapsed }) => ({
...state, ...state,
operationCollapsed: operationCollapsed operationCollapsed
})), })),
on(startComponentSuccess, stopComponentSuccess, (state, { response }) => { on(startComponentSuccess, stopComponentSuccess, (state, { response }) => {
return produce(state, (draftState) => { return produce(state, (draftState) => {

View File

@ -80,7 +80,7 @@ export const selectAnySelectedComponentIds = createSelector(selectCurrentRoute,
export const selectBulkSelectedComponentIds = createSelector(selectCurrentRoute, (route) => { export const selectBulkSelectedComponentIds = createSelector(selectCurrentRoute, (route) => {
const ids: string[] = []; const ids: string[] = [];
// only handle either bulk component route // only handle bulk component route
if (route?.params.ids) { if (route?.params.ids) {
ids.push(...route.params.ids.split(',')); ids.push(...route.params.ids.split(','));
} }
@ -140,6 +140,8 @@ export const selectDragging = createSelector(selectFlowState, (state: FlowState)
export const selectSkipTransform = createSelector(selectFlowState, (state: FlowState) => state.skipTransform); export const selectSkipTransform = createSelector(selectFlowState, (state: FlowState) => state.skipTransform);
export const selectAllowTransition = createSelector(selectFlowState, (state: FlowState) => state.allowTransition);
export const selectFunnels = createSelector( export const selectFunnels = createSelector(
selectFlowState, selectFlowState,
(state: FlowState) => state.flow.processGroupFlow?.flow.funnels (state: FlowState) => state.flow.processGroupFlow?.flow.funnels

View File

@ -40,6 +40,10 @@ export interface SelectComponentsRequest {
components: SelectedComponent[]; components: SelectedComponent[];
} }
export interface CenterComponentRequest {
allowTransition: boolean;
}
/* /*
Load Process Group Load Process Group
*/ */
@ -473,6 +477,7 @@ export interface FlowState {
dragging: boolean; dragging: boolean;
transitionRequired: boolean; transitionRequired: boolean;
skipTransform: boolean; skipTransform: boolean;
allowTransition: boolean;
saving: boolean; saving: boolean;
navigationCollapsed: boolean; navigationCollapsed: boolean;
operationCollapsed: boolean; operationCollapsed: boolean;

View File

@ -20,7 +20,7 @@ import { CanvasState } from '../../state';
import { Position } from '../../state/shared'; import { Position } from '../../state/shared';
import { Store } from '@ngrx/store'; import { Store } from '@ngrx/store';
import { import {
centerSelectedComponent, centerSelectedComponents,
deselectAllComponents, deselectAllComponents,
editComponent, editComponent,
editCurrentProcessGroup, editCurrentProcessGroup,
@ -38,6 +38,7 @@ import { selectTransform } from '../../state/transform/transform.selectors';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { SelectedComponent } from '../../state/flow'; import { SelectedComponent } from '../../state/flow';
import { import {
selectAllowTransition,
selectBulkSelectedComponentIds, selectBulkSelectedComponentIds,
selectConnection, selectConnection,
selectCurrentProcessGroupId, selectCurrentProcessGroupId,
@ -57,13 +58,15 @@ import {
selectViewStatusHistoryComponent selectViewStatusHistoryComponent
} from '../../state/flow/flow.selectors'; } from '../../state/flow/flow.selectors';
import { filter, map, switchMap, take } from 'rxjs'; import { filter, map, switchMap, take } from 'rxjs';
import { restoreViewport, zoomFit } from '../../state/transform/transform.actions'; import { restoreViewport } from '../../state/transform/transform.actions';
import { ComponentType, isDefinedAndNotNull } from '../../../../state/shared'; import { ComponentType, isDefinedAndNotNull } from '../../../../state/shared';
import { initialState } from '../../state/flow/flow.reducer'; import { initialState } from '../../state/flow/flow.reducer';
import { CanvasContextMenu } from '../../service/canvas-context-menu.service'; import { CanvasContextMenu } from '../../service/canvas-context-menu.service';
import { getStatusHistoryAndOpenDialog } from '../../../../state/status-history/status-history.actions'; import { getStatusHistoryAndOpenDialog } from '../../../../state/status-history/status-history.actions';
import { loadFlowConfiguration } from '../../../../state/flow-configuration/flow-configuration.actions'; import { loadFlowConfiguration } from '../../../../state/flow-configuration/flow-configuration.actions';
import { concatLatestFrom } from '@ngrx/effects'; import { concatLatestFrom } from '@ngrx/effects';
import { selectUrl } from '../../../../state/router/router.selectors';
import { Storage } from '../../../../service/storage.service';
@Component({ @Component({
selector: 'fd-canvas', selector: 'fd-canvas',
@ -81,6 +84,7 @@ export class Canvas implements OnInit, OnDestroy {
private viewContainerRef: ViewContainerRef, private viewContainerRef: ViewContainerRef,
private store: Store<CanvasState>, private store: Store<CanvasState>,
private canvasView: CanvasView, private canvasView: CanvasView,
private storage: Storage,
public canvasContextMenu: CanvasContextMenu public canvasContextMenu: CanvasContextMenu
) { ) {
this.store this.store
@ -90,6 +94,13 @@ export class Canvas implements OnInit, OnDestroy {
this.scale = transform.scale; this.scale = transform.scale;
}); });
this.store
.select(selectUrl)
.pipe(takeUntilDestroyed())
.subscribe((route) => {
this.storage.setItem('current-canvas-route', route);
});
// load the process group from the route // load the process group from the route
this.store this.store
.select(selectProcessGroupIdFromRoute) .select(selectProcessGroupIdFromRoute)
@ -133,14 +144,17 @@ export class Canvas implements OnInit, OnDestroy {
filter((processGroupId) => processGroupId != initialState.id), filter((processGroupId) => processGroupId != initialState.id),
switchMap(() => this.store.select(selectSingleSelectedComponent)), switchMap(() => this.store.select(selectSingleSelectedComponent)),
filter((selectedComponent) => selectedComponent != null), filter((selectedComponent) => selectedComponent != null),
concatLatestFrom(() => this.store.select(selectSkipTransform)), concatLatestFrom(() => [
this.store.select(selectSkipTransform),
this.store.select(selectAllowTransition)
]),
takeUntilDestroyed() takeUntilDestroyed()
) )
.subscribe(([, skipTransform]) => { .subscribe(([, skipTransform, allowTransition]) => {
if (skipTransform) { if (skipTransform) {
this.store.dispatch(setSkipTransform({ skipTransform: false })); this.store.dispatch(setSkipTransform({ skipTransform: false }));
} else { } else {
this.store.dispatch(centerSelectedComponent()); this.store.dispatch(centerSelectedComponents({ request: { allowTransition } }));
} }
}); });
@ -151,14 +165,17 @@ export class Canvas implements OnInit, OnDestroy {
filter((processGroupId) => processGroupId != initialState.id), filter((processGroupId) => processGroupId != initialState.id),
switchMap(() => this.store.select(selectBulkSelectedComponentIds)), switchMap(() => this.store.select(selectBulkSelectedComponentIds)),
filter((ids) => ids.length > 0), filter((ids) => ids.length > 0),
concatLatestFrom(() => this.store.select(selectSkipTransform)), concatLatestFrom(() => [
this.store.select(selectSkipTransform),
this.store.select(selectAllowTransition)
]),
takeUntilDestroyed() takeUntilDestroyed()
) )
.subscribe(([, skipTransform]) => { .subscribe(([, skipTransform, allowTransition]) => {
if (skipTransform) { if (skipTransform) {
this.store.dispatch(setSkipTransform({ skipTransform: false })); this.store.dispatch(setSkipTransform({ skipTransform: false }));
} else { } else {
this.store.dispatch(zoomFit()); this.store.dispatch(centerSelectedComponents({ request: { allowTransition } }));
} }
}); });

View File

@ -18,27 +18,41 @@
import { ComponentFixture, TestBed } from '@angular/core/testing'; import { ComponentFixture, TestBed } from '@angular/core/testing';
import { FlowStatus } from './flow-status.component'; import { FlowStatus } from './flow-status.component';
import { Search } from '../search/search.component';
import { HttpClientTestingModule } from '@angular/common/http/testing'; import { HttpClientTestingModule } from '@angular/common/http/testing';
import { CdkConnectedOverlay, CdkOverlayOrigin } from '@angular/cdk/overlay'; import { CdkConnectedOverlay, CdkOverlayOrigin } from '@angular/cdk/overlay';
import { MatAutocompleteModule } from '@angular/material/autocomplete'; import { MatAutocompleteModule } from '@angular/material/autocomplete';
import { FormsModule, ReactiveFormsModule } from '@angular/forms'; import { FormsModule, ReactiveFormsModule } from '@angular/forms';
import { Component } from '@angular/core';
import { provideMockStore } from '@ngrx/store/testing';
import { initialState } from '../../../../state/flow/flow.reducer';
describe('FlowStatus', () => { describe('FlowStatus', () => {
let component: FlowStatus; let component: FlowStatus;
let fixture: ComponentFixture<FlowStatus>; let fixture: ComponentFixture<FlowStatus>;
@Component({
selector: 'search',
standalone: true,
template: ''
})
class MockSearch {}
beforeEach(() => { beforeEach(() => {
TestBed.configureTestingModule({ TestBed.configureTestingModule({
imports: [ imports: [
FlowStatus, FlowStatus,
Search, MockSearch,
HttpClientTestingModule, HttpClientTestingModule,
CdkOverlayOrigin, CdkOverlayOrigin,
CdkConnectedOverlay, CdkConnectedOverlay,
MatAutocompleteModule, MatAutocompleteModule,
FormsModule, FormsModule,
ReactiveFormsModule ReactiveFormsModule
],
providers: [
provideMockStore({
initialState
})
] ]
}); });
fixture = TestBed.createComponent(FlowStatus); fixture = TestBed.createComponent(FlowStatus);

View File

@ -186,10 +186,21 @@
</li> </li>
<!-- TODO - Consider showing more context of match like existing UI --> <!-- TODO - Consider showing more context of match like existing UI -->
<li *ngFor="let result of results" class="ml-2 py-1"> <li *ngFor="let result of results" class="ml-2 py-1">
<a *ngIf="!result.parentGroup; else resultLink" [routerLink]="['/process-groups', result.id]"> <a *ngIf="!result.parentGroup; else componentLink" [routerLink]="['/process-groups', result.id]">
{{ result.name }} {{ result.name }}
</a> </a>
<ng-template #resultLink> <ng-template #componentLink>
<a
*ngIf="
result.parentGroup.id == currentProcessGroupId;
else componentInDifferentProcessGroupLink
"
(click)="componentLinkClicked(path, result.id)"
[routerLink]="['/process-groups', result.parentGroup.id, path, result.id]">
{{ result.name ? result.name : result.id }}
</a>
</ng-template>
<ng-template #componentInDifferentProcessGroupLink>
<a [routerLink]="['/process-groups', result.parentGroup.id, path, result.id]"> <a [routerLink]="['/process-groups', result.parentGroup.id, path, result.id]">
{{ result.name ? result.name : result.id }} {{ result.name ? result.name : result.id }}
</a> </a>

View File

@ -22,6 +22,8 @@ import { HttpClientTestingModule } from '@angular/common/http/testing';
import { CdkConnectedOverlay, CdkOverlayOrigin } from '@angular/cdk/overlay'; import { CdkConnectedOverlay, CdkOverlayOrigin } from '@angular/cdk/overlay';
import { FormsModule, ReactiveFormsModule } from '@angular/forms'; import { FormsModule, ReactiveFormsModule } from '@angular/forms';
import { MatAutocompleteModule } from '@angular/material/autocomplete'; import { MatAutocompleteModule } from '@angular/material/autocomplete';
import { provideMockStore } from '@ngrx/store/testing';
import { initialState } from '../../../../state/flow/flow.reducer';
describe('Search', () => { describe('Search', () => {
let component: Search; let component: Search;
@ -37,6 +39,11 @@ describe('Search', () => {
ReactiveFormsModule, ReactiveFormsModule,
CdkConnectedOverlay, CdkConnectedOverlay,
MatAutocompleteModule MatAutocompleteModule
],
providers: [
provideMockStore({
initialState
})
] ]
}); });
fixture = TestBed.createComponent(Search); fixture = TestBed.createComponent(Search);

View File

@ -32,6 +32,11 @@ import { NgForOf, NgIf, NgTemplateOutlet } from '@angular/common';
import { RouterLink } from '@angular/router'; import { RouterLink } from '@angular/router';
import { MatFormFieldModule } from '@angular/material/form-field'; import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input'; import { MatInputModule } from '@angular/material/input';
import { CanvasState } from '../../../../state';
import { Store } from '@ngrx/store';
import { centerSelectedComponents, setAllowTransition } from '../../../../state/flow/flow.actions';
import { selectCurrentRoute } from '../../../../../../state/router/router.selectors';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
@Component({ @Component({
selector: 'search', selector: 'search',
@ -86,11 +91,25 @@ export class Search implements OnInit {
parameterProviderNodeResults: ComponentSearchResult[] = []; parameterProviderNodeResults: ComponentSearchResult[] = [];
parameterResults: ComponentSearchResult[] = []; parameterResults: ComponentSearchResult[] = [];
selectedComponentType: ComponentType | null = null;
selectedComponentId: string | null = null;
constructor( constructor(
private formBuilder: FormBuilder, private formBuilder: FormBuilder,
private searchService: SearchService private searchService: SearchService,
private store: Store<CanvasState>
) { ) {
this.searchForm = this.formBuilder.group({ searchBar: '' }); this.searchForm = this.formBuilder.group({ searchBar: '' });
this.store
.select(selectCurrentRoute)
.pipe(takeUntilDestroyed())
.subscribe((route) => {
if (route?.params) {
this.selectedComponentId = route.params.id;
this.selectedComponentType = route.params.type;
}
});
} }
ngOnInit(): void { ngOnInit(): void {
@ -169,4 +188,12 @@ export class Search implements OnInit {
this.parameterProviderNodeResults = []; this.parameterProviderNodeResults = [];
this.parameterResults = []; this.parameterResults = [];
} }
componentLinkClicked(componentType: ComponentType, id: string): void {
if (componentType == this.selectedComponentType && id == this.selectedComponentId) {
this.store.dispatch(centerSelectedComponents({ request: { allowTransition: true } }));
} else {
this.store.dispatch(setAllowTransition({ allowTransition: true }));
}
}
} }

View File

@ -43,7 +43,7 @@
<i class="fa fa-navicon"></i> <i class="fa fa-navicon"></i>
</button> </button>
<mat-menu #globalMenu="matMenu" xPosition="before"> <mat-menu #globalMenu="matMenu" xPosition="before">
<button mat-menu-item class="global-menu-item" [routerLink]="['/']"> <button mat-menu-item class="global-menu-item" [routerLink]="getCanvasLink()">
<i class="icon icon-drop mr-2"></i> <i class="icon icon-drop mr-2"></i>
Canvas Canvas
</button> </button>

View File

@ -32,6 +32,7 @@ import { MatButtonModule } from '@angular/material/button';
import { NiFiState } from '../../../state'; import { NiFiState } from '../../../state';
import { selectFlowConfiguration } from '../../../state/flow-configuration/flow-configuration.selectors'; import { selectFlowConfiguration } from '../../../state/flow-configuration/flow-configuration.selectors';
import { MatSlideToggleModule } from '@angular/material/slide-toggle'; import { MatSlideToggleModule } from '@angular/material/slide-toggle';
import { Storage } from '../../../service/storage.service';
@Component({ @Component({
selector: 'navigation', selector: 'navigation',
@ -59,6 +60,7 @@ export class Navigation {
private store: Store<NiFiState>, private store: Store<NiFiState>,
private authStorage: AuthStorage, private authStorage: AuthStorage,
private authService: AuthService, private authService: AuthService,
private storage: Storage,
@Inject(DOCUMENT) private _document: Document @Inject(DOCUMENT) private _document: Document
) {} ) {}
@ -94,6 +96,11 @@ export class Navigation {
); );
} }
getCanvasLink(): string {
const canvasRoute = this.storage.getItem('current-canvas-route');
return canvasRoute || '/';
}
toggleTheme(value = !this.isDarkMode) { toggleTheme(value = !this.isDarkMode) {
this.isDarkMode = value; this.isDarkMode = value;
this._document.body.classList.toggle('dark-theme', value); this._document.body.classList.toggle('dark-theme', value);