NIFI-13059: (#8661)

- Adding support for copying and pasting on the canvas.

This closes #8661
This commit is contained in:
Matt Gilman 2024-04-18 18:32:24 -04:00 committed by GitHub
parent 8d8aebc5be
commit 41e4779bc5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 624 additions and 156 deletions

View File

@ -54,11 +54,14 @@ import {
startCurrentProcessGroup, startCurrentProcessGroup,
stopComponents, stopComponents,
stopCurrentProcessGroup, stopCurrentProcessGroup,
stopVersionControlRequest stopVersionControlRequest,
copy,
paste
} from '../state/flow/flow.actions'; } from '../state/flow/flow.actions';
import { ComponentType } from '../../../state/shared'; import { ComponentType } from '../../../state/shared';
import { import {
ConfirmStopVersionControlRequest, ConfirmStopVersionControlRequest,
CopyComponentRequest,
DeleteComponentRequest, DeleteComponentRequest,
MoveComponentRequest, MoveComponentRequest,
OpenChangeVersionDialogRequest, OpenChangeVersionDialogRequest,
@ -76,6 +79,7 @@ import { getComponentStateAndOpenDialog } from '../../../state/component-state/c
import { navigateToComponentDocumentation } from '../../../state/documentation/documentation.actions'; import { navigateToComponentDocumentation } from '../../../state/documentation/documentation.actions';
import * as d3 from 'd3'; import * as d3 from 'd3';
import { Client } from '../../../service/client.service'; import { Client } from '../../../service/client.service';
import { CanvasView } from './canvas-view.service';
@Injectable({ providedIn: 'root' }) @Injectable({ providedIn: 'root' })
export class CanvasContextMenu implements ContextMenuDefinitionProvider { export class CanvasContextMenu implements ContextMenuDefinitionProvider {
@ -1173,12 +1177,12 @@ export class CanvasContextMenu implements ContextMenuDefinitionProvider {
} }
}, },
{ {
condition: (selection: any) => { condition: (selection: d3.Selection<any, any, any, any>) => {
return this.canvasUtils.isDisconnected(selection); return this.canvasUtils.isDisconnected(selection);
}, },
clazz: 'fa icon-group', clazz: 'fa icon-group',
text: 'Group', text: 'Group',
action: (selection: any) => { action: (selection: d3.Selection<any, any, any, any>) => {
const moveComponents: MoveComponentRequest[] = []; const moveComponents: MoveComponentRequest[] = [];
selection.each(function (d: any) { selection.each(function (d: any) {
moveComponents.push({ moveComponents.push({
@ -1212,25 +1216,55 @@ export class CanvasContextMenu implements ContextMenuDefinitionProvider {
isSeparator: true isSeparator: true
}, },
{ {
condition: (selection: any) => { condition: (selection: d3.Selection<any, any, any, any>) => {
// TODO - isCopyable return this.canvasUtils.isCopyable(selection);
return false;
}, },
clazz: 'fa fa-copy', clazz: 'fa fa-copy',
text: 'Copy', text: 'Copy',
action: () => { action: (selection: d3.Selection<any, any, any, any>) => {
// TODO - copy 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
}
})
);
} }
}, },
{ {
condition: (selection: any) => { condition: () => {
// TODO - isPastable return this.canvasUtils.isPastable();
return false;
}, },
clazz: 'fa fa-paste', clazz: 'fa fa-paste',
text: 'Paste', text: 'Paste',
action: () => { action: (selection: d3.Selection<any, any, any, any>, event) => {
// TODO - paste if (event) {
const pasteLocation = this.canvasView.getCanvasPosition({ x: event.pageX, y: event.pageY });
if (pasteLocation) {
this.store.dispatch(
paste({
request: {
pasteLocation
}
})
);
}
}
} }
}, },
{ {
@ -1325,7 +1359,8 @@ export class CanvasContextMenu implements ContextMenuDefinitionProvider {
constructor( constructor(
private store: Store<CanvasState>, private store: Store<CanvasState>,
private canvasUtils: CanvasUtils, private canvasUtils: CanvasUtils,
private client: Client private client: Client,
private canvasView: CanvasView
) { ) {
this.allMenus = new Map<string, ContextMenuDefinition>(); this.allMenus = new Map<string, ContextMenuDefinition>();
this.allMenus.set(this.ROOT_MENU.id, this.ROOT_MENU); this.allMenus.set(this.ROOT_MENU.id, this.ROOT_MENU);

View File

@ -24,6 +24,7 @@ import {
selectBreadcrumbs, selectBreadcrumbs,
selectCanvasPermissions, selectCanvasPermissions,
selectConnections, selectConnections,
selectCopiedSnippet,
selectCurrentProcessGroupId, selectCurrentProcessGroupId,
selectParentProcessGroupId selectParentProcessGroupId
} from '../state/flow/flow.selectors'; } from '../state/flow/flow.selectors';
@ -39,7 +40,7 @@ import { selectCurrentUser } from '../../../state/current-user/current-user.sele
import { FlowConfiguration } from '../../../state/flow-configuration'; import { FlowConfiguration } from '../../../state/flow-configuration';
import { initialState as initialFlowConfigurationState } from '../../../state/flow-configuration/flow-configuration.reducer'; import { initialState as initialFlowConfigurationState } from '../../../state/flow-configuration/flow-configuration.reducer';
import { selectFlowConfiguration } from '../../../state/flow-configuration/flow-configuration.selectors'; import { selectFlowConfiguration } from '../../../state/flow-configuration/flow-configuration.selectors';
import { VersionControlInformation } from '../state/flow'; import { CopiedSnippet, VersionControlInformation } from '../state/flow';
import { Overlay, OverlayRef } from '@angular/cdk/overlay'; import { Overlay, OverlayRef } from '@angular/cdk/overlay';
import { ComponentPortal } from '@angular/cdk/portal'; import { ComponentPortal } from '@angular/cdk/portal';
@ -59,6 +60,7 @@ export class CanvasUtils {
private flowConfiguration: FlowConfiguration | null = initialFlowConfigurationState.flowConfiguration; private flowConfiguration: FlowConfiguration | null = initialFlowConfigurationState.flowConfiguration;
private connections: any[] = []; private connections: any[] = [];
private breadcrumbs: BreadcrumbEntity | null = null; private breadcrumbs: BreadcrumbEntity | null = null;
private copiedSnippet: CopiedSnippet | null = null;
private readonly humanizeDuration: Humanizer; private readonly humanizeDuration: Humanizer;
@ -118,6 +120,13 @@ export class CanvasUtils {
.subscribe((breadcrumbs) => { .subscribe((breadcrumbs) => {
this.breadcrumbs = breadcrumbs; this.breadcrumbs = breadcrumbs;
}); });
this.store
.select(selectCopiedSnippet)
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((copiedSnippet) => {
this.copiedSnippet = copiedSnippet;
});
} }
public hasDownstream(selection: any): boolean { public hasDownstream(selection: any): boolean {
@ -807,14 +816,13 @@ export class CanvasUtils {
* *
* @argument {selection} selection The selection * @argument {selection} selection The selection
*/ */
getOrigin(selection: any): Position { public getOrigin(selection: d3.Selection<any, any, any, any>): Position {
const self: CanvasUtils = this;
let x: number | undefined; let x: number | undefined;
let y: number | undefined; let y: number | undefined;
selection.each(function (this: any, d: any) { selection.each((d, i, nodes) => {
const selected: any = d3.select(this); const selected: any = d3.select(nodes[i]);
if (!self.isConnection(selected)) { if (!this.isConnection(selected)) {
if (x == null || d.position.x < x) { if (x == null || d.position.x < x) {
x = d.position.x; x = d.position.x;
} }
@ -831,6 +839,54 @@ export class CanvasUtils {
return { x, y }; return { x, y };
} }
public isCopyable(selection: d3.Selection<any, any, any, any>): boolean {
// if nothing is selected return
if (selection.empty()) {
return false;
}
if (!this.canRead(selection)) {
return false;
}
// determine how many copyable components are selected
const copyable = selection.filter((d, i, nodes) => {
const selected = d3.select(nodes[i]);
if (this.isConnection(selected)) {
const sourceIncluded = !selection
.filter((source) => {
const sourceComponentId = this.getConnectionSourceComponentId(d);
return sourceComponentId === source.id;
})
.empty();
const destinationIncluded = !selection
.filter((destination) => {
const destinationComponentId = this.getConnectionDestinationComponentId(d);
return destinationComponentId === destination.id;
})
.empty();
return sourceIncluded && destinationIncluded;
} else {
return (
this.isProcessor(selected) ||
this.isFunnel(selected) ||
this.isLabel(selected) ||
this.isProcessGroup(selected) ||
this.isRemoteProcessGroup(selected) ||
this.isInputPort(selected) ||
this.isOutputPort(selected)
);
}
});
// ensure everything selected is copyable
return selection.size() === copyable.size();
}
public isPastable(): boolean {
return this.canvasPermissions.canWrite && this.copiedSnippet != null;
}
/** /**
* Gets the name for this connection. * Gets the name for this connection.
* *

View File

@ -31,6 +31,7 @@ import { RemoteProcessGroupManager } from './manager/remote-process-group-manage
import { ConnectionManager } from './manager/connection-manager.service'; import { ConnectionManager } from './manager/connection-manager.service';
import { deselectAllComponents } from '../state/flow/flow.actions'; import { deselectAllComponents } from '../state/flow/flow.actions';
import { CanvasUtils } from './canvas-utils.service'; import { CanvasUtils } from './canvas-utils.service';
import { Position } from '../state/shared';
@Injectable({ @Injectable({
providedIn: 'root' providedIn: 'root'
@ -185,7 +186,6 @@ export class CanvasView {
public isSelectedComponentOnScreen(): boolean { public isSelectedComponentOnScreen(): boolean {
const canvasContainer: any = document.getElementById('canvas-container'); const canvasContainer: any = document.getElementById('canvas-container');
if (canvasContainer == null) { if (canvasContainer == null) {
return false; return false;
} }
@ -245,6 +245,55 @@ export class CanvasView {
} }
} }
/**
* Determines if a bounding box is fully in the current viewable canvas area.
*
* @param {type} boundingBox Bounding box to check.
* @param {boolean} strict If true, the entire bounding box must be in the viewport.
* If false, only part of the bounding box must be in the viewport.
* @returns {boolean}
*/
public isBoundingBoxInViewport(boundingBox: any, strict: boolean): boolean {
const selection: any = this.canvasUtils.getSelection();
if (selection.size() !== 1) {
return false;
}
const canvasContainer: any = document.getElementById('canvas-container');
if (!canvasContainer) {
return false;
}
const yOffset = canvasContainer.getBoundingClientRect().top;
// scale the translation
const translate = [this.x / this.k, this.y / this.k];
// get the normalized screen width and height
const screenWidth = canvasContainer.offsetWidth / this.k;
const screenHeight = canvasContainer.offsetHeight / this.k;
// 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;
const left = Math.ceil(boundingBox.x);
const right = Math.floor(boundingBox.x + boundingBox.width);
const top = Math.ceil(boundingBox.y - yOffset / this.k);
const bottom = Math.floor(boundingBox.y - yOffset / this.k + boundingBox.height);
if (strict) {
return !(left < screenLeft || right > screenRight || top < screenTop || bottom > screenBottom);
} else {
return (
((left > screenLeft && left < screenRight) || (right < screenRight && right > screenLeft)) &&
((top > screenTop && top < screenBottom) || (bottom < screenBottom && bottom > screenTop))
);
}
}
public updateCanvasVisibility(): void { public updateCanvasVisibility(): void {
const self: CanvasView = this; const self: CanvasView = this;
const canvasContainer: any = document.getElementById('canvas-container'); const canvasContainer: any = document.getElementById('canvas-container');
@ -354,11 +403,6 @@ export class CanvasView {
} }
public centerSelectedComponents(allowTransition: boolean): 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()) { if (selection.empty()) {
return; return;
@ -368,7 +412,7 @@ export class CanvasView {
if (selection.size() === 1) { if (selection.size() === 1) {
bbox = this.getSingleSelectionBoundingClientRect(selection); bbox = this.getSingleSelectionBoundingClientRect(selection);
} else { } else {
bbox = this.getBulkSelectionBoundingClientRect(selection, canvasContainer); bbox = this.getSelectionBoundingClientRect(selection);
} }
this.allowTransition = allowTransition; this.allowTransition = allowTransition;
@ -408,8 +452,13 @@ export class CanvasView {
/** /**
* Get a BoundingClientRect, normalized to the canvas, that encompasses all nodes in a given selection. * Get a BoundingClientRect, normalized to the canvas, that encompasses all nodes in a given selection.
*/ */
private getBulkSelectionBoundingClientRect(selection: any, canvasContainer: any): any { public getSelectionBoundingClientRect(selection: any): any {
const canvasBoundingBox: any = canvasContainer.getBoundingClientRect(); let yOffset = 0;
const canvasContainer: any = document.getElementById('canvas-container');
if (canvasContainer) {
yOffset = canvasContainer.getBoundingClientRect().top;
}
const initialBBox: any = { const initialBBox: any = {
x: Number.MAX_VALUE, x: Number.MAX_VALUE,
@ -430,9 +479,9 @@ export class CanvasView {
// normalize the bounding box with scale and translate // normalize the bounding box with scale and translate
bbox.x = (bbox.x - this.x) / this.k; bbox.x = (bbox.x - this.x) / this.k;
bbox.y = (bbox.y - canvasBoundingBox.top - this.y) / this.k; bbox.y = (bbox.y - yOffset - this.y) / this.k;
bbox.right = (bbox.right - this.x) / this.k; bbox.right = (bbox.right - this.x) / this.k;
bbox.bottom = (bbox.bottom - canvasBoundingBox.top - this.y) / this.k; bbox.bottom = (bbox.bottom - yOffset - this.y) / this.k;
bbox.width = bbox.right - bbox.x; bbox.width = bbox.right - bbox.x;
bbox.height = bbox.bottom - bbox.y; bbox.height = bbox.bottom - bbox.y;
@ -442,6 +491,37 @@ export class CanvasView {
return bbox; return bbox;
} }
public getCanvasPosition(position: Position): Position | null {
const canvasContainer: any = document.getElementById('canvas-container');
if (!canvasContainer) {
return null;
}
const rect = canvasContainer.getBoundingClientRect();
// translate the point onto the canvas
const canvasDropPoint = {
x: position.x - rect.left,
y: position.y - rect.top
};
// if the position is over the canvas fire an event to add the new item
if (
canvasDropPoint.x >= 0 &&
canvasDropPoint.x < rect.width &&
canvasDropPoint.y >= 0 &&
canvasDropPoint.y < rect.height
) {
// adjust the x and y coordinates accordingly
const x = canvasDropPoint.x / this.k - this.x / this.k;
const y = canvasDropPoint.y / this.k - this.y / this.k;
return { x, y };
}
return null;
}
private centerBoundingBox(boundingBox: any): void { private centerBoundingBox(boundingBox: any): void {
let scale: number = this.k; let scale: number = this.k;
if (boundingBox.scale != null) { if (boundingBox.scale != null) {
@ -460,7 +540,7 @@ export class CanvasView {
* @param {type} boundingBox * @param {type} boundingBox
* @returns {number[]} * @returns {number[]}
*/ */
private getCenterForBoundingBox(boundingBox: any): number[] { public getCenterForBoundingBox(boundingBox: any): number[] {
let scale: number = this.k; let scale: number = this.k;
if (boundingBox.scale != null) { if (boundingBox.scale != null) {
scale = boundingBox.scale; scale = boundingBox.scale;

View File

@ -35,7 +35,6 @@ import {
ReplayLastProvenanceEventRequest, ReplayLastProvenanceEventRequest,
RunOnceRequest, RunOnceRequest,
SaveToVersionControlRequest, SaveToVersionControlRequest,
Snippet,
StartComponentRequest, StartComponentRequest,
StartProcessGroupRequest, StartProcessGroupRequest,
StopComponentRequest, StopComponentRequest,
@ -257,33 +256,6 @@ export class FlowService implements PropertyDescriptorRetriever {
return this.httpClient.delete(this.nifiCommon.stripProtocol(deleteComponent.uri), { params }); return this.httpClient.delete(this.nifiCommon.stripProtocol(deleteComponent.uri), { params });
} }
createSnippet(snippet: Snippet): Observable<any> {
return this.httpClient.post(`${FlowService.API}/snippets`, {
disconnectedNodeAcknowledged: this.clusterConnectionService.isDisconnectionAcknowledged(),
snippet
});
}
moveSnippet(snippetId: string, groupId: string): Observable<any> {
const payload: any = {
disconnectedNodeAcknowledged: this.clusterConnectionService.isDisconnectionAcknowledged(),
snippet: {
id: snippetId,
parentGroupId: groupId
}
};
return this.httpClient.put(`${FlowService.API}/snippets/${snippetId}`, payload);
}
deleteSnippet(snippetId: string): Observable<any> {
const params = new HttpParams({
fromObject: {
disconnectedNodeAcknowledged: this.clusterConnectionService.isDisconnectionAcknowledged()
}
});
return this.httpClient.delete(`${FlowService.API}/snippets/${snippetId}`, { params });
}
replayLastProvenanceEvent(request: ReplayLastProvenanceEventRequest): Observable<any> { replayLastProvenanceEvent(request: ReplayLastProvenanceEventRequest): Observable<any> {
return this.httpClient.post(`${FlowService.API}/provenance-events/latest/replays`, request); return this.httpClient.post(`${FlowService.API}/provenance-events/latest/replays`, request);
} }

View File

@ -0,0 +1,118 @@
/*
* 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 { Observable } from 'rxjs';
import { HttpClient, HttpParams } from '@angular/common/http';
import { Snippet, SnippetComponentRequest } from '../state/flow';
import { ClusterConnectionService } from '../../../service/cluster-connection.service';
import { ComponentType } from '../../../state/shared';
import { Client } from '../../../service/client.service';
import { Position } from '../state/shared';
@Injectable({ providedIn: 'root' })
export class SnippetService {
private static readonly API: string = '../nifi-api';
constructor(
private httpClient: HttpClient,
private client: Client,
private clusterConnectionService: ClusterConnectionService
) {}
marshalSnippet(components: SnippetComponentRequest[], processGroupId: string): Snippet {
return components.reduce(
(snippet, component) => {
switch (component.type) {
case ComponentType.Processor:
snippet.processors[component.id] = this.client.getRevision(component.entity);
break;
case ComponentType.InputPort:
snippet.inputPorts[component.id] = this.client.getRevision(component.entity);
break;
case ComponentType.OutputPort:
snippet.outputPorts[component.id] = this.client.getRevision(component.entity);
break;
case ComponentType.ProcessGroup:
snippet.processGroups[component.id] = this.client.getRevision(component.entity);
break;
case ComponentType.RemoteProcessGroup:
snippet.remoteProcessGroups[component.id] = this.client.getRevision(component.entity);
break;
case ComponentType.Funnel:
snippet.funnels[component.id] = this.client.getRevision(component.entity);
break;
case ComponentType.Label:
snippet.labels[component.id] = this.client.getRevision(component.entity);
break;
case ComponentType.Connection:
snippet.connections[component.id] = this.client.getRevision(component.entity);
break;
}
return snippet;
},
{
parentGroupId: processGroupId,
processors: {},
funnels: {},
inputPorts: {},
outputPorts: {},
remoteProcessGroups: {},
processGroups: {},
connections: {},
labels: {}
} as Snippet
);
}
createSnippet(snippet: Snippet): Observable<any> {
return this.httpClient.post(`${SnippetService.API}/snippets`, {
disconnectedNodeAcknowledged: this.clusterConnectionService.isDisconnectionAcknowledged(),
snippet
});
}
moveSnippet(snippetId: string, groupId: string): Observable<any> {
const payload: any = {
disconnectedNodeAcknowledged: this.clusterConnectionService.isDisconnectionAcknowledged(),
snippet: {
id: snippetId,
parentGroupId: groupId
}
};
return this.httpClient.put(`${SnippetService.API}/snippets/${snippetId}`, payload);
}
copySnippet(snippetId: string, pasteLocation: Position, groupId: string): Observable<any> {
const payload: any = {
disconnectedNodeAcknowledged: this.clusterConnectionService.isDisconnectionAcknowledged(),
originX: pasteLocation.x,
originY: pasteLocation.y,
snippetId
};
return this.httpClient.post(`${SnippetService.API}/process-groups/${groupId}/snippet-instance`, payload);
}
deleteSnippet(snippetId: string): Observable<any> {
const params = new HttpParams({
fromObject: {
disconnectedNodeAcknowledged: this.clusterConnectionService.isDisconnectionAcknowledged()
}
});
return this.httpClient.delete(`${SnippetService.API}/snippets/${snippetId}`, { params });
}
}

View File

@ -21,6 +21,8 @@ import {
ChangeVersionDialogRequest, ChangeVersionDialogRequest,
ComponentEntity, ComponentEntity,
ConfirmStopVersionControlRequest, ConfirmStopVersionControlRequest,
CopiedSnippet,
CopyRequest,
CreateComponentRequest, CreateComponentRequest,
CreateComponentResponse, CreateComponentResponse,
CreateConnection, CreateConnection,
@ -64,6 +66,8 @@ import {
OpenGroupComponentsDialogRequest, OpenGroupComponentsDialogRequest,
OpenLocalChangesDialogRequest, OpenLocalChangesDialogRequest,
OpenSaveVersionDialogRequest, OpenSaveVersionDialogRequest,
PasteRequest,
PasteResponse,
RefreshRemoteProcessGroupRequest, RefreshRemoteProcessGroupRequest,
ReplayLastProvenanceEventRequest, ReplayLastProvenanceEventRequest,
RpgManageRemotePortsRequest, RpgManageRemotePortsRequest,
@ -476,6 +480,14 @@ export const moveComponents = createAction(
props<{ request: MoveComponentsRequest }>() props<{ request: MoveComponentsRequest }>()
); );
export const copy = createAction(`${CANVAS_PREFIX} Copy`, props<{ request: CopyRequest }>());
export const copySuccess = createAction(`${CANVAS_PREFIX} Copy Success`, props<{ copiedSnippet: CopiedSnippet }>());
export const paste = createAction(`${CANVAS_PREFIX} Paste`, props<{ request: PasteRequest }>());
export const pasteSuccess = createAction(`${CANVAS_PREFIX} Paste Success`, props<{ response: PasteResponse }>());
/* /*
Delete Component Actions Delete Component Actions
*/ */

View File

@ -39,14 +39,17 @@ import {
tap tap
} from 'rxjs'; } from 'rxjs';
import { import {
CopyComponentRequest,
CreateProcessGroupDialogRequest, CreateProcessGroupDialogRequest,
DeleteComponentResponse, DeleteComponentResponse,
GroupComponentsDialogRequest, GroupComponentsDialogRequest,
ImportFromRegistryDialogRequest, ImportFromRegistryDialogRequest,
LoadProcessGroupRequest, LoadProcessGroupRequest,
LoadProcessGroupResponse, LoadProcessGroupResponse,
MoveComponentRequest,
SaveVersionDialogRequest, SaveVersionDialogRequest,
SaveVersionRequest, SaveVersionRequest,
SelectedComponent,
Snippet, Snippet,
StopVersionControlRequest, StopVersionControlRequest,
StopVersionControlResponse, StopVersionControlResponse,
@ -61,6 +64,7 @@ import { Action, Store } from '@ngrx/store';
import { import {
selectAnySelectedComponentIds, selectAnySelectedComponentIds,
selectChangeVersionRequest, selectChangeVersionRequest,
selectCopiedSnippet,
selectCurrentParameterContext, selectCurrentParameterContext,
selectCurrentProcessGroupId, selectCurrentProcessGroupId,
selectMaxZIndex, selectMaxZIndex,
@ -119,6 +123,8 @@ import { LocalChangesDialog } from '../../ui/canvas/items/flow/local-changes-dia
import { ClusterConnectionService } from '../../../../service/cluster-connection.service'; import { ClusterConnectionService } from '../../../../service/cluster-connection.service';
import { ExtensionTypesService } from '../../../../service/extension-types.service'; import { ExtensionTypesService } from '../../../../service/extension-types.service';
import { ChangeComponentVersionDialog } from '../../../../ui/common/change-component-version-dialog/change-component-version-dialog'; import { ChangeComponentVersionDialog } from '../../../../ui/common/change-component-version-dialog/change-component-version-dialog';
import { SnippetService } from '../../service/snippet.service';
import { selectTransform } from '../transform/transform.selectors';
@Injectable() @Injectable()
export class FlowEffects { export class FlowEffects {
@ -134,6 +140,7 @@ export class FlowEffects {
private birdseyeView: BirdseyeView, private birdseyeView: BirdseyeView,
private connectionManager: ConnectionManager, private connectionManager: ConnectionManager,
private clusterConnectionService: ClusterConnectionService, private clusterConnectionService: ClusterConnectionService,
private snippetService: SnippetService,
private router: Router, private router: Router,
private dialog: MatDialog, private dialog: MatDialog,
private propertyTableHelperService: PropertyTableHelperService, private propertyTableHelperService: PropertyTableHelperService,
@ -1816,53 +1823,11 @@ export class FlowEffects {
map((action) => action.request), map((action) => action.request),
concatLatestFrom(() => this.store.select(selectCurrentProcessGroupId)), concatLatestFrom(() => this.store.select(selectCurrentProcessGroupId)),
mergeMap(([request, processGroupId]) => { mergeMap(([request, processGroupId]) => {
const components: any[] = request.components; const components: MoveComponentRequest[] = request.components;
const snippet = this.snippetService.marshalSnippet(components, processGroupId);
const snippet: Snippet = components.reduce( return from(this.snippetService.createSnippet(snippet)).pipe(
(snippet, component) => { switchMap((response) => this.snippetService.moveSnippet(response.snippet.id, request.groupId)),
switch (component.type) {
case ComponentType.Processor:
snippet.processors[component.id] = this.client.getRevision(component.entity);
break;
case ComponentType.InputPort:
snippet.inputPorts[component.id] = this.client.getRevision(component.entity);
break;
case ComponentType.OutputPort:
snippet.outputPorts[component.id] = this.client.getRevision(component.entity);
break;
case ComponentType.ProcessGroup:
snippet.processGroups[component.id] = this.client.getRevision(component.entity);
break;
case ComponentType.RemoteProcessGroup:
snippet.remoteProcessGroups[component.id] = this.client.getRevision(component.entity);
break;
case ComponentType.Funnel:
snippet.funnels[component.id] = this.client.getRevision(component.entity);
break;
case ComponentType.Label:
snippet.labels[component.id] = this.client.getRevision(component.entity);
break;
case ComponentType.Connection:
snippet.connections[component.id] = this.client.getRevision(component.entity);
break;
}
return snippet;
},
{
parentGroupId: processGroupId,
processors: {},
funnels: {},
inputPorts: {},
outputPorts: {},
remoteProcessGroups: {},
processGroups: {},
connections: {},
labels: {}
} as Snippet
);
return from(this.flowService.createSnippet(snippet)).pipe(
switchMap((response) => this.flowService.moveSnippet(response.snippet.id, request.groupId)),
map(() => { map(() => {
const deleteResponses: DeleteComponentResponse[] = []; const deleteResponses: DeleteComponentResponse[] = [];
@ -1884,6 +1849,157 @@ export class FlowEffects {
) )
); );
copy$ = createEffect(() =>
this.actions$.pipe(
ofType(FlowActions.copy),
map((action) => action.request),
concatLatestFrom(() => this.store.select(selectCurrentProcessGroupId)),
switchMap(([request, processGroupId]) => {
const components: CopyComponentRequest[] = request.components;
const snippet = this.snippetService.marshalSnippet(components, processGroupId);
return of(
FlowActions.copySuccess({
copiedSnippet: {
snippet,
dimensions: request.dimensions,
origin: request.origin
}
})
);
})
)
);
paste$ = createEffect(() =>
this.actions$.pipe(
ofType(FlowActions.paste),
map((action) => action.request),
concatLatestFrom(() => [
this.store.select(selectCopiedSnippet).pipe(isDefinedAndNotNull()),
this.store.select(selectCurrentProcessGroupId),
this.store.select(selectTransform)
]),
switchMap(([request, copiedSnippet, processGroupId, transform]) =>
from(this.snippetService.createSnippet(copiedSnippet.snippet)).pipe(
switchMap((response) => {
let pasteLocation = request.pasteLocation;
const snippetOrigin = copiedSnippet.origin;
const dimensions = copiedSnippet.dimensions;
if (!pasteLocation) {
// if the copied snippet is from a different group or the original items are not in the viewport, center the pasted snippet
if (
copiedSnippet.snippet.parentGroupId != processGroupId ||
!this.canvasView.isBoundingBoxInViewport(dimensions, false)
) {
const center = this.canvasView.getCenterForBoundingBox(dimensions);
pasteLocation = {
x: center[0] - transform.translate.x / transform.scale,
y: center[1] - transform.translate.y / transform.scale
};
} else {
pasteLocation = {
x: snippetOrigin.x + 25,
y: snippetOrigin.y + 25
};
}
}
return from(
this.snippetService.copySnippet(response.snippet.id, pasteLocation, processGroupId)
).pipe(map((response) => FlowActions.pasteSuccess({ response })));
}),
catchError((error) => of(FlowActions.flowSnackbarError({ error: error.error })))
)
)
)
);
pasteSuccess$ = createEffect(() =>
this.actions$.pipe(
ofType(FlowActions.pasteSuccess),
map((action) => action.response),
switchMap((response) => {
this.canvasView.updateCanvasVisibility();
this.birdseyeView.refresh();
const components: SelectedComponent[] = [];
components.push(
...response.flow.labels.map((label) => {
return {
id: label.id,
componentType: ComponentType.Label
};
})
);
components.push(
...response.flow.funnels.map((funnel) => {
return {
id: funnel.id,
componentType: ComponentType.Funnel
};
})
);
components.push(
...response.flow.remoteProcessGroups.map((remoteProcessGroups) => {
return {
id: remoteProcessGroups.id,
componentType: ComponentType.RemoteProcessGroup
};
})
);
components.push(
...response.flow.inputPorts.map((inputPorts) => {
return {
id: inputPorts.id,
componentType: ComponentType.InputPort
};
})
);
components.push(
...response.flow.outputPorts.map((outputPorts) => {
return {
id: outputPorts.id,
componentType: ComponentType.OutputPort
};
})
);
components.push(
...response.flow.processGroups.map((processGroup) => {
return {
id: processGroup.id,
componentType: ComponentType.ProcessGroup
};
})
);
components.push(
...response.flow.processors.map((processor) => {
return {
id: processor.id,
componentType: ComponentType.Processor
};
})
);
components.push(
...response.flow.connections.map((connection) => {
return {
id: connection.id,
componentType: ComponentType.Connection
};
})
);
return of(
FlowActions.selectComponents({
request: {
components
}
})
);
})
)
);
deleteComponent$ = createEffect(() => deleteComponent$ = createEffect(() =>
this.actions$.pipe( this.actions$.pipe(
ofType(FlowActions.deleteComponents), ofType(FlowActions.deleteComponents),
@ -1962,8 +2078,8 @@ export class FlowEffects {
} as Snippet } as Snippet
); );
return from(this.flowService.createSnippet(snippet)).pipe( return from(this.snippetService.createSnippet(snippet)).pipe(
switchMap((response) => this.flowService.deleteSnippet(response.snippet.id)), switchMap((response) => this.snippetService.deleteSnippet(response.snippet.id)),
map(() => { map(() => {
const deleteResponses: DeleteComponentResponse[] = []; const deleteResponses: DeleteComponentResponse[] = [];

View File

@ -20,6 +20,7 @@ import {
changeVersionComplete, changeVersionComplete,
changeVersionSuccess, changeVersionSuccess,
clearFlowApiError, clearFlowApiError,
copySuccess,
createComponentComplete, createComponentComplete,
createComponentSuccess, createComponentSuccess,
createConnection, createConnection,
@ -41,6 +42,7 @@ import {
loadProcessorSuccess, loadProcessorSuccess,
loadRemoteProcessGroupSuccess, loadRemoteProcessGroupSuccess,
navigateWithoutTransform, navigateWithoutTransform,
pasteSuccess,
pollChangeVersionSuccess, pollChangeVersionSuccess,
pollRevertChangesSuccess, pollRevertChangesSuccess,
requestRefreshRemoteProcessGroup, requestRefreshRemoteProcessGroup,
@ -143,6 +145,7 @@ export const initialState: FlowState = {
parameterProviderBulletins: [], parameterProviderBulletins: [],
reportingTaskBulletins: [] reportingTaskBulletins: []
}, },
copiedSnippet: null,
dragging: false, dragging: false,
saving: false, saving: false,
versionSaving: false, versionSaving: false,
@ -324,6 +327,51 @@ export const flowReducer = createReducer(
}); });
}); });
}), }),
on(copySuccess, (state, { copiedSnippet }) => ({
...state,
copiedSnippet
})),
on(pasteSuccess, (state, { response }) => {
return produce(state, (draftState) => {
const labels: any[] | null = getComponentCollection(draftState, ComponentType.Label);
if (labels) {
labels.push(...response.flow.labels);
}
const funnels: any[] | null = getComponentCollection(draftState, ComponentType.Funnel);
if (funnels) {
funnels.push(...response.flow.funnels);
}
const remoteProcessGroups: any[] | null = getComponentCollection(
draftState,
ComponentType.RemoteProcessGroup
);
if (remoteProcessGroups) {
remoteProcessGroups.push(...response.flow.remoteProcessGroups);
}
const inputPorts: any[] | null = getComponentCollection(draftState, ComponentType.InputPort);
if (inputPorts) {
inputPorts.push(...response.flow.inputPorts);
}
const outputPorts: any[] | null = getComponentCollection(draftState, ComponentType.OutputPort);
if (outputPorts) {
outputPorts.push(...response.flow.outputPorts);
}
const processGroups: any[] | null = getComponentCollection(draftState, ComponentType.ProcessGroup);
if (processGroups) {
processGroups.push(...response.flow.processGroups);
}
const processors: any[] | null = getComponentCollection(draftState, ComponentType.Processor);
if (processors) {
processors.push(...response.flow.processors);
}
const connections: any[] | null = getComponentCollection(draftState, ComponentType.Connection);
if (connections) {
connections.push(...response.flow.connections);
}
draftState.copiedSnippet = null;
});
}),
on(setDragging, (state, { dragging }) => ({ on(setDragging, (state, { dragging }) => ({
...state, ...state,
dragging dragging

View File

@ -42,6 +42,8 @@ export const selectCurrentProcessGroupId = createSelector(selectFlowState, (stat
export const selectRefreshRpgDetails = createSelector(selectFlowState, (state: FlowState) => state.refreshRpgDetails); export const selectRefreshRpgDetails = createSelector(selectFlowState, (state: FlowState) => state.refreshRpgDetails);
export const selectCopiedSnippet = createSelector(selectFlowState, (state: FlowState) => state.copiedSnippet);
export const selectCurrentParameterContext = createSelector( export const selectCurrentParameterContext = createSelector(
selectFlowState, selectFlowState,
(state: FlowState) => state.flow.processGroupFlow.parameterContext (state: FlowState) => state.flow.processGroupFlow.parameterContext

View File

@ -423,18 +423,36 @@ export interface UpdatePositionsRequest {
connectionUpdates: UpdateComponentRequest[]; connectionUpdates: UpdateComponentRequest[];
} }
export interface MoveComponentRequest { export interface SnippetComponentRequest {
id: string; id: string;
uri: string; uri: string;
type: ComponentType; type: ComponentType;
entity: any; entity: any;
} }
export interface MoveComponentRequest extends SnippetComponentRequest {}
export interface MoveComponentsRequest { export interface MoveComponentsRequest {
components: MoveComponentRequest[]; components: MoveComponentRequest[];
groupId: string; groupId: string;
} }
export interface CopyComponentRequest extends SnippetComponentRequest {}
export interface CopyRequest {
components: CopyComponentRequest[];
origin: Position;
dimensions: any;
}
export interface PasteRequest {
pasteLocation?: Position;
}
export interface PasteResponse {
flow: Flow;
}
export interface DeleteComponentRequest { export interface DeleteComponentRequest {
id: string; id: string;
uri: string; uri: string;
@ -490,6 +508,12 @@ export interface Snippet {
}; };
} }
export interface CopiedSnippet {
snippet: Snippet;
origin: Position;
dimensions: any;
}
/* /*
Tooltips Tooltips
*/ */
@ -613,6 +637,7 @@ export interface FlowState {
error: string | null; error: string | null;
versionSaving: boolean; versionSaving: boolean;
changeVersionRequest: FlowUpdateRequestEntity | null; changeVersionRequest: FlowUpdateRequestEntity | null;
copiedSnippet: CopiedSnippet | null;
status: 'pending' | 'loading' | 'error' | 'success'; status: 'pending' | 'loading' | 'error' | 'success';
} }

View File

@ -116,8 +116,8 @@
color="primary" color="primary"
class="mr-2" class="mr-2"
type="button" type="button"
[disabled]="!canPaste(selection)" [disabled]="!canPaste()"
(click)="paste(selection)"> (click)="paste()">
<i class="fa fa-paste"></i> <i class="fa fa-paste"></i>
</button> </button>
<button <button

View File

@ -17,11 +17,13 @@
import { Component, Input } from '@angular/core'; import { Component, Input } from '@angular/core';
import { import {
copy,
deleteComponents, deleteComponents,
getParameterContextsAndOpenGroupComponentsDialog, getParameterContextsAndOpenGroupComponentsDialog,
navigateToEditComponent, navigateToEditComponent,
navigateToEditCurrentProcessGroup, navigateToEditCurrentProcessGroup,
navigateToManageComponentPolicies, navigateToManageComponentPolicies,
paste,
setOperationCollapsed, setOperationCollapsed,
startComponents, startComponents,
startCurrentProcessGroup, startCurrentProcessGroup,
@ -34,6 +36,7 @@ import { CanvasUtils } from '../../../../service/canvas-utils.service';
import { initialState } from '../../../../state/flow/flow.reducer'; import { initialState } from '../../../../state/flow/flow.reducer';
import { Storage } from '../../../../../../service/storage.service'; import { Storage } from '../../../../../../service/storage.service';
import { import {
CopyComponentRequest,
DeleteComponentRequest, DeleteComponentRequest,
MoveComponentRequest, MoveComponentRequest,
StartComponentRequest, StartComponentRequest,
@ -43,6 +46,8 @@ import {
import { BreadcrumbEntity } from '../../../../state/shared'; import { BreadcrumbEntity } from '../../../../state/shared';
import { ComponentType } from '../../../../../../state/shared'; import { ComponentType } from '../../../../../../state/shared';
import { MatButtonModule } from '@angular/material/button'; import { MatButtonModule } from '@angular/material/button';
import * as d3 from 'd3';
import { CanvasView } from '../../../../service/canvas-view.service';
@Component({ @Component({
selector: 'operation-control', selector: 'operation-control',
@ -63,6 +68,7 @@ export class OperationControl {
constructor( constructor(
private store: Store<CanvasState>, private store: Store<CanvasState>,
public canvasUtils: CanvasUtils, public canvasUtils: CanvasUtils,
private canvasView: CanvasView,
private storage: Storage private storage: Storage
) { ) {
try { try {
@ -335,22 +341,45 @@ export class OperationControl {
} }
} }
canCopy(selection: any): boolean { canCopy(selection: d3.Selection<any, any, any, any>): boolean {
// TODO - isCopyable return this.canvasUtils.isCopyable(selection);
return false;
} }
copy(selection: any): void { copy(selection: d3.Selection<any, any, any, any>): void {
// TODO - copy 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
}
})
);
} }
canPaste(selection: any): boolean { canPaste(): boolean {
// TODO - isPastable return this.canvasUtils.isPastable();
return false;
} }
paste(selection: any): void { paste(): void {
// TODO - paste this.store.dispatch(
paste({
request: {}
})
);
} }
canGroup(selection: any): boolean { canGroup(selection: any): boolean {

View File

@ -19,14 +19,12 @@ import { Component, Input } from '@angular/core';
import { CdkDrag, CdkDragEnd } from '@angular/cdk/drag-drop'; import { CdkDrag, CdkDragEnd } from '@angular/cdk/drag-drop';
import { Store } from '@ngrx/store'; import { Store } from '@ngrx/store';
import { CanvasState } from '../../../../state'; import { CanvasState } from '../../../../state';
import { INITIAL_SCALE, INITIAL_TRANSLATE } from '../../../../state/transform/transform.reducer';
import { selectTransform } from '../../../../state/transform/transform.selectors';
import { createComponentRequest, setDragging } from '../../../../state/flow/flow.actions'; import { createComponentRequest, setDragging } from '../../../../state/flow/flow.actions';
import { Client } from '../../../../../../service/client.service'; import { Client } from '../../../../../../service/client.service';
import { selectDragging } from '../../../../state/flow/flow.selectors'; import { selectDragging } from '../../../../state/flow/flow.selectors';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { Position } from '../../../../state/shared';
import { ComponentType } from '../../../../../../state/shared'; import { ComponentType } from '../../../../../../state/shared';
import { CanvasView } from '../../../../service/canvas-view.service';
@Component({ @Component({
selector: 'new-canvas-item', selector: 'new-canvas-item',
@ -45,21 +43,11 @@ export class NewCanvasItem {
private hovering = false; private hovering = false;
private scale: number = INITIAL_SCALE;
private translate: Position = INITIAL_TRANSLATE;
constructor( constructor(
private client: Client, private client: Client,
private canvasView: CanvasView,
private store: Store<CanvasState> private store: Store<CanvasState>
) { ) {
this.store
.select(selectTransform)
.pipe(takeUntilDestroyed())
.subscribe((transform) => {
this.scale = transform.scale;
this.translate = transform.translate;
});
this.store this.store
.select(selectDragging) .select(selectDragging)
.pipe(takeUntilDestroyed()) .pipe(takeUntilDestroyed())
@ -93,27 +81,10 @@ export class NewCanvasItem {
} }
onDragEnded(event: CdkDragEnd): void { onDragEnded(event: CdkDragEnd): void {
const canvasContainer: any = document.getElementById('canvas-container');
const rect = canvasContainer.getBoundingClientRect();
const dropPoint = event.dropPoint; const dropPoint = event.dropPoint;
// translate the drop point onto the canvas const position = this.canvasView.getCanvasPosition(dropPoint);
const canvasDropPoint = { if (position) {
x: dropPoint.x - rect.left,
y: dropPoint.y - rect.top
};
// if the position is over the canvas fire an event to add the new item
if (
canvasDropPoint.x >= 0 &&
canvasDropPoint.x < rect.width &&
canvasDropPoint.y >= 0 &&
canvasDropPoint.y < rect.height
) {
// adjust the x and y coordinates accordingly
const x = canvasDropPoint.x / this.scale - this.translate.x / this.scale;
const y = canvasDropPoint.y / this.scale - this.translate.y / this.scale;
this.store.dispatch( this.store.dispatch(
createComponentRequest({ createComponentRequest({
request: { request: {
@ -122,7 +93,7 @@ export class NewCanvasItem {
version: 0 version: 0
}, },
type: this.type, type: this.type,
position: { x, y } position
} }
}) })
); );

View File

@ -44,7 +44,11 @@
<b>History</b> <b>History</b>
<ul class="px-2"> <ul class="px-2">
@for (previousValue of propertyHistory.previousValues; track previousValue) { @for (previousValue of propertyHistory.previousValues; track previousValue) {
<li>{{ previousValue.previousValue }} - {{ previousValue.timestamp }} ({{ previousValue.userIdentity }})</li> <li>
{{ previousValue.previousValue }} - {{ previousValue.timestamp }} ({{
previousValue.userIdentity
}})
</li>
} }
</ul> </ul>
</div> </div>