From 41e4779bc560bf80e9e0fde253ae89525f115cf7 Mon Sep 17 00:00:00 2001 From: Matt Gilman Date: Thu, 18 Apr 2024 18:32:24 -0400 Subject: [PATCH] NIFI-13059: (#8661) - Adding support for copying and pasting on the canvas. This closes #8661 --- .../service/canvas-context-menu.service.ts | 63 ++++-- .../service/canvas-utils.service.ts | 68 +++++- .../service/canvas-view.service.ts | 104 ++++++++- .../flow-designer/service/flow.service.ts | 28 --- .../flow-designer/service/snippet.service.ts | 118 ++++++++++ .../flow-designer/state/flow/flow.actions.ts | 12 + .../flow-designer/state/flow/flow.effects.ts | 212 ++++++++++++++---- .../flow-designer/state/flow/flow.reducer.ts | 48 ++++ .../state/flow/flow.selectors.ts | 2 + .../pages/flow-designer/state/flow/index.ts | 27 ++- .../operation-control.component.html | 4 +- .../operation-control.component.ts | 49 +++- .../new-canvas-item.component.ts | 39 +--- .../property-tip/property-tip.component.html | 6 +- 14 files changed, 624 insertions(+), 156 deletions(-) create mode 100644 nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-frontend/src/main/nifi/src/app/pages/flow-designer/service/snippet.service.ts diff --git a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-frontend/src/main/nifi/src/app/pages/flow-designer/service/canvas-context-menu.service.ts b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-frontend/src/main/nifi/src/app/pages/flow-designer/service/canvas-context-menu.service.ts index ec00ba526d..ba80a35559 100644 --- a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-frontend/src/main/nifi/src/app/pages/flow-designer/service/canvas-context-menu.service.ts +++ b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-frontend/src/main/nifi/src/app/pages/flow-designer/service/canvas-context-menu.service.ts @@ -54,11 +54,14 @@ import { startCurrentProcessGroup, stopComponents, stopCurrentProcessGroup, - stopVersionControlRequest + stopVersionControlRequest, + copy, + paste } from '../state/flow/flow.actions'; import { ComponentType } from '../../../state/shared'; import { ConfirmStopVersionControlRequest, + CopyComponentRequest, DeleteComponentRequest, MoveComponentRequest, OpenChangeVersionDialogRequest, @@ -76,6 +79,7 @@ import { getComponentStateAndOpenDialog } from '../../../state/component-state/c import { navigateToComponentDocumentation } from '../../../state/documentation/documentation.actions'; import * as d3 from 'd3'; import { Client } from '../../../service/client.service'; +import { CanvasView } from './canvas-view.service'; @Injectable({ providedIn: 'root' }) export class CanvasContextMenu implements ContextMenuDefinitionProvider { @@ -1173,12 +1177,12 @@ export class CanvasContextMenu implements ContextMenuDefinitionProvider { } }, { - condition: (selection: any) => { + condition: (selection: d3.Selection) => { return this.canvasUtils.isDisconnected(selection); }, clazz: 'fa icon-group', text: 'Group', - action: (selection: any) => { + action: (selection: d3.Selection) => { const moveComponents: MoveComponentRequest[] = []; selection.each(function (d: any) { moveComponents.push({ @@ -1212,25 +1216,55 @@ export class CanvasContextMenu implements ContextMenuDefinitionProvider { isSeparator: true }, { - condition: (selection: any) => { - // TODO - isCopyable - return false; + condition: (selection: d3.Selection) => { + return this.canvasUtils.isCopyable(selection); }, clazz: 'fa fa-copy', text: 'Copy', - action: () => { - // TODO - copy + action: (selection: d3.Selection) => { + 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) => { - // TODO - isPastable - return false; + condition: () => { + return this.canvasUtils.isPastable(); }, clazz: 'fa fa-paste', text: 'Paste', - action: () => { - // TODO - paste + action: (selection: d3.Selection, event) => { + 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( private store: Store, private canvasUtils: CanvasUtils, - private client: Client + private client: Client, + private canvasView: CanvasView ) { this.allMenus = new Map(); this.allMenus.set(this.ROOT_MENU.id, this.ROOT_MENU); diff --git a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-frontend/src/main/nifi/src/app/pages/flow-designer/service/canvas-utils.service.ts b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-frontend/src/main/nifi/src/app/pages/flow-designer/service/canvas-utils.service.ts index 37b4e7adb8..24ede50236 100644 --- a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-frontend/src/main/nifi/src/app/pages/flow-designer/service/canvas-utils.service.ts +++ b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-frontend/src/main/nifi/src/app/pages/flow-designer/service/canvas-utils.service.ts @@ -24,6 +24,7 @@ import { selectBreadcrumbs, selectCanvasPermissions, selectConnections, + selectCopiedSnippet, selectCurrentProcessGroupId, selectParentProcessGroupId } 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 { initialState as initialFlowConfigurationState } from '../../../state/flow-configuration/flow-configuration.reducer'; 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 { ComponentPortal } from '@angular/cdk/portal'; @@ -59,6 +60,7 @@ export class CanvasUtils { private flowConfiguration: FlowConfiguration | null = initialFlowConfigurationState.flowConfiguration; private connections: any[] = []; private breadcrumbs: BreadcrumbEntity | null = null; + private copiedSnippet: CopiedSnippet | null = null; private readonly humanizeDuration: Humanizer; @@ -118,6 +120,13 @@ export class CanvasUtils { .subscribe((breadcrumbs) => { this.breadcrumbs = breadcrumbs; }); + + this.store + .select(selectCopiedSnippet) + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe((copiedSnippet) => { + this.copiedSnippet = copiedSnippet; + }); } public hasDownstream(selection: any): boolean { @@ -807,14 +816,13 @@ export class CanvasUtils { * * @argument {selection} selection The selection */ - getOrigin(selection: any): Position { - const self: CanvasUtils = this; + public getOrigin(selection: d3.Selection): Position { let x: number | undefined; let y: number | undefined; - selection.each(function (this: any, d: any) { - const selected: any = d3.select(this); - if (!self.isConnection(selected)) { + selection.each((d, i, nodes) => { + const selected: any = d3.select(nodes[i]); + if (!this.isConnection(selected)) { if (x == null || d.position.x < x) { x = d.position.x; } @@ -831,6 +839,54 @@ export class CanvasUtils { return { x, y }; } + public isCopyable(selection: d3.Selection): 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. * diff --git a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-frontend/src/main/nifi/src/app/pages/flow-designer/service/canvas-view.service.ts b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-frontend/src/main/nifi/src/app/pages/flow-designer/service/canvas-view.service.ts index bb8a844836..8614e150ed 100644 --- a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-frontend/src/main/nifi/src/app/pages/flow-designer/service/canvas-view.service.ts +++ b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-frontend/src/main/nifi/src/app/pages/flow-designer/service/canvas-view.service.ts @@ -31,6 +31,7 @@ import { RemoteProcessGroupManager } from './manager/remote-process-group-manage import { ConnectionManager } from './manager/connection-manager.service'; import { deselectAllComponents } from '../state/flow/flow.actions'; import { CanvasUtils } from './canvas-utils.service'; +import { Position } from '../state/shared'; @Injectable({ providedIn: 'root' @@ -185,7 +186,6 @@ export class CanvasView { public isSelectedComponentOnScreen(): boolean { const canvasContainer: any = document.getElementById('canvas-container'); - if (canvasContainer == null) { 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 { const self: CanvasView = this; const canvasContainer: any = document.getElementById('canvas-container'); @@ -354,11 +403,6 @@ export class CanvasView { } public centerSelectedComponents(allowTransition: boolean): void { - const canvasContainer: any = document.getElementById('canvas-container'); - if (canvasContainer == null) { - return; - } - const selection: any = this.canvasUtils.getSelection(); if (selection.empty()) { return; @@ -368,7 +412,7 @@ export class CanvasView { if (selection.size() === 1) { bbox = this.getSingleSelectionBoundingClientRect(selection); } else { - bbox = this.getBulkSelectionBoundingClientRect(selection, canvasContainer); + bbox = this.getSelectionBoundingClientRect(selection); } 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. */ - private getBulkSelectionBoundingClientRect(selection: any, canvasContainer: any): any { - const canvasBoundingBox: any = canvasContainer.getBoundingClientRect(); + public getSelectionBoundingClientRect(selection: any): any { + let yOffset = 0; + + const canvasContainer: any = document.getElementById('canvas-container'); + if (canvasContainer) { + yOffset = canvasContainer.getBoundingClientRect().top; + } const initialBBox: any = { x: Number.MAX_VALUE, @@ -430,9 +479,9 @@ export class CanvasView { // 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.y = (bbox.y - yOffset - this.y) / 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.height = bbox.bottom - bbox.y; @@ -442,6 +491,37 @@ export class CanvasView { 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 { let scale: number = this.k; if (boundingBox.scale != null) { @@ -460,7 +540,7 @@ export class CanvasView { * @param {type} boundingBox * @returns {number[]} */ - private getCenterForBoundingBox(boundingBox: any): number[] { + public getCenterForBoundingBox(boundingBox: any): number[] { let scale: number = this.k; if (boundingBox.scale != null) { scale = boundingBox.scale; diff --git a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-frontend/src/main/nifi/src/app/pages/flow-designer/service/flow.service.ts b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-frontend/src/main/nifi/src/app/pages/flow-designer/service/flow.service.ts index 79be4a518c..08ee7b6c05 100644 --- a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-frontend/src/main/nifi/src/app/pages/flow-designer/service/flow.service.ts +++ b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-frontend/src/main/nifi/src/app/pages/flow-designer/service/flow.service.ts @@ -35,7 +35,6 @@ import { ReplayLastProvenanceEventRequest, RunOnceRequest, SaveToVersionControlRequest, - Snippet, StartComponentRequest, StartProcessGroupRequest, StopComponentRequest, @@ -257,33 +256,6 @@ export class FlowService implements PropertyDescriptorRetriever { return this.httpClient.delete(this.nifiCommon.stripProtocol(deleteComponent.uri), { params }); } - createSnippet(snippet: Snippet): Observable { - return this.httpClient.post(`${FlowService.API}/snippets`, { - disconnectedNodeAcknowledged: this.clusterConnectionService.isDisconnectionAcknowledged(), - snippet - }); - } - - moveSnippet(snippetId: string, groupId: string): Observable { - 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 { - const params = new HttpParams({ - fromObject: { - disconnectedNodeAcknowledged: this.clusterConnectionService.isDisconnectionAcknowledged() - } - }); - return this.httpClient.delete(`${FlowService.API}/snippets/${snippetId}`, { params }); - } - replayLastProvenanceEvent(request: ReplayLastProvenanceEventRequest): Observable { return this.httpClient.post(`${FlowService.API}/provenance-events/latest/replays`, request); } diff --git a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-frontend/src/main/nifi/src/app/pages/flow-designer/service/snippet.service.ts b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-frontend/src/main/nifi/src/app/pages/flow-designer/service/snippet.service.ts new file mode 100644 index 0000000000..411ea5e975 --- /dev/null +++ b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-frontend/src/main/nifi/src/app/pages/flow-designer/service/snippet.service.ts @@ -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 { + return this.httpClient.post(`${SnippetService.API}/snippets`, { + disconnectedNodeAcknowledged: this.clusterConnectionService.isDisconnectionAcknowledged(), + snippet + }); + } + + moveSnippet(snippetId: string, groupId: string): Observable { + 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 { + 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 { + const params = new HttpParams({ + fromObject: { + disconnectedNodeAcknowledged: this.clusterConnectionService.isDisconnectionAcknowledged() + } + }); + return this.httpClient.delete(`${SnippetService.API}/snippets/${snippetId}`, { params }); + } +} diff --git a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-frontend/src/main/nifi/src/app/pages/flow-designer/state/flow/flow.actions.ts b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-frontend/src/main/nifi/src/app/pages/flow-designer/state/flow/flow.actions.ts index 030d9a01fc..7e38808152 100644 --- a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-frontend/src/main/nifi/src/app/pages/flow-designer/state/flow/flow.actions.ts +++ b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-frontend/src/main/nifi/src/app/pages/flow-designer/state/flow/flow.actions.ts @@ -21,6 +21,8 @@ import { ChangeVersionDialogRequest, ComponentEntity, ConfirmStopVersionControlRequest, + CopiedSnippet, + CopyRequest, CreateComponentRequest, CreateComponentResponse, CreateConnection, @@ -64,6 +66,8 @@ import { OpenGroupComponentsDialogRequest, OpenLocalChangesDialogRequest, OpenSaveVersionDialogRequest, + PasteRequest, + PasteResponse, RefreshRemoteProcessGroupRequest, ReplayLastProvenanceEventRequest, RpgManageRemotePortsRequest, @@ -476,6 +480,14 @@ export const moveComponents = createAction( 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 */ diff --git a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-frontend/src/main/nifi/src/app/pages/flow-designer/state/flow/flow.effects.ts b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-frontend/src/main/nifi/src/app/pages/flow-designer/state/flow/flow.effects.ts index 4e6f79db87..cac1991f97 100644 --- a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-frontend/src/main/nifi/src/app/pages/flow-designer/state/flow/flow.effects.ts +++ b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-frontend/src/main/nifi/src/app/pages/flow-designer/state/flow/flow.effects.ts @@ -39,14 +39,17 @@ import { tap } from 'rxjs'; import { + CopyComponentRequest, CreateProcessGroupDialogRequest, DeleteComponentResponse, GroupComponentsDialogRequest, ImportFromRegistryDialogRequest, LoadProcessGroupRequest, LoadProcessGroupResponse, + MoveComponentRequest, SaveVersionDialogRequest, SaveVersionRequest, + SelectedComponent, Snippet, StopVersionControlRequest, StopVersionControlResponse, @@ -61,6 +64,7 @@ import { Action, Store } from '@ngrx/store'; import { selectAnySelectedComponentIds, selectChangeVersionRequest, + selectCopiedSnippet, selectCurrentParameterContext, selectCurrentProcessGroupId, selectMaxZIndex, @@ -119,6 +123,8 @@ import { LocalChangesDialog } from '../../ui/canvas/items/flow/local-changes-dia import { ClusterConnectionService } from '../../../../service/cluster-connection.service'; import { ExtensionTypesService } from '../../../../service/extension-types.service'; 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() export class FlowEffects { @@ -134,6 +140,7 @@ export class FlowEffects { private birdseyeView: BirdseyeView, private connectionManager: ConnectionManager, private clusterConnectionService: ClusterConnectionService, + private snippetService: SnippetService, private router: Router, private dialog: MatDialog, private propertyTableHelperService: PropertyTableHelperService, @@ -1816,53 +1823,11 @@ export class FlowEffects { map((action) => action.request), concatLatestFrom(() => this.store.select(selectCurrentProcessGroupId)), 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( - (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 - ); - - return from(this.flowService.createSnippet(snippet)).pipe( - switchMap((response) => this.flowService.moveSnippet(response.snippet.id, request.groupId)), + return from(this.snippetService.createSnippet(snippet)).pipe( + switchMap((response) => this.snippetService.moveSnippet(response.snippet.id, request.groupId)), map(() => { 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(() => this.actions$.pipe( ofType(FlowActions.deleteComponents), @@ -1962,8 +2078,8 @@ export class FlowEffects { } as Snippet ); - return from(this.flowService.createSnippet(snippet)).pipe( - switchMap((response) => this.flowService.deleteSnippet(response.snippet.id)), + return from(this.snippetService.createSnippet(snippet)).pipe( + switchMap((response) => this.snippetService.deleteSnippet(response.snippet.id)), map(() => { const deleteResponses: DeleteComponentResponse[] = []; diff --git a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-frontend/src/main/nifi/src/app/pages/flow-designer/state/flow/flow.reducer.ts b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-frontend/src/main/nifi/src/app/pages/flow-designer/state/flow/flow.reducer.ts index 67a8aa0bc8..7eb089b8ba 100644 --- a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-frontend/src/main/nifi/src/app/pages/flow-designer/state/flow/flow.reducer.ts +++ b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-frontend/src/main/nifi/src/app/pages/flow-designer/state/flow/flow.reducer.ts @@ -20,6 +20,7 @@ import { changeVersionComplete, changeVersionSuccess, clearFlowApiError, + copySuccess, createComponentComplete, createComponentSuccess, createConnection, @@ -41,6 +42,7 @@ import { loadProcessorSuccess, loadRemoteProcessGroupSuccess, navigateWithoutTransform, + pasteSuccess, pollChangeVersionSuccess, pollRevertChangesSuccess, requestRefreshRemoteProcessGroup, @@ -143,6 +145,7 @@ export const initialState: FlowState = { parameterProviderBulletins: [], reportingTaskBulletins: [] }, + copiedSnippet: null, dragging: false, saving: 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 }) => ({ ...state, dragging diff --git a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-frontend/src/main/nifi/src/app/pages/flow-designer/state/flow/flow.selectors.ts b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-frontend/src/main/nifi/src/app/pages/flow-designer/state/flow/flow.selectors.ts index 01a75fc101..536b5378f8 100644 --- a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-frontend/src/main/nifi/src/app/pages/flow-designer/state/flow/flow.selectors.ts +++ b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-frontend/src/main/nifi/src/app/pages/flow-designer/state/flow/flow.selectors.ts @@ -42,6 +42,8 @@ export const selectCurrentProcessGroupId = createSelector(selectFlowState, (stat export const selectRefreshRpgDetails = createSelector(selectFlowState, (state: FlowState) => state.refreshRpgDetails); +export const selectCopiedSnippet = createSelector(selectFlowState, (state: FlowState) => state.copiedSnippet); + export const selectCurrentParameterContext = createSelector( selectFlowState, (state: FlowState) => state.flow.processGroupFlow.parameterContext diff --git a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-frontend/src/main/nifi/src/app/pages/flow-designer/state/flow/index.ts b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-frontend/src/main/nifi/src/app/pages/flow-designer/state/flow/index.ts index 092cab1e54..392c267b55 100644 --- a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-frontend/src/main/nifi/src/app/pages/flow-designer/state/flow/index.ts +++ b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-frontend/src/main/nifi/src/app/pages/flow-designer/state/flow/index.ts @@ -423,18 +423,36 @@ export interface UpdatePositionsRequest { connectionUpdates: UpdateComponentRequest[]; } -export interface MoveComponentRequest { +export interface SnippetComponentRequest { id: string; uri: string; type: ComponentType; entity: any; } +export interface MoveComponentRequest extends SnippetComponentRequest {} + export interface MoveComponentsRequest { components: MoveComponentRequest[]; 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 { id: string; uri: string; @@ -490,6 +508,12 @@ export interface Snippet { }; } +export interface CopiedSnippet { + snippet: Snippet; + origin: Position; + dimensions: any; +} + /* Tooltips */ @@ -613,6 +637,7 @@ export interface FlowState { error: string | null; versionSaving: boolean; changeVersionRequest: FlowUpdateRequestEntity | null; + copiedSnippet: CopiedSnippet | null; status: 'pending' | 'loading' | 'error' | 'success'; } diff --git a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-frontend/src/main/nifi/src/app/pages/flow-designer/ui/canvas/graph-controls/operation-control/operation-control.component.html b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-frontend/src/main/nifi/src/app/pages/flow-designer/ui/canvas/graph-controls/operation-control/operation-control.component.html index 7dc0e816a1..03f21089f9 100644 --- a/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-frontend/src/main/nifi/src/app/pages/flow-designer/ui/canvas/graph-controls/operation-control/operation-control.component.html +++ b/nifi-nar-bundles/nifi-framework-bundle/nifi-framework/nifi-web/nifi-web-frontend/src/main/nifi/src/app/pages/flow-designer/ui/canvas/graph-controls/operation-control/operation-control.component.html @@ -116,8 +116,8 @@ color="primary" class="mr-2" type="button" - [disabled]="!canPaste(selection)" - (click)="paste(selection)"> + [disabled]="!canPaste()" + (click)="paste()">