[NIFI-13158] support for keyboard shortcuts (#8770)

* removed aliasing of 'this' from canvas.component.ts
* removed aliasing of 'this' from canvas-utils.ts
* reuse common actions between operation and context menu

This closes #8770
This commit is contained in:
Rob Fellows 2024-05-08 11:33:53 -04:00 committed by GitHub
parent 45098ed859
commit 0f39428209
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 658 additions and 655 deletions

View File

@ -0,0 +1,469 @@
/*
* 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 { Injectable } from '@angular/core';
import { CanvasUtils } from './canvas-utils.service';
import {
copy,
deleteComponents,
disableComponents,
disableCurrentProcessGroup,
enableComponents,
enableCurrentProcessGroup,
getParameterContextsAndOpenGroupComponentsDialog,
leaveProcessGroup,
navigateToEditComponent,
navigateToEditCurrentProcessGroup,
navigateToManageComponentPolicies,
paste,
reloadFlow,
selectComponents,
startComponents,
startCurrentProcessGroup,
stopComponents,
stopCurrentProcessGroup
} from '../state/flow/flow.actions';
import {
CopyComponentRequest,
DeleteComponentRequest,
DisableComponentRequest,
EnableComponentRequest,
MoveComponentRequest,
PasteRequest,
SelectedComponent,
StartComponentRequest,
StopComponentRequest
} from '../state/flow';
import { Store } from '@ngrx/store';
import { CanvasState } from '../state';
import * as d3 from 'd3';
import { MatDialog } from '@angular/material/dialog';
import { CanvasView } from './canvas-view.service';
import { ComponentType } from '../../../state/shared';
import { Client } from '../../../service/client.service';
export type CanvasConditionFunction = (selection: d3.Selection<any, any, any, any>) => boolean;
export type CanvasActionFunction = (selection: d3.Selection<any, any, any, any>, extraArgs?: any) => void;
export interface CanvasAction {
id: string;
condition: CanvasConditionFunction;
action: CanvasActionFunction;
}
export interface CanvasActions {
[key: string]: CanvasAction;
}
@Injectable({
providedIn: 'root'
})
export class CanvasActionsService {
private _actions: CanvasActions = {
delete: {
id: 'delete',
condition: (selection: d3.Selection<any, any, any, any>) => {
return this.canvasUtils.areDeletable(selection);
},
action: (selection: d3.Selection<any, any, any, any>) => {
if (selection.size() === 1) {
const selectionData = selection.datum();
this.store.dispatch(
deleteComponents({
request: [
{
id: selectionData.id,
type: selectionData.type,
uri: selectionData.uri,
entity: selectionData
}
]
})
);
} else {
const requests: DeleteComponentRequest[] = [];
selection.each((d: any) => {
requests.push({
id: d.id,
type: d.type,
uri: d.uri,
entity: d
});
});
this.store.dispatch(
deleteComponents({
request: requests
})
);
}
}
},
refresh: {
id: 'refresh',
condition: (selection: d3.Selection<any, any, any, any>) => {
return this.canvasUtils.emptySelection(selection);
},
action: () => {
this.store.dispatch(reloadFlow());
}
},
leaveGroup: {
id: 'leaveGroup',
condition: (selection: d3.Selection<any, any, any, any>) => {
const dialogsAreOpen = this.dialog.openDialogs.length > 0;
return this.canvasUtils.isNotRootGroupAndEmptySelection(selection) && !dialogsAreOpen;
},
action: () => {
this.store.dispatch(leaveProcessGroup());
}
},
copy: {
id: 'copy',
condition: (selection: d3.Selection<any, any, any, any>) => {
return this.canvasUtils.isCopyable(selection);
},
action: (selection: d3.Selection<any, any, any, any>) => {
const origin = this.canvasUtils.getOrigin(selection);
const dimensions = this.canvasView.getSelectionBoundingClientRect(selection);
const components: CopyComponentRequest[] = [];
selection.each((d) => {
components.push({
id: d.id,
type: d.type,
uri: d.uri,
entity: d
});
});
this.store.dispatch(
copy({
request: {
components,
origin,
dimensions
}
})
);
}
},
paste: {
id: 'paste',
condition: () => {
return this.canvasUtils.isPastable();
},
action: (selection, extraArgs) => {
const pasteRequest: PasteRequest = {};
if (extraArgs?.pasteLocation) {
pasteRequest.pasteLocation = extraArgs.pasteLocation;
}
this.store.dispatch(
paste({
request: pasteRequest
})
);
}
},
selectAll: {
id: 'selectAll',
condition: () => {
return true;
},
action: () => {
const selectedComponents = this.select(d3.selectAll('g.component, g.connection'));
this.store.dispatch(
selectComponents({
request: {
components: selectedComponents
}
})
);
}
},
configure: {
id: 'configure',
condition: (selection: d3.Selection<any, any, any, any>) => {
return this.canvasUtils.isConfigurable(selection);
},
action: (selection: d3.Selection<any, any, any, any>) => {
if (selection.empty()) {
this.store.dispatch(navigateToEditCurrentProcessGroup());
} else {
const selectionData = selection.datum();
this.store.dispatch(
navigateToEditComponent({
request: {
type: selectionData.type,
id: selectionData.id
}
})
);
}
}
},
manageAccess: {
id: 'manageAccess',
condition: (selection: d3.Selection<any, any, any, any>) => {
return this.canvasUtils.canManagePolicies(selection);
},
action: (selection: d3.Selection<any, any, any, any>, extraArgs?) => {
if (selection.empty()) {
if (extraArgs?.processGroupId) {
this.store.dispatch(
navigateToManageComponentPolicies({
request: {
resource: 'process-groups',
id: extraArgs.processGroupId
}
})
);
}
} else {
const selectionData = selection.datum();
const componentType: ComponentType = selectionData.type;
let resource = 'process-groups';
switch (componentType) {
case ComponentType.Processor:
resource = 'processors';
break;
case ComponentType.InputPort:
resource = 'input-ports';
break;
case ComponentType.OutputPort:
resource = 'output-ports';
break;
case ComponentType.Funnel:
resource = 'funnels';
break;
case ComponentType.Label:
resource = 'labels';
break;
case ComponentType.RemoteProcessGroup:
resource = 'remote-process-groups';
break;
}
this.store.dispatch(
navigateToManageComponentPolicies({
request: {
resource,
id: selectionData.id
}
})
);
}
}
},
start: {
id: 'start',
condition: (selection: d3.Selection<any, any, any, any>) => {
if (!selection) {
return false;
}
return this.canvasUtils.areAnyRunnable(selection);
},
action: (selection: d3.Selection<any, any, any, any>) => {
if (selection.empty()) {
// attempting to start the current process group
this.store.dispatch(startCurrentProcessGroup());
} else {
const components: StartComponentRequest[] = [];
const startable = this.canvasUtils.getStartable(selection);
startable.each((d: any) => {
components.push({
id: d.id,
uri: d.uri,
type: d.type,
revision: this.client.getRevision(d),
errorStrategy: 'snackbar'
});
});
this.store.dispatch(
startComponents({
request: {
components
}
})
);
}
}
},
stop: {
id: 'stop',
condition: (selection: d3.Selection<any, any, any, any>) => {
return this.canvasUtils.areAnyStoppable(selection);
},
action: (selection: d3.Selection<any, any, any, any>) => {
if (selection.empty()) {
// attempting to start the current process group
this.store.dispatch(stopCurrentProcessGroup());
} else {
const components: StopComponentRequest[] = [];
const stoppable = this.canvasUtils.getStoppable(selection);
stoppable.each((d: any) => {
components.push({
id: d.id,
uri: d.uri,
type: d.type,
revision: this.client.getRevision(d),
errorStrategy: 'snackbar'
});
});
this.store.dispatch(
stopComponents({
request: {
components
}
})
);
}
}
},
enable: {
id: 'enable',
condition: (selection: d3.Selection<any, any, any, any>) => {
return this.canvasUtils.canEnable(selection);
},
action: (selection: d3.Selection<any, any, any, any>) => {
if (selection.empty()) {
// attempting to enable the current process group
this.store.dispatch(enableCurrentProcessGroup());
} else {
const components: EnableComponentRequest[] = [];
const enableable = this.canvasUtils.filterEnable(selection);
enableable.each((d: any) => {
components.push({
id: d.id,
uri: d.uri,
type: d.type,
revision: this.client.getRevision(d),
errorStrategy: 'snackbar'
});
});
this.store.dispatch(
enableComponents({
request: {
components
}
})
);
}
}
},
disable: {
id: 'disable',
condition: (selection: d3.Selection<any, any, any, any>) => {
return this.canvasUtils.canDisable(selection);
},
action: (selection: d3.Selection<any, any, any, any>) => {
if (selection.empty()) {
// attempting to disable the current process group
this.store.dispatch(disableCurrentProcessGroup());
} else {
const components: DisableComponentRequest[] = [];
const disableable = this.canvasUtils.filterDisable(selection);
disableable.each((d: any) => {
components.push({
id: d.id,
uri: d.uri,
type: d.type,
revision: this.client.getRevision(d),
errorStrategy: 'snackbar'
});
});
this.store.dispatch(
disableComponents({
request: {
components
}
})
);
}
}
},
group: {
id: 'group',
condition: (selection: d3.Selection<any, any, any, any>) => {
return this.canvasUtils.isDisconnected(selection) && this.canvasUtils.canModify(selection);
},
action: (selection: d3.Selection<any, any, any, any>) => {
const moveComponents: MoveComponentRequest[] = [];
selection.each(function (d: any) {
moveComponents.push({
id: d.id,
type: d.type,
uri: d.uri,
entity: d
});
});
// move the selection into the group
this.store.dispatch(
getParameterContextsAndOpenGroupComponentsDialog({
request: {
moveComponents,
position: this.canvasUtils.getOrigin(selection)
}
})
);
}
}
};
constructor(
private store: Store<CanvasState>,
private canvasUtils: CanvasUtils,
private canvasView: CanvasView,
private dialog: MatDialog,
private client: Client
) {}
private select(selection: d3.Selection<any, any, any, any>) {
const selectedComponents: SelectedComponent[] = [];
if (selection) {
selection.each((d: any) => {
selectedComponents.push({
id: d.id,
componentType: d.type
});
});
}
return selectedComponents;
}
getAction(id: string): CanvasAction | null {
if (this._actions && this._actions[id]) {
return this._actions[id];
}
return null;
}
getActionFunction(id: string): CanvasActionFunction {
if (this._actions && this._actions[id]) {
return this._actions[id].action;
}
return () => {};
}
getConditionFunction(id: string): CanvasConditionFunction {
if (this._actions && this._actions[id]) {
return this._actions[id].condition;
}
return () => false;
}
}

View File

@ -21,12 +21,9 @@ import { Store } from '@ngrx/store';
import { CanvasState } from '../state';
import {
centerSelectedComponents,
deleteComponents,
downloadFlow,
enterProcessGroup,
getParameterContextsAndOpenGroupComponentsDialog,
goToRemoteProcessGroup,
leaveProcessGroup,
moveComponents,
moveToFront,
navigateToAdvancedProcessorUi,
@ -34,8 +31,8 @@ import {
navigateToControllerServicesForProcessGroup,
navigateToEditComponent,
navigateToEditCurrentProcessGroup,
navigateToManageComponentPolicies,
navigateToManageRemotePorts,
navigateToParameterContext,
navigateToProvenanceForComponent,
navigateToQueueListing,
navigateToViewStatusHistoryForComponent,
@ -46,36 +43,18 @@ import {
openRevertLocalChangesDialogRequest,
openSaveVersionDialogRequest,
openShowLocalChangesDialogRequest,
reloadFlow,
replayLastProvenanceEvent,
requestRefreshRemoteProcessGroup,
runOnce,
startComponents,
startCurrentProcessGroup,
stopComponents,
stopCurrentProcessGroup,
stopVersionControlRequest,
copy,
paste,
terminateThreads,
navigateToParameterContext,
enableCurrentProcessGroup,
enableComponents,
disableCurrentProcessGroup,
disableComponents
terminateThreads
} from '../state/flow/flow.actions';
import { ComponentType } from '../../../state/shared';
import {
ConfirmStopVersionControlRequest,
CopyComponentRequest,
DeleteComponentRequest,
DisableComponentRequest,
EnableComponentRequest,
MoveComponentRequest,
OpenChangeVersionDialogRequest,
OpenLocalChangesDialogRequest,
StartComponentRequest,
StopComponentRequest
OpenLocalChangesDialogRequest
} from '../state/flow';
import {
ContextMenuDefinition,
@ -88,6 +67,7 @@ import { navigateToComponentDocumentation } from '../../../state/documentation/d
import * as d3 from 'd3';
import { Client } from '../../../service/client.service';
import { CanvasView } from './canvas-view.service';
import { CanvasActionsService } from './canvas-actions.service';
@Injectable({ providedIn: 'root' })
export class CanvasContextMenu implements ContextMenuDefinitionProvider {
@ -400,49 +380,25 @@ export class CanvasContextMenu implements ContextMenuDefinitionProvider {
id: 'root',
menuItems: [
{
condition: (selection: any) => {
return this.canvasUtils.emptySelection(selection);
},
condition: this.canvasActionsService.getConditionFunction('refresh'),
clazz: 'fa fa-refresh',
text: 'Refresh',
action: () => {
this.store.dispatch(reloadFlow());
}
action: this.canvasActionsService.getActionFunction('refresh')
},
{
condition: (selection: any) => {
return this.canvasUtils.isNotRootGroupAndEmptySelection(selection);
},
condition: this.canvasActionsService.getConditionFunction('leaveGroup'),
clazz: 'fa fa-level-up',
text: 'Leave group',
action: () => {
this.store.dispatch(leaveProcessGroup());
}
action: this.canvasActionsService.getActionFunction('leaveGroup')
},
{
isSeparator: true
},
{
condition: (selection: any) => {
return this.canvasUtils.isConfigurable(selection);
},
condition: this.canvasActionsService.getConditionFunction('configure'),
clazz: 'fa fa-gear',
text: 'Configure',
action: (selection: any) => {
if (selection.empty()) {
this.store.dispatch(navigateToEditCurrentProcessGroup());
} else {
const selectionData = selection.datum();
this.store.dispatch(
navigateToEditComponent({
request: {
type: selectionData.type,
id: selectionData.id
}
})
);
}
}
action: this.canvasActionsService.getActionFunction('configure')
},
{
condition: (selection: d3.Selection<any, any, any, any>) => {
@ -585,35 +541,11 @@ export class CanvasContextMenu implements ContextMenuDefinitionProvider {
startable.filter((d: any) => d.type === ComponentType.RemoteProcessGroup).size() ===
startable.size();
return this.canvasUtils.areAnyRunnable(selection) && !allRpgs;
return this.canvasActionsService.getConditionFunction('start')(selection) && !allRpgs;
},
clazz: 'fa fa-play',
text: 'Start',
action: (selection: any) => {
if (selection.empty()) {
// attempting to start the current process group
this.store.dispatch(startCurrentProcessGroup());
} else {
const components: StartComponentRequest[] = [];
const startable = this.canvasUtils.getStartable(selection);
startable.each((d: any) => {
components.push({
id: d.id,
uri: d.uri,
type: d.type,
revision: this.client.getRevision(d),
errorStrategy: 'snackbar'
});
});
this.store.dispatch(
startComponents({
request: {
components
}
})
);
}
}
action: this.canvasActionsService.getActionFunction('start')
},
{
condition: (selection: any) => {
@ -627,35 +559,11 @@ export class CanvasContextMenu implements ContextMenuDefinitionProvider {
stoppable.filter((d: any) => d.type === ComponentType.RemoteProcessGroup).size() ===
stoppable.size();
return this.canvasUtils.areAnyStoppable(selection) && !allRpgs;
return this.canvasActionsService.getConditionFunction('stop')(selection) && !allRpgs;
},
clazz: 'fa fa-stop',
text: 'Stop',
action: (selection: any) => {
if (selection.empty()) {
// attempting to start the current process group
this.store.dispatch(stopCurrentProcessGroup());
} else {
const components: StopComponentRequest[] = [];
const stoppable = this.canvasUtils.getStoppable(selection);
stoppable.each((d: any) => {
components.push({
id: d.id,
uri: d.uri,
type: d.type,
revision: this.client.getRevision(d),
errorStrategy: 'snackbar'
});
});
this.store.dispatch(
stopComponents({
request: {
components
}
})
);
}
}
action: this.canvasActionsService.getActionFunction('stop')
},
{
condition: (selection: any) => {
@ -697,68 +605,16 @@ export class CanvasContextMenu implements ContextMenuDefinitionProvider {
}
},
{
condition: (selection: d3.Selection<any, any, any, any>) => {
return this.canvasUtils.canEnable(selection);
},
condition: this.canvasActionsService.getConditionFunction('enable'),
clazz: 'fa fa-flash',
text: 'Enable',
action: (selection: d3.Selection<any, any, any, any>) => {
if (selection.empty()) {
// attempting to enable the current process group
this.store.dispatch(enableCurrentProcessGroup());
} else {
const components: EnableComponentRequest[] = [];
const enableable = this.canvasUtils.filterEnable(selection);
enableable.each((d: any) => {
components.push({
id: d.id,
uri: d.uri,
type: d.type,
revision: this.client.getRevision(d),
errorStrategy: 'snackbar'
});
});
this.store.dispatch(
enableComponents({
request: {
components
}
})
);
}
}
action: this.canvasActionsService.getActionFunction('enable')
},
{
condition: (selection: d3.Selection<any, any, any, any>) => {
return this.canvasUtils.canDisable(selection);
},
condition: this.canvasActionsService.getConditionFunction('disable'),
clazz: 'icon icon-enable-false',
text: 'Disable',
action: (selection: d3.Selection<any, any, any, any>) => {
if (selection.empty()) {
// attempting to disable the current process group
this.store.dispatch(disableCurrentProcessGroup());
} else {
const components: DisableComponentRequest[] = [];
const disableable = this.canvasUtils.filterDisable(selection);
disableable.each((d: any) => {
components.push({
id: d.id,
uri: d.uri,
type: d.type,
revision: this.client.getRevision(d),
errorStrategy: 'snackbar'
});
});
this.store.dispatch(
disableComponents({
request: {
components
}
})
);
}
}
action: this.canvasActionsService.getActionFunction('disable')
},
{
condition: (selection: any) => {
@ -766,27 +622,7 @@ export class CanvasContextMenu implements ContextMenuDefinitionProvider {
},
clazz: 'fa fa-bullseye',
text: 'Enable transmission',
action: (selection: d3.Selection<any, any, any, any>) => {
const components: StartComponentRequest[] = [];
const startable = this.canvasUtils.getStartable(selection);
startable.each((d: any) => {
components.push({
id: d.id,
uri: d.uri,
type: d.type,
revision: this.client.getRevision(d),
errorStrategy: 'snackbar'
});
});
this.store.dispatch(
startComponents({
request: {
components
}
})
);
}
action: this.canvasActionsService.getActionFunction('start')
},
{
condition: (selection: any) => {
@ -794,27 +630,7 @@ export class CanvasContextMenu implements ContextMenuDefinitionProvider {
},
clazz: 'icon icon-transmit-false',
text: 'Disable transmission',
action: (selection: d3.Selection<any, any, any, any>) => {
const components: StopComponentRequest[] = [];
const stoppable = this.canvasUtils.getStoppable(selection);
stoppable.each((d: any) => {
components.push({
id: d.id,
uri: d.uri,
type: d.type,
revision: this.client.getRevision(d),
errorStrategy: 'snackbar'
});
});
this.store.dispatch(
stopComponents({
request: {
components
}
})
);
}
action: this.canvasActionsService.getActionFunction('stop')
},
{
isSeparator: true
@ -1023,50 +839,9 @@ export class CanvasContextMenu implements ContextMenuDefinitionProvider {
clazz: 'fa fa-key',
text: 'Manage access policies',
action: (selection: any) => {
if (selection.empty()) {
this.store.dispatch(
navigateToManageComponentPolicies({
request: {
resource: 'process-groups',
id: this.canvasUtils.getProcessGroupId()
}
})
);
} else {
const selectionData = selection.datum();
const componentType: ComponentType = selectionData.type;
let resource = 'process-groups';
switch (componentType) {
case ComponentType.Processor:
resource = 'processors';
break;
case ComponentType.InputPort:
resource = 'input-ports';
break;
case ComponentType.OutputPort:
resource = 'output-ports';
break;
case ComponentType.Funnel:
resource = 'funnels';
break;
case ComponentType.Label:
resource = 'labels';
break;
case ComponentType.RemoteProcessGroup:
resource = 'remote-process-groups';
break;
}
this.store.dispatch(
navigateToManageComponentPolicies({
request: {
resource,
id: selectionData.id
}
})
);
}
this.canvasActionsService.getActionFunction('manageAccess')(selection, {
processGroupId: this.canvasUtils.getProcessGroupId()
});
}
},
{
@ -1243,7 +1018,7 @@ export class CanvasContextMenu implements ContextMenuDefinitionProvider {
isSeparator: true
},
{
condition: (selection: d3.Selection<any, any, any, any>) => {
condition: () => {
return this.canvasUtils.isNotRootGroup();
},
clazz: 'fa fa-arrows',
@ -1272,32 +1047,10 @@ export class CanvasContextMenu implements ContextMenuDefinitionProvider {
}
},
{
condition: (selection: d3.Selection<any, any, any, any>) => {
return this.canvasUtils.isDisconnected(selection) && this.canvasUtils.canModify(selection);
},
condition: this.canvasActionsService.getConditionFunction('group'),
clazz: 'fa icon-group',
text: 'Group',
action: (selection: d3.Selection<any, any, any, any>) => {
const moveComponents: MoveComponentRequest[] = [];
selection.each(function (d: any) {
moveComponents.push({
id: d.id,
type: d.type,
uri: d.uri,
entity: d
});
});
// move the selection into the group
this.store.dispatch(
getParameterContextsAndOpenGroupComponentsDialog({
request: {
moveComponents,
position: this.canvasUtils.getOrigin(selection)
}
})
);
}
action: this.canvasActionsService.getActionFunction('group')
},
{
isSeparator: true
@ -1311,39 +1064,14 @@ export class CanvasContextMenu implements ContextMenuDefinitionProvider {
isSeparator: true
},
{
condition: (selection: d3.Selection<any, any, any, any>) => {
return this.canvasUtils.isCopyable(selection);
},
condition: this.canvasActionsService.getConditionFunction('copy'),
clazz: 'fa fa-copy',
text: 'Copy',
action: (selection: d3.Selection<any, any, any, any>) => {
const origin = this.canvasUtils.getOrigin(selection);
const dimensions = this.canvasView.getSelectionBoundingClientRect(selection);
const components: CopyComponentRequest[] = [];
selection.each((d) => {
components.push({
id: d.id,
type: d.type,
uri: d.uri,
entity: d
});
});
this.store.dispatch(
copy({
request: {
components,
origin,
dimensions
}
})
);
}
action: this.canvasActionsService.getActionFunction('copy')
},
{
condition: () => {
return this.canvasUtils.isPastable();
return this.canvasActionsService.getConditionFunction('paste')(d3.select(null));
},
clazz: 'fa fa-paste',
text: 'Paste',
@ -1351,13 +1079,7 @@ export class CanvasContextMenu implements ContextMenuDefinitionProvider {
if (event) {
const pasteLocation = this.canvasView.getCanvasPosition({ x: event.pageX, y: event.pageY });
if (pasteLocation) {
this.store.dispatch(
paste({
request: {
pasteLocation
}
})
);
this.canvasActionsService.getActionFunction('paste')(selection, { pasteLocation });
}
}
}
@ -1408,43 +1130,10 @@ export class CanvasContextMenu implements ContextMenuDefinitionProvider {
}
},
{
condition: (selection: any) => {
return this.canvasUtils.areDeletable(selection);
},
condition: this.canvasActionsService.getConditionFunction('delete'),
clazz: 'fa fa-trash',
text: 'Delete',
action: (selection: any) => {
if (selection.size() === 1) {
const selectionData = selection.datum();
this.store.dispatch(
deleteComponents({
request: [
{
id: selectionData.id,
type: selectionData.type,
uri: selectionData.uri,
entity: selectionData
}
]
})
);
} else {
const requests: DeleteComponentRequest[] = [];
selection.each(function (d: any) {
requests.push({
id: d.id,
type: d.type,
uri: d.uri,
entity: d
});
});
this.store.dispatch(
deleteComponents({
request: requests
})
);
}
}
action: this.canvasActionsService.getActionFunction('delete')
}
]
};
@ -1455,7 +1144,8 @@ export class CanvasContextMenu implements ContextMenuDefinitionProvider {
private store: Store<CanvasState>,
private canvasUtils: CanvasUtils,
private client: Client,
private canvasView: CanvasView
private canvasView: CanvasView,
private canvasActionsService: CanvasActionsService
) {
this.allMenus = new Map<string, ContextMenuDefinition>();
this.allMenus.set(this.ROOT_MENU.id, this.ROOT_MENU);

View File

@ -347,15 +347,14 @@ export class CanvasUtils {
* @argument {selection} selection The selection
* @return {boolean} Whether the selection is deletable
*/
public areDeletable(selection: any): boolean {
public areDeletable(selection: d3.Selection<any, any, any, any>): boolean {
if (selection.empty()) {
return false;
}
const self: CanvasUtils = this;
let isDeletable = true;
selection.each(function (this: any) {
if (!self.isDeletable(d3.select(this))) {
selection.each((data, index, nodes) => {
if (!this.isDeletable(d3.select(nodes[index]))) {
isDeletable = false;
}
});
@ -826,8 +825,6 @@ export class CanvasUtils {
return false;
}
const self: CanvasUtils = this;
const connections: Map<string, any> = new Map<string, any>();
const components: Map<string, any> = new Map<string, any>();
@ -835,23 +832,23 @@ export class CanvasUtils {
// include connections
selection
.filter(function (d: any) {
.filter((d: any) => {
return d.type === 'Connection';
})
.each(function (d: any) {
.each((d: any) => {
connections.set(d.id, d);
});
// include components and ensure their connections are included
selection
.filter(function (d: any) {
.filter((d: any) => {
return d.type !== 'Connection';
})
.each(function (d: any) {
.each((d: any) => {
components.set(d.id, d.component);
// check all connections of this component
self.getComponentConnections(d.id).forEach((connection) => {
this.getComponentConnections(d.id).forEach((connection) => {
if (!connections.has(connection.id)) {
isDisconnected = false;
}
@ -864,8 +861,8 @@ export class CanvasUtils {
if (isDisconnected) {
// determine whether this connection and its components are included within the selection
isDisconnected =
components.has(self.getConnectionSourceComponentId(connection)) &&
components.has(self.getConnectionDestinationComponentId(connection));
components.has(this.getConnectionSourceComponentId(connection)) &&
components.has(this.getConnectionDestinationComponentId(connection));
}
});
}

View File

@ -37,7 +37,8 @@ import {
switchMap,
take,
takeUntil,
tap
tap,
throttleTime
} from 'rxjs';
import {
CopyComponentRequest,
@ -160,6 +161,7 @@ export class FlowEffects {
reloadFlow$ = createEffect(() =>
this.actions$.pipe(
ofType(FlowActions.reloadFlow),
throttleTime(1000),
concatLatestFrom(() => this.store.select(selectCurrentProcessGroupId)),
switchMap(([, processGroupId]) => {
return of(

View File

@ -15,7 +15,7 @@
* limitations under the License.
*/
import { Component, OnDestroy, OnInit } from '@angular/core';
import { Component, HostListener, OnDestroy, OnInit } from '@angular/core';
import { CanvasState } from '../../state';
import { Position } from '../../state/shared';
import { Store } from '@ngrx/store';
@ -66,6 +66,8 @@ import { getStatusHistoryAndOpenDialog } from '../../../../state/status-history/
import { concatLatestFrom } from '@ngrx/operators';
import { selectUrl } from '../../../../state/router/router.selectors';
import { Storage } from '../../../../service/storage.service';
import { CanvasUtils } from '../../service/canvas-utils.service';
import { CanvasActionsService } from '../../service/canvas-actions.service';
@Component({
selector: 'fd-canvas',
@ -83,7 +85,9 @@ export class Canvas implements OnInit, OnDestroy {
private store: Store<CanvasState>,
private canvasView: CanvasView,
private storage: Storage,
public canvasContextMenu: CanvasContextMenu
private canvasUtils: CanvasUtils,
public canvasContextMenu: CanvasContextMenu,
private canvasActionsService: CanvasActionsService
) {
this.store
.select(selectTransform)
@ -286,20 +290,18 @@ export class Canvas implements OnInit, OnDestroy {
}
private createSvg(): void {
const self: Canvas = this;
this.svg = d3
.select('#canvas-container')
.append('svg')
.attr('class', 'canvas-svg')
.on('contextmenu', function (event) {
.on('contextmenu', (event) => {
// reset the canvas click flag
self.canvasClicked = false;
this.canvasClicked = false;
// if this context menu click was on the canvas (and not a nested
// element) we need to clear the selection
if (event.target === self.svg.node()) {
self.store.dispatch(deselectAllComponents());
if (event.target === this.svg.node()) {
this.store.dispatch(deselectAllComponents());
}
});
@ -318,7 +320,7 @@ export class Canvas implements OnInit, OnDestroy {
.data(['normal', 'ghost', 'unauthorized', 'full'])
.enter()
.append('marker')
.attr('id', function (d: string) {
.attr('id', (d: string) => {
return d;
})
.attr('viewBox', '0 0 6 6')
@ -327,7 +329,7 @@ export class Canvas implements OnInit, OnDestroy {
.attr('markerWidth', 6)
.attr('markerHeight', 6)
.attr('orient', 'auto')
.attr('class', function (d: string) {
.attr('class', (d: string) => {
if (d === 'ghost') {
return 'ghost surface-color';
} else if (d === 'unauthorized') {
@ -419,8 +421,6 @@ export class Canvas implements OnInit, OnDestroy {
}
private initCanvas(): void {
const self: Canvas = this;
const t = [INITIAL_TRANSLATE.x, INITIAL_TRANSLATE.y];
this.canvas = this.svg
.append('g')
@ -430,8 +430,8 @@ export class Canvas implements OnInit, OnDestroy {
// handle canvas events
this.svg
.on('mousedown.selection', function (event: MouseEvent) {
self.canvasClicked = true;
.on('mousedown.selection', (event: MouseEvent) => {
this.canvasClicked = true;
if (event.button !== 0) {
// prevent further propagation (to parents and others handlers
@ -442,8 +442,8 @@ export class Canvas implements OnInit, OnDestroy {
// show selection box if shift is held down
if (event.shiftKey) {
const position: any = d3.pointer(event, self.canvas.node());
self.canvas
const position: any = d3.pointer(event, this.canvas.node());
this.canvas
.append('rect')
.attr('rx', 6)
.attr('ry', 6)
@ -452,11 +452,11 @@ export class Canvas implements OnInit, OnDestroy {
.attr('class', 'component-selection')
.attr('width', 0)
.attr('height', 0)
.attr('stroke-width', function () {
return 1 / self.scale;
.attr('stroke-width', () => {
return 1 / this.scale;
})
.attr('stroke-dasharray', function () {
return 4 / self.scale;
.attr('stroke-dasharray', () => {
return 4 / this.scale;
})
.datum(position);
@ -468,7 +468,7 @@ export class Canvas implements OnInit, OnDestroy {
event.preventDefault();
}
})
.on('mousemove.selection', function (event: MouseEvent) {
.on('mousemove.selection', (event: MouseEvent) => {
// update selection box if shift is held down
if (event.shiftKey) {
// get the selection box
@ -476,7 +476,7 @@ export class Canvas implements OnInit, OnDestroy {
if (!selectionBox.empty()) {
// get the original position
const originalPosition: any = selectionBox.datum();
const position: any = d3.pointer(event, self.canvas.node());
const position: any = d3.pointer(event, this.canvas.node());
const d: any = {};
if (originalPosition[0] < position[0]) {
@ -503,17 +503,17 @@ export class Canvas implements OnInit, OnDestroy {
}
}
})
.on('mouseup.selection', function (this: any) {
.on('mouseup.selection', () => {
// ensure this originated from clicking the canvas, not a component.
// when clicking on a component, the event propagation is stopped so
// it never reaches the canvas. we cannot do this however on up events
// since the drag events break down
if (!self.canvasClicked) {
if (!this.canvasClicked) {
return;
}
// reset the canvas click flag
self.canvasClicked = false;
this.canvasClicked = false;
// get the selection box
const selectionBox: any = d3.select('rect.component-selection');
@ -528,10 +528,11 @@ export class Canvas implements OnInit, OnDestroy {
};
// see if a component should be selected or not
d3.selectAll('g.component').each(function (d: any) {
d3.selectAll('g.component').each((d: any, i, nodes) => {
const item = nodes[i];
// consider it selected if its already selected or enclosed in the bounding box
if (
d3.select(this).classed('selected') ||
d3.select(item).classed('selected') ||
(d.position.x >= selectionBoundingBox.x &&
d.position.x + d.dimensions.width <=
selectionBoundingBox.x + selectionBoundingBox.width &&
@ -547,21 +548,22 @@ export class Canvas implements OnInit, OnDestroy {
});
// see if a connection should be selected or not
d3.selectAll('g.connection').each(function (d: any) {
d3.selectAll('g.connection').each((d: any, i, nodes) => {
// consider all points
const points: Position[] = [d.start].concat(d.bends, [d.end]);
// determine the bounding box
const x: any = d3.extent(points, function (pt: Position) {
const x: any = d3.extent(points, (pt: Position) => {
return pt.x;
});
const y: any = d3.extent(points, function (pt: Position) {
const y: any = d3.extent(points, (pt: Position) => {
return pt.y;
});
const item = nodes[i];
// consider it selected if its already selected or enclosed in the bounding box
if (
d3.select(this).classed('selected') ||
d3.select(item).classed('selected') ||
(x[0] >= selectionBoundingBox.x &&
x[1] <= selectionBoundingBox.x + selectionBoundingBox.width &&
y[0] >= selectionBoundingBox.y &&
@ -575,7 +577,7 @@ export class Canvas implements OnInit, OnDestroy {
});
// dispatch the selected components
self.store.dispatch(
this.store.dispatch(
selectComponents({
request: {
components: selection
@ -593,4 +595,82 @@ export class Canvas implements OnInit, OnDestroy {
this.store.dispatch(resetFlowState());
this.store.dispatch(stopProcessGroupPolling());
}
private executeAction(actionId: string, bypassCondition?: boolean): boolean {
const selection = this.canvasUtils.getSelection();
const canvasAction = this.canvasActionsService.getAction(actionId);
if (canvasAction) {
if (bypassCondition || canvasAction.condition(selection)) {
canvasAction.action(selection);
return true;
}
}
return false;
}
@HostListener('window:keydown.delete', ['$event'])
handleKeyDownDelete() {
this.executeAction('delete');
}
@HostListener('window:keydown.backspace', ['$event'])
handleKeyDownBackspace() {
this.executeAction('delete');
}
@HostListener('window:keydown.control.r', ['$event'])
handleKeyDownCtrlR(event: KeyboardEvent) {
if (this.executeAction('refresh', true)) {
event.preventDefault();
}
}
@HostListener('window:keydown.meta.r', ['$event'])
handleKeyDownMetaR(event: KeyboardEvent) {
if (this.executeAction('refresh', true)) {
event.preventDefault();
}
}
@HostListener('window:keydown.escape', ['$event'])
handleKeyDownEsc() {
this.executeAction('leaveGroup');
}
@HostListener('window:keydown.control.c', ['$event'])
handleKeyDownCtrlC(event: KeyboardEvent) {
if (this.executeAction('copy')) {
event.preventDefault();
}
}
@HostListener('window:keydown.meta.c', ['$event'])
handleKeyDownMetaC(event: KeyboardEvent) {
if (this.executeAction('copy')) {
event.preventDefault();
}
}
@HostListener('window:keydown.control.v', ['$event'])
handleKeyDownCtrlV(event: KeyboardEvent) {
if (this.executeAction('paste')) {
event.preventDefault();
}
}
@HostListener('window:keydown.meta.v', ['$event'])
handleKeyDownMetaV(event: KeyboardEvent) {
if (this.executeAction('paste')) {
event.preventDefault();
}
}
@HostListener('window:keydown.control.a', ['$event'])
handleKeyDownCtrlA(event: KeyboardEvent) {
if (this.executeAction('selectAll')) {
event.preventDefault();
}
}
@HostListener('window:keydown.meta.a', ['$event'])
handleKeyDownMetaA(event: KeyboardEvent) {
if (this.executeAction('selectAll')) {
event.preventDefault();
}
}
}

View File

@ -16,45 +16,19 @@
*/
import { Component, Input } from '@angular/core';
import {
copy,
deleteComponents,
disableComponents,
disableCurrentProcessGroup,
enableComponents,
enableCurrentProcessGroup,
getParameterContextsAndOpenGroupComponentsDialog,
navigateToEditComponent,
navigateToEditCurrentProcessGroup,
navigateToManageComponentPolicies,
paste,
setOperationCollapsed,
startComponents,
startCurrentProcessGroup,
stopComponents,
stopCurrentProcessGroup
} from '../../../../state/flow/flow.actions';
import { setOperationCollapsed } from '../../../../state/flow/flow.actions';
import { Store } from '@ngrx/store';
import { CanvasState } from '../../../../state';
import { CanvasUtils } from '../../../../service/canvas-utils.service';
import { initialState } from '../../../../state/flow/flow.reducer';
import { Storage } from '../../../../../../service/storage.service';
import {
CopyComponentRequest,
DeleteComponentRequest,
DisableComponentRequest,
EnableComponentRequest,
MoveComponentRequest,
StartComponentRequest,
StopComponentRequest
} from '../../../../state/flow';
import { BreadcrumbEntity } from '../../../../state/shared';
import { ComponentType } from '../../../../../../state/shared';
import { MatButtonModule } from '@angular/material/button';
import * as d3 from 'd3';
import { CanvasView } from '../../../../service/canvas-view.service';
import { Client } from '../../../../../../service/client.service';
import { CanvasActionsService } from '../../../../service/canvas-actions.service';
@Component({
selector: 'operation-control',
@ -77,7 +51,8 @@ export class OperationControl {
public canvasUtils: CanvasUtils,
private canvasView: CanvasView,
private client: Client,
private storage: Storage
private storage: Storage,
private canvasActionsService: CanvasActionsService
) {
try {
const item: { [key: string]: boolean } | null = this.storage.getItem(
@ -199,23 +174,11 @@ export class OperationControl {
}
canConfigure(selection: d3.Selection<any, any, any, any>): boolean {
return this.canvasUtils.isConfigurable(selection);
return this.canvasActionsService.getConditionFunction('configure')(selection);
}
configure(selection: d3.Selection<any, any, any, any>): void {
if (selection.empty()) {
this.store.dispatch(navigateToEditCurrentProcessGroup());
} else {
const selectionData = selection.datum();
this.store.dispatch(
navigateToEditComponent({
request: {
type: selectionData.type,
id: selectionData.id
}
})
);
}
this.canvasActionsService.getActionFunction('configure')(selection);
}
supportsManagedAuthorizer(): boolean {
@ -223,114 +186,29 @@ export class OperationControl {
}
canManageAccess(selection: d3.Selection<any, any, any, any>): boolean {
return this.canvasUtils.canManagePolicies(selection);
return this.canvasActionsService.getConditionFunction('manageAccess')(selection);
}
manageAccess(selection: d3.Selection<any, any, any, any>): void {
if (selection.empty()) {
this.store.dispatch(
navigateToManageComponentPolicies({
request: {
resource: 'process-groups',
id: this.breadcrumbEntity.id
}
})
);
} else {
const selectionData = selection.datum();
const componentType: ComponentType = selectionData.type;
let resource = 'process-groups';
switch (componentType) {
case ComponentType.Processor:
resource = 'processors';
break;
case ComponentType.InputPort:
resource = 'input-ports';
break;
case ComponentType.OutputPort:
resource = 'output-ports';
break;
case ComponentType.Funnel:
resource = 'funnels';
break;
case ComponentType.Label:
resource = 'labels';
break;
case ComponentType.RemoteProcessGroup:
resource = 'remote-process-groups';
break;
}
this.store.dispatch(
navigateToManageComponentPolicies({
request: {
resource,
id: selectionData.id
}
})
);
}
this.canvasActionsService.getActionFunction('manageAccess')(selection, {
processGroupId: this.breadcrumbEntity.id
});
}
canEnable(selection: d3.Selection<any, any, any, any>): boolean {
return this.canvasUtils.canEnable(selection);
return this.canvasActionsService.getConditionFunction('enable')(selection);
}
enable(selection: d3.Selection<any, any, any, any>): void {
if (selection.empty()) {
// attempting to enable the current process group
this.store.dispatch(enableCurrentProcessGroup());
} else {
const components: EnableComponentRequest[] = [];
const enableable = this.canvasUtils.filterEnable(selection);
enableable.each((d: any) => {
components.push({
id: d.id,
uri: d.uri,
type: d.type,
revision: this.client.getRevision(d),
errorStrategy: 'snackbar'
});
});
this.store.dispatch(
enableComponents({
request: {
components
}
})
);
}
this.canvasActionsService.getActionFunction('enable')(selection);
}
canDisable(selection: d3.Selection<any, any, any, any>): boolean {
return this.canvasUtils.canDisable(selection);
return this.canvasActionsService.getConditionFunction('disable')(selection);
}
disable(selection: d3.Selection<any, any, any, any>): void {
if (selection.empty()) {
// attempting to disable the current process group
this.store.dispatch(disableCurrentProcessGroup());
} else {
const components: DisableComponentRequest[] = [];
const disableable = this.canvasUtils.filterDisable(selection);
disableable.each((d: any) => {
components.push({
id: d.id,
uri: d.uri,
type: d.type,
revision: this.client.getRevision(d),
errorStrategy: 'snackbar'
});
});
this.store.dispatch(
disableComponents({
request: {
components
}
})
);
}
this.canvasActionsService.getActionFunction('disable')(selection);
}
canStart(selection: d3.Selection<any, any, any, any>): boolean {
@ -338,126 +216,39 @@ export class OperationControl {
}
start(selection: d3.Selection<any, any, any, any>): void {
if (selection.empty()) {
// attempting to start the current process group
this.store.dispatch(startCurrentProcessGroup());
} else {
const components: StartComponentRequest[] = [];
const startable = this.canvasUtils.getStartable(selection);
startable.each((d: any) => {
components.push({
id: d.id,
uri: d.uri,
type: d.type,
revision: this.client.getRevision(d),
errorStrategy: 'snackbar'
});
});
this.store.dispatch(
startComponents({
request: {
components
}
})
);
}
this.canvasActionsService.getActionFunction('start')(selection);
}
canStop(selection: d3.Selection<any, any, any, any>): boolean {
return this.canvasUtils.areAnyStoppable(selection);
return this.canvasActionsService.getConditionFunction('stop')(selection);
}
stop(selection: d3.Selection<any, any, any, any>): void {
if (selection.empty()) {
// attempting to start the current process group
this.store.dispatch(stopCurrentProcessGroup());
} else {
const components: StopComponentRequest[] = [];
const stoppable = this.canvasUtils.getStoppable(selection);
stoppable.each((d: any) => {
components.push({
id: d.id,
uri: d.uri,
type: d.type,
revision: this.client.getRevision(d),
errorStrategy: 'snackbar'
});
});
this.store.dispatch(
stopComponents({
request: {
components
}
})
);
}
this.canvasActionsService.getActionFunction('stop')(selection);
}
canCopy(selection: d3.Selection<any, any, any, any>): boolean {
return this.canvasUtils.isCopyable(selection);
return this.canvasActionsService.getConditionFunction('copy')(selection);
}
copy(selection: d3.Selection<any, any, any, any>): void {
const components: CopyComponentRequest[] = [];
selection.each((d) => {
components.push({
id: d.id,
type: d.type,
uri: d.uri,
entity: d
});
});
const origin = this.canvasUtils.getOrigin(selection);
const dimensions = this.canvasView.getSelectionBoundingClientRect(selection);
this.store.dispatch(
copy({
request: {
components,
origin,
dimensions
}
})
);
this.canvasActionsService.getActionFunction('copy')(selection);
}
canPaste(): boolean {
return this.canvasUtils.isPastable();
return this.canvasActionsService.getConditionFunction('paste')(d3.select(null));
}
paste(): void {
this.store.dispatch(
paste({
request: {}
})
);
return this.canvasActionsService.getActionFunction('paste')(d3.select(null));
}
canGroup(selection: d3.Selection<any, any, any, any>): boolean {
return this.canvasUtils.isDisconnected(selection);
return this.canvasActionsService.getConditionFunction('group')(selection);
}
group(selection: d3.Selection<any, any, any, any>): void {
const moveComponents: MoveComponentRequest[] = [];
selection.each(function (d: any) {
moveComponents.push({
id: d.id,
type: d.type,
uri: d.uri,
entity: d
});
});
// move the selection into the group
this.store.dispatch(
getParameterContextsAndOpenGroupComponentsDialog({
request: {
moveComponents,
position: this.canvasUtils.getOrigin(selection)
}
})
);
this.canvasActionsService.getActionFunction('group')(selection);
}
canColor(selection: d3.Selection<any, any, any, any>): boolean {
@ -470,39 +261,10 @@ export class OperationControl {
}
canDelete(selection: d3.Selection<any, any, any, any>): boolean {
return this.canvasUtils.areDeletable(selection);
return this.canvasActionsService.getConditionFunction('delete')(selection);
}
delete(selection: d3.Selection<any, any, any, any>): void {
if (selection.size() === 1) {
const selectionData = selection.datum();
this.store.dispatch(
deleteComponents({
request: [
{
id: selectionData.id,
type: selectionData.type,
uri: selectionData.uri,
entity: selectionData
}
]
})
);
} else {
const requests: DeleteComponentRequest[] = [];
selection.each(function (d: any) {
requests.push({
id: d.id,
type: d.type,
uri: d.uri,
entity: d
});
});
this.store.dispatch(
deleteComponents({
request: requests
})
);
}
this.canvasActionsService.getActionFunction('delete')(selection);
}
}

View File

@ -19,7 +19,7 @@
<div
class="context-menu pt-2 pb-2 mat-elevation-z8 primary-color"
[class.show-focused]="showFocused$ | async"
(keydown)="keydown()"
(keydown)="keydown($event)"
cdkMenu>
@for (item of getMenuItems(menuId); track item) {
@if (item.isSeparator) {

View File

@ -114,10 +114,13 @@ export class ContextMenu implements OnInit {
return !!menuItemDefinition.subMenuId;
}
keydown(): void {
keydown(event: KeyboardEvent): void {
// TODO - Currently the first item in the context menu is auto focused. By default, this is rendered with an
// outline. This appears to be an issue with the cdkMenu/cdkMenuItem so we are working around it by manually
// overriding styles.
if (event.key === 'Escape') {
event.stopPropagation();
}
this.showFocused.next(true);
}