From df793ce14e0ce50988c0f217b72cbe89a553bde7 Mon Sep 17 00:00:00 2001 From: Rob Fellows Date: Tue, 3 Dec 2024 16:57:04 -0500 Subject: [PATCH] [NIFI-13977] - Updated Copy/Paste in UI to align with new backend API (#9536) * [NIFI-13977] - Updated Copy/Paste in UI to align with new backend API * center pasted elements on screen. zoom out to fit them if needed * offset pasted components if the originally copied content is still in view * copy using ClipboardItem to support Safari. other review concerns addressed. added some minor positioning fixes as well. * copy using ClipboardItem to support Safari. other review concerns addressed. added some minor positioning fixes as well. * remove commented out code * Offset paste to center if it would overlap a prior paste of that content This closes #9536 --- .../frontend/apps/nifi/src/app/app.module.ts | 4 +- .../service/canvas-actions.service.ts | 130 +++++-- .../service/canvas-utils.service.ts | 10 +- .../service/canvas-view.service.ts | 35 +- .../service/copy-paste.service.ts | 354 ++++++++++++++++++ .../flow-designer/service/snippet.service.ts | 11 - .../flow-designer/state/flow/flow.actions.ts | 12 +- .../flow-designer/state/flow/flow.effects.ts | 151 +++++--- .../flow-designer/state/flow/flow.reducer.ts | 12 +- .../state/flow/flow.selectors.ts | 7 +- .../pages/flow-designer/state/flow/index.ts | 35 +- .../ui/canvas/canvas.component.ts | 63 +++- .../nifi/src/app/state/copy/copy.actions.ts | 25 ++ .../nifi/src/app/state/copy/copy.effects.ts | 23 ++ .../nifi/src/app/state/copy/copy.reducer.ts | 49 +++ .../nifi/src/app/state/copy/copy.selectors.ts | 25 ++ .../apps/nifi/src/app/state/copy/index.ts | 73 ++++ .../frontend/apps/nifi/src/app/state/index.ts | 6 +- .../apps/nifi/src/app/state/shared/index.ts | 5 + 19 files changed, 875 insertions(+), 155 deletions(-) create mode 100644 nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/service/copy-paste.service.ts create mode 100644 nifi-frontend/src/main/frontend/apps/nifi/src/app/state/copy/copy.actions.ts create mode 100644 nifi-frontend/src/main/frontend/apps/nifi/src/app/state/copy/copy.effects.ts create mode 100644 nifi-frontend/src/main/frontend/apps/nifi/src/app/state/copy/copy.reducer.ts create mode 100644 nifi-frontend/src/main/frontend/apps/nifi/src/app/state/copy/copy.selectors.ts create mode 100644 nifi-frontend/src/main/frontend/apps/nifi/src/app/state/copy/index.ts diff --git a/nifi-frontend/src/main/frontend/apps/nifi/src/app/app.module.ts b/nifi-frontend/src/main/frontend/apps/nifi/src/app/app.module.ts index 270f8c989f..c28d4970bf 100644 --- a/nifi-frontend/src/main/frontend/apps/nifi/src/app/app.module.ts +++ b/nifi-frontend/src/main/frontend/apps/nifi/src/app/app.module.ts @@ -56,6 +56,7 @@ import { LoginConfigurationEffects } from './state/login-configuration/login-con import { BannerTextEffects } from './state/banner-text/banner-text.effects'; import { MAT_TOOLTIP_DEFAULT_OPTIONS, MatTooltipDefaultOptions } from '@angular/material/tooltip'; import { CLIPBOARD_OPTIONS, provideMarkdown } from 'ngx-markdown'; +import { CopyEffects } from './state/copy/copy.effects'; const entry = localStorage.getItem('disable-animations'); let disableAnimations: string = entry !== null ? JSON.parse(entry).item : ''; @@ -97,7 +98,8 @@ export const customTooltipDefaults: MatTooltipDefaultOptions = { ComponentStateEffects, DocumentationEffects, ClusterSummaryEffects, - PropertyVerificationEffects + PropertyVerificationEffects, + CopyEffects ), StoreDevtoolsModule.instrument({ maxAge: 25, diff --git a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/service/canvas-actions.service.ts b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/service/canvas-actions.service.ts index 16e5d0ffff..fc13989716 100644 --- a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/service/canvas-actions.service.ts +++ b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/service/canvas-actions.service.ts @@ -18,7 +18,7 @@ import { Injectable } from '@angular/core'; import { CanvasUtils } from './canvas-utils.service'; import { - copy, + copySuccess, deleteComponents, disableComponents, disableCurrentProcessGroup, @@ -30,7 +30,6 @@ import { navigateToEditCurrentProcessGroup, navigateToManageComponentPolicies, openChangeColorDialog, - paste, reloadFlow, selectComponents, startComponents, @@ -40,12 +39,10 @@ import { } from '../state/flow/flow.actions'; import { ChangeColorRequest, - CopyComponentRequest, DeleteComponentRequest, DisableComponentRequest, EnableComponentRequest, MoveComponentRequest, - PasteRequest, SelectedComponent, StartComponentRequest, StopComponentRequest @@ -57,6 +54,11 @@ import { MatDialog } from '@angular/material/dialog'; import { CanvasView } from './canvas-view.service'; import { ComponentType } from 'libs/shared/src'; import { Client } from '../../../service/client.service'; +import { CopyRequestContext, CopyRequestEntity, CopyResponseEntity } from '../../../state/copy'; +import { CopyPasteService } from './copy-paste.service'; +import { firstValueFrom } from 'rxjs'; +import { selectCurrentProcessGroupId } from '../state/flow/flow.selectors'; +import { snackBarError } from '../../../state/error/error.actions'; export type CanvasConditionFunction = (selection: d3.Selection) => boolean; export type CanvasActionFunction = (selection: d3.Selection, extraArgs?: any) => void; @@ -139,45 +141,90 @@ export class CanvasActionsService { return this.canvasUtils.isCopyable(selection); }, action: (selection: d3.Selection) => { - const origin = this.canvasUtils.getOrigin(selection); - const dimensions = this.canvasView.getSelectionBoundingClientRect(selection); - - const components: CopyComponentRequest[] = []; + const copyRequestEntity: CopyRequestEntity = {}; selection.each((d) => { - components.push({ - id: d.id, - type: d.type, - uri: d.uri, - entity: d - }); + switch (d.type) { + case ComponentType.Processor: + if (!copyRequestEntity.processors) { + copyRequestEntity.processors = []; + } + copyRequestEntity.processors.push(d.id); + break; + case ComponentType.ProcessGroup: + if (!copyRequestEntity.processGroups) { + copyRequestEntity.processGroups = []; + } + copyRequestEntity.processGroups.push(d.id); + break; + case ComponentType.Connection: + if (!copyRequestEntity.connections) { + copyRequestEntity.connections = []; + } + copyRequestEntity.connections.push(d.id); + break; + case ComponentType.RemoteProcessGroup: + if (!copyRequestEntity.remoteProcessGroups) { + copyRequestEntity.remoteProcessGroups = []; + } + copyRequestEntity.remoteProcessGroups.push(d.id); + break; + case ComponentType.InputPort: + if (!copyRequestEntity.inputPorts) { + copyRequestEntity.inputPorts = []; + } + copyRequestEntity.inputPorts.push(d.id); + break; + case ComponentType.OutputPort: + if (!copyRequestEntity.outputPorts) { + copyRequestEntity.outputPorts = []; + } + copyRequestEntity.outputPorts.push(d.id); + break; + case ComponentType.Label: + if (!copyRequestEntity.labels) { + copyRequestEntity.labels = []; + } + copyRequestEntity.labels.push(d.id); + break; + case ComponentType.Funnel: + if (!copyRequestEntity.funnels) { + copyRequestEntity.funnels = []; + } + copyRequestEntity.funnels.push(d.id); + break; + } }); - this.store.dispatch( - copy({ - request: { - components, - origin, - dimensions - } + const copyRequestContext: CopyRequestContext = { + copyRequestEntity, + processGroupId: this.currentProcessGroupId() + }; + let copyResponse: CopyResponseEntity | null = null; + + // Safari in particular is strict in enforcing that any writing to the clipboard needs to be triggered directly by a user action. + // As such, firing a simple async rxjs action to initiate the copy sequence fails this check. + // However, below is the workaround to construct a ClipboardItem from an async call. + const clipboardItem = new ClipboardItem({ + 'text/plain': firstValueFrom(this.copyService.copy(copyRequestContext)).then((response) => { + copyResponse = response; + return new Blob([JSON.stringify(response, null, 2)], { type: 'text/plain' }); }) - ); - } - }, - 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 - }) - ); + }); + navigator.clipboard.write([clipboardItem]).then(() => { + if (copyResponse) { + this.store.dispatch( + copySuccess({ + response: { + copyResponse, + processGroupId: copyRequestContext.processGroupId, + pasteCount: 0 + } + }) + ); + } else { + this.store.dispatch(snackBarError({ error: 'Copy failed' })); + } + }); } }, selectAll: { @@ -479,12 +526,15 @@ export class CanvasActionsService { } }; + currentProcessGroupId = this.store.selectSignal(selectCurrentProcessGroupId); + constructor( private store: Store, private canvasUtils: CanvasUtils, private canvasView: CanvasView, private dialog: MatDialog, - private client: Client + private client: Client, + private copyService: CopyPasteService ) {} private select(selection: d3.Selection) { diff --git a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/service/canvas-utils.service.ts b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/service/canvas-utils.service.ts index 949fe47c5f..8e574fa3cd 100644 --- a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/service/canvas-utils.service.ts +++ b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/service/canvas-utils.service.ts @@ -24,7 +24,6 @@ import { selectBreadcrumbs, selectCanvasPermissions, selectConnections, - selectCopiedSnippet, selectCurrentParameterContext, selectCurrentProcessGroupId, selectParentProcessGroupId @@ -135,13 +134,6 @@ export class CanvasUtils { this.breadcrumbs = breadcrumbs; }); - this.store - .select(selectCopiedSnippet) - .pipe(takeUntilDestroyed(this.destroyRef)) - .subscribe((copiedSnippet) => { - this.copiedSnippet = copiedSnippet; - }); - this.store .select(selectScale) .pipe(takeUntilDestroyed(this.destroyRef)) @@ -1011,7 +1003,7 @@ export class CanvasUtils { } public isPastable(): boolean { - return this.canvasPermissions.canWrite && this.copiedSnippet != null; + return this.canvasPermissions.canWrite; } /** diff --git a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/service/canvas-view.service.ts b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/service/canvas-view.service.ts index ada2f5efc1..345e5210ee 100644 --- a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/service/canvas-view.service.ts +++ b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/service/canvas-view.service.ts @@ -177,6 +177,15 @@ export class CanvasView { this.canvasInitialized = true; } + public getCanvasBoundingClientRect(): DOMRect | null { + const canvasContainer: any = document.getElementById('canvas-container'); + if (canvasContainer == null) { + return null; + } + + return canvasContainer.getBoundingClientRect() as DOMRect; + } + // filters zoom events as programmatically modifying the translate or scale now triggers the handlers private isBirdseyeEvent(): boolean { return this.birdseyeTranslateInProgress; @@ -252,7 +261,7 @@ export class CanvasView { } /** - * Determines if a bounding box is fully in the current viewable canvas area. + * Determines if a bounding box is 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. @@ -260,18 +269,11 @@ export class CanvasView { * @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]; @@ -287,8 +289,8 @@ export class CanvasView { 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); + const top = Math.ceil(boundingBox.y); + const bottom = Math.floor(boundingBox.y + boundingBox.height); if (strict) { return !(left < screenLeft || right > screenRight || top < screenTop || bottom > screenBottom); @@ -497,6 +499,13 @@ export class CanvasView { return bbox; } + /** + * Translates a position to the space visible on the canvas + * + * @param position + * + * @returns {Position | null} + */ public getCanvasPosition(position: Position): Position | null { const canvasContainer: any = document.getElementById('canvas-container'); if (!canvasContainer) { @@ -528,7 +537,11 @@ export class CanvasView { return null; } - private centerBoundingBox(boundingBox: any): void { + /** + * Centers the canvas to a bounding box. If a scale is provided, it will zoom to that scale. + * @param {type} boundingBox + */ + public centerBoundingBox(boundingBox: any): void { let scale: number = this.k; if (boundingBox.scale != null) { scale = boundingBox.scale; diff --git a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/service/copy-paste.service.ts b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/service/copy-paste.service.ts new file mode 100644 index 0000000000..ae28d6c03d --- /dev/null +++ b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/service/copy-paste.service.ts @@ -0,0 +1,354 @@ +/* + * 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 { HttpClient } from '@angular/common/http'; +import { Dimensions, PasteRequest, PasteRequestContext, PasteRequestEntity } from '../state/flow'; +import { Observable } from 'rxjs'; +import { ClusterConnectionService } from '../../../service/cluster-connection.service'; +import { Position } from '../state/shared'; +import { CanvasView } from './canvas-view.service'; +import { CopyRequestContext, CopyResponseEntity, PasteRequestStrategy } from '../../../state/copy'; +import { Store } from '@ngrx/store'; +import { NiFiState } from '../../../state'; +import { selectCurrentProcessGroupId } from '../state/flow/flow.selectors'; +import * as d3 from 'd3'; + +@Injectable({ + providedIn: 'root' +}) +export class CopyPasteService { + private static readonly API: string = '../nifi-api'; + currentProcessGroupId = this.store.selectSignal(selectCurrentProcessGroupId); + + constructor( + private httpClient: HttpClient, + private clusterConnectionService: ClusterConnectionService, + private canvasView: CanvasView, + private store: Store + ) {} + + copy(copyRequest: CopyRequestContext): Observable { + return this.httpClient.post( + `${CopyPasteService.API}/process-groups/${copyRequest.processGroupId}/copy`, + copyRequest.copyRequestEntity + ) as Observable; + } + + paste(pasteRequest: PasteRequestContext): Observable { + const payload: PasteRequestEntity = { + ...pasteRequest.pasteRequest, + disconnectedNodeAcknowledged: this.clusterConnectionService.isDisconnectionAcknowledged() + }; + return this.httpClient.put( + `${CopyPasteService.API}/process-groups/${pasteRequest.processGroupId}/paste`, + payload + ); + } + + public isCopiedContentInView(copyResponse: CopyResponseEntity): boolean { + const bbox = this.calculateBoundingBoxForCopiedContent(copyResponse); + return this.canvasView.isBoundingBoxInViewport(bbox, false); + } + + /** + * Use when pasting components to the same process group they were copied from and some + * part of those components are still visible on canvas + * @param copyResponse + * @param pasteIncrement how many times the content has been pasted already. used to determine the overall offset. + * @private + */ + public toOffsetPasteRequest(copyResponse: CopyResponseEntity, pasteIncrement: number = 0): PasteRequest { + const offset = 25; + const paste: PasteRequest = { + copyResponse: this.cloneCopyResponseEntity(copyResponse), + strategy: PasteRequestStrategy.OFFSET_FROM_ORIGINAL + }; + + Object.values(paste.copyResponse) + .filter((values) => !!values && Array.isArray(values)) + .forEach((values: any[]) => { + values.forEach((value) => { + if (value.position) { + value.position.x += offset * (pasteIncrement + 1); + value.position.y += offset * (pasteIncrement + 1); + } else if (value.bends) { + value.bends.forEach((bend: Position) => { + bend.x += offset * (pasteIncrement + 1); + bend.y += offset * (pasteIncrement + 1); + }); + } + }); + }); + return paste; + } + + /** + * Use when it isn't known if the copied content is still visible on the screen (possibly a different pg or browser tab), + * or it is known to be off-screen. + * @param copyResponse + * @private + */ + public toCenteredPasteRequest(copyResponse: CopyResponseEntity): PasteRequest { + const paste: PasteRequest = { + copyResponse: this.cloneCopyResponseEntity(copyResponse), + strategy: PasteRequestStrategy.CENTER_ON_CANVAS + }; + + // get center of canvas + const canvasBBox = this.canvasView.getCanvasBoundingClientRect(); + if (canvasBBox) { + // Get the normalized center of the canvas to later compare with the center of the items being pasted + const canvasCenterNormalized = this.canvasView.getCanvasPosition({ + x: canvasBBox.width / 2 + canvasBBox.left, + y: canvasBBox.height / 2 + canvasBBox.top + }); + if (canvasCenterNormalized) { + // get the bounding box of the items being pasted (including the bends of connections) + const copiedBBox = this.calculateBoundingBoxForCopiedContent(paste.copyResponse); + + // get it's center + const centerOfCopiedContent: Position = { + x: copiedBBox.width / 2 + copiedBBox.x, + y: copiedBBox.height / 2 + copiedBBox.y + }; + + // find the difference between the centers + const centerOffset: Position = { + x: canvasCenterNormalized.x - centerOfCopiedContent.x, + y: canvasCenterNormalized.y - centerOfCopiedContent.y + }; + + // try to detect if the proposed paste content has already been pasted and might overlap + const offset = this.calculateOffsetForCenterPaste(paste.copyResponse, centerOffset); + + // offset all items (and bends) by the diff of the centers + Object.values(paste.copyResponse) + .filter((values) => !!values && Array.isArray(values)) + .forEach((componentArray: any[]) => { + componentArray.forEach((component) => { + if (component.position) { + component.position.x += centerOffset.x + offset; + component.position.y += centerOffset.y + offset; + } else if (component.bends) { + component.bends.forEach((bend: Position) => { + bend.x += centerOffset.x + offset; + bend.y += centerOffset.y + offset; + }); + } + }); + }); + + // set the new bounding box on the request with a scale that would fit the contents + paste.bbox = { + height: copiedBBox.height, + width: copiedBBox.width, + x: copiedBBox.x + centerOffset.x, + y: copiedBBox.y + centerOffset.y + }; + + const willItFit = this.canvasView.isBoundingBoxInViewport(paste.bbox, true); + if (!willItFit) { + paste.fitToScreen = true; + const scale = Math.min(canvasBBox.width / copiedBBox.width, canvasBBox.height / copiedBBox.height); + paste.bbox.scale = scale * 0.95; // leave a bit of padding around the newly centered selection + } + } + } + return paste; + } + + private calculateOffsetForCenterPaste(proposedPaste: CopyResponseEntity, centerOffset: Position): number { + // get the positions of things already on the screen + const existingPositions = this.getAllComponentPositions(); + const offsetIncrement = 25; + const buffer = 4; + let offset = 0; + + // get a sample component to probe the canvas with to detect a duplicate paste + const positioned = Object.values(proposedPaste) + .filter((values) => !!values && Array.isArray(values)) + .flat() + .filter((component) => { + return !!component.position; + }) + .map((component) => component.position); + + if (positioned.length > 0) { + const sample: Position = { + x: positioned[0].x + centerOffset.x, + y: positioned[0].y + centerOffset.y + }; + + let foundCollision = existingPositions.some( + (position) => + position.x >= sample.x - buffer && + position.x <= sample.x + buffer && + position.y >= sample.y - buffer && + position.y <= sample.y + buffer + ); + + while (foundCollision) { + offset += offsetIncrement; + foundCollision = existingPositions.some( + (position) => + position.x >= sample.x + offset - buffer && + position.x <= sample.x + offset + buffer && + position.y >= sample.y + offset - buffer && + position.y <= sample.y + offset + buffer + ); + } + } + return offset; + } + + private getAllComponentPositions() { + const positions: Position[] = []; + const selectionBoundingBox = this.canvasView.getCanvasBoundingClientRect(); + if (selectionBoundingBox) { + d3.selectAll('g.component').each((d: any) => { + positions.push(d.position); + }); + } + return positions; + } + + private cloneCopyResponseEntity(copyResponse: CopyResponseEntity): CopyResponseEntity { + const arrayOrUndefined = (arr: any[] | undefined) => { + if (arr && Array.isArray(arr) && arr.length > 0) { + if (arr[0].position) { + return arr.map((component: any) => { + if (component.position) { + return { + ...component, + position: { + ...component.position + } + }; + } + }); + } else { + // this is an array of connections, handle them differently to account for bends + return arr.map((connection: any) => { + if (connection.bends && connection.bends.length > 0) { + const clonedBends = connection.bends.map((bend: Position) => { + return { + ...bend + }; + }); + return { + ...connection, + bends: clonedBends + }; + } + return { + ...connection + }; + }); + } + } + return undefined; + }; + return { + id: copyResponse.id, + connections: arrayOrUndefined(copyResponse.connections), + funnels: arrayOrUndefined(copyResponse.funnels), + inputPorts: arrayOrUndefined(copyResponse.inputPorts), + labels: arrayOrUndefined(copyResponse.labels), + outputPorts: arrayOrUndefined(copyResponse.outputPorts), + processGroups: arrayOrUndefined(copyResponse.processGroups), + processors: arrayOrUndefined(copyResponse.processors), + remoteProcessGroups: arrayOrUndefined(copyResponse.remoteProcessGroups), + externalControllerServiceReferences: copyResponse.externalControllerServiceReferences, + parameterContexts: copyResponse.parameterContexts, + parameterProviders: copyResponse.parameterProviders + } as CopyResponseEntity; + } + + private calculateBoundingBoxForCopiedContent(copyResponse: CopyResponseEntity): any { + const bbox = { + left: Number.MAX_SAFE_INTEGER, + top: Number.MAX_SAFE_INTEGER, + right: Number.MIN_SAFE_INTEGER, + bottom: Number.MIN_SAFE_INTEGER + }; + Object.values(copyResponse) + .flat() + .filter((value: any[]) => !!value) + .reduce((acc, current) => { + if (current.componentType) { + const dimensions: Dimensions = this.getComponentWidth(current); + if (current.componentType === 'CONNECTION') { + current.bends.forEach((bend: Position) => { + acc.left = Math.min(acc.left, bend.x); + acc.top = Math.min(acc.top, bend.y); + acc.right = Math.max(acc.right, bend.x); + acc.bottom = Math.max(acc.bottom, bend.y); + }); + } else { + acc.left = Math.min(acc.left, current.position.x); + acc.top = Math.min(acc.top, current.position.y); + acc.right = Math.max(acc.right, current.position.x + dimensions.width); + acc.bottom = Math.max(acc.bottom, current.position.y + dimensions.height); + } + } + return acc; + }, bbox); + + return { + x: bbox.left, + y: bbox.top, + width: bbox.right - bbox.left, + height: bbox.bottom - bbox.top + }; + } + + private getComponentWidth(component: any): Dimensions { + if (!component) { + return { height: 0, width: 0 }; + } + switch (component.componentType) { + case 'PROCESSOR': + return { + width: 352, + height: 128 + }; + case 'PROCESS_GROUP': + case 'REMOTE_PROCESS_GROUP': + return { + width: 384, + height: 176 + }; + case 'INPUT_PORT': + case 'OUTPUT_PORT': + case 'REMOTE_INPUT_PORT': + case 'REMOTE_OUTPUT_PORT': + return { + width: 240, + height: 48 + }; + case 'FUNNEL': + return { height: 48, width: 48 }; + case 'LABEL': + return { height: component.height, width: component.width }; + default: + return { height: 0, width: 0 }; + } + } +} diff --git a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/service/snippet.service.ts b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/service/snippet.service.ts index 84c221edbe..f97db726f2 100644 --- a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/service/snippet.service.ts +++ b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/service/snippet.service.ts @@ -22,7 +22,6 @@ import { Snippet, SnippetComponentRequest } from '../state/flow'; import { ClusterConnectionService } from '../../../service/cluster-connection.service'; import { ComponentType } from 'libs/shared/src'; import { Client } from '../../../service/client.service'; -import { Position } from '../state/shared'; @Injectable({ providedIn: 'root' }) export class SnippetService { @@ -97,16 +96,6 @@ export class SnippetService { 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: { diff --git a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/state/flow/flow.actions.ts b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/state/flow/flow.actions.ts index dec106d2f8..a5882f5396 100644 --- a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/state/flow/flow.actions.ts +++ b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/state/flow/flow.actions.ts @@ -22,8 +22,6 @@ import { ChangeVersionDialogRequest, ComponentEntity, ConfirmStopVersionControlRequest, - CopiedSnippet, - CopyRequest, CreateComponentRequest, CreateComponentResponse, CreateConnection, @@ -77,8 +75,7 @@ import { OpenGroupComponentsDialogRequest, OpenLocalChangesDialogRequest, OpenSaveVersionDialogRequest, - PasteRequest, - PasteResponse, + PasteResponseContext, RefreshRemoteProcessGroupRequest, ReplayLastProvenanceEventRequest, RpgManageRemotePortsRequest, @@ -114,6 +111,7 @@ import { import { StatusHistoryRequest } from '../../../../state/status-history'; import { FetchComponentVersionsRequest } from '../../../../state/shared'; import { ErrorContext } from '../../../../state/error'; +import { CopyRequest, CopyResponseContext, CopyResponseEntity } from '../../../../state/copy'; const CANVAS_PREFIX = '[Canvas]'; @@ -502,11 +500,11 @@ export const moveComponents = createAction( export const copy = createAction(`${CANVAS_PREFIX} Copy`, props<{ request: CopyRequest }>()); -export const copySuccess = createAction(`${CANVAS_PREFIX} Copy Success`, props<{ copiedSnippet: CopiedSnippet }>()); +export const copySuccess = createAction(`${CANVAS_PREFIX} Copy Success`, props<{ response: CopyResponseContext }>()); -export const paste = createAction(`${CANVAS_PREFIX} Paste`, props<{ request: PasteRequest }>()); +export const paste = createAction(`${CANVAS_PREFIX} Paste`, props<{ request: CopyResponseEntity }>()); -export const pasteSuccess = createAction(`${CANVAS_PREFIX} Paste Success`, props<{ response: PasteResponse }>()); +export const pasteSuccess = createAction(`${CANVAS_PREFIX} Paste Success`, props<{ response: PasteResponseContext }>()); /* Delete Component Actions diff --git a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/state/flow/flow.effects.ts b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/state/flow/flow.effects.ts index ffc14fa944..6c540083e7 100644 --- a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/state/flow/flow.effects.ts +++ b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/state/flow/flow.effects.ts @@ -22,6 +22,7 @@ import { concatLatestFrom } from '@ngrx/operators'; import * as FlowActions from './flow.actions'; import * as StatusHistoryActions from '../../../../state/status-history/status-history.actions'; import * as ErrorActions from '../../../../state/error/error.actions'; +import * as CopyActions from '../../../../state/copy/copy.actions'; import { asyncScheduler, catchError, @@ -41,7 +42,6 @@ import { throttleTime } from 'rxjs'; import { - CopyComponentRequest, CreateConnectionDialogRequest, CreateProcessGroupDialogRequest, DeleteComponentResponse, @@ -49,6 +49,9 @@ import { ImportFromRegistryDialogRequest, LoadProcessGroupResponse, MoveComponentRequest, + PasteRequest, + PasteRequestContext, + PasteRequestEntity, SaveVersionDialogRequest, SaveVersionRequest, SelectedComponent, @@ -67,9 +70,9 @@ import { Action, Store } from '@ngrx/store'; import { selectAnySelectedComponentIds, selectChangeVersionRequest, - selectCopiedSnippet, selectCurrentParameterContext, selectCurrentProcessGroupId, + selectCurrentProcessGroupRevision, selectFlowLoadingStatus, selectInputPort, selectMaxZIndex, @@ -136,7 +139,6 @@ import { ClusterConnectionService } from '../../../../service/cluster-connection 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'; import { EditLabel } from '../../ui/canvas/items/label/edit-label/edit-label.component'; import { ErrorHelper } from '../../../../service/error-helper.service'; import { selectConnectedStateChanged } from '../../../../state/cluster-summary/cluster-summary.selectors'; @@ -158,6 +160,9 @@ import { selectDocumentVisibilityState } from '../../../../state/document-visibi import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { DocumentVisibility } from '../../../../state/document-visibility'; import { ErrorContextKey } from '../../../../state/error'; +import { CopyPasteService } from '../../service/copy-paste.service'; +import { selectCopiedContent } from '../../../../state/copy/copy.selectors'; +import { CopyRequestContext, CopyResponseContext } from '../../../../state/copy'; @Injectable() export class FlowEffects { @@ -183,7 +188,8 @@ export class FlowEffects { private propertyTableHelperService: PropertyTableHelperService, private parameterHelperService: ParameterHelperService, private extensionTypesService: ExtensionTypesService, - private errorHelper: ErrorHelper + private errorHelper: ErrorHelper, + private copyPasteService: CopyPasteService ) { this.store .select(selectDocumentVisibilityState) @@ -2225,15 +2231,48 @@ export class FlowEffects { map((action) => action.request), concatLatestFrom(() => this.store.select(selectCurrentProcessGroupId)), switchMap(([request, processGroupId]) => { - const components: CopyComponentRequest[] = request.components; - const snippet = this.snippetService.marshalSnippet(components, processGroupId); + const copyRequest: CopyRequestContext = { + ...request, + processGroupId + }; + return from(this.copyPasteService.copy(copyRequest)).pipe( + switchMap((response) => { + const copyBlob = new Blob([JSON.stringify(response, null, 2)], { type: 'text/plain' }); + const clipboardItem: ClipboardItem = new ClipboardItem({ + 'text/plain': copyBlob + }); + return from(navigator.clipboard.write([clipboardItem])).pipe( + switchMap(() => { + return of( + FlowActions.copySuccess({ + response: { + copyResponse: response, + processGroupId, + pasteCount: 0 + } as CopyResponseContext + }) + ); + }), + catchError((e) => { + console.log(e); + return of(FlowActions.flowSnackbarError({ error: 'Copy failed' })); + }) + ); + }), + catchError((errorResponse: HttpErrorResponse) => of(this.snackBarOrFullScreenError(errorResponse))) + ); + }) + ) + ); + + copySuccess$ = createEffect(() => + this.actions$.pipe( + ofType(FlowActions.copySuccess), + map((action) => action.response), + switchMap((response) => { return of( - FlowActions.copySuccess({ - copiedSnippet: { - snippet, - dimensions: request.dimensions, - origin: request.origin - } + CopyActions.setCopiedContent({ + content: response }) ); }) @@ -2245,43 +2284,55 @@ export class FlowEffects { ofType(FlowActions.paste), map((action) => action.request), concatLatestFrom(() => [ - this.store.select(selectCopiedSnippet).pipe(isDefinedAndNotNull()), this.store.select(selectCurrentProcessGroupId), - this.store.select(selectTransform) + this.store.select(selectCurrentProcessGroupRevision), + this.store.select(selectCopiedContent) ]), - 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; + switchMap(([request, processGroupId, revision, copiedContent]) => { + let pasteRequest: PasteRequest | null = null; - 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 - }; - } + // Determine if the paste should be positioned based off of previously copied items or centered. + // * The current process group is the same as the content that was last copied + // * And, the last copied content is the same as the content being pasted + // * And, the original content is still in the canvas view + if (copiedContent && processGroupId === copiedContent.processGroupId) { + if (copiedContent.copyResponse.id === request.id) { + const isInView = this.copyPasteService.isCopiedContentInView(copiedContent.copyResponse); + if (isInView) { + pasteRequest = this.copyPasteService.toOffsetPasteRequest( + request, + copiedContent.pasteCount + ); } + } + } - return from( - this.snippetService.copySnippet(response.snippet.id, pasteLocation, processGroupId) - ).pipe(map((response) => FlowActions.pasteSuccess({ response }))); + // If no paste request was created before, create one that is centered in the current canvas view + if (!pasteRequest) { + pasteRequest = this.copyPasteService.toCenteredPasteRequest(request); + } + + const payload: PasteRequestEntity = { + copyResponse: pasteRequest.copyResponse, + revision + }; + const pasteRequestContext: PasteRequestContext = { + pasteRequest: payload, + processGroupId, + pasteStrategy: pasteRequest.strategy + }; + return from(this.copyPasteService.paste(pasteRequestContext)).pipe( + map((response) => { + return FlowActions.pasteSuccess({ + response: { + ...response, + pasteRequest + } + }); }), catchError((errorResponse: HttpErrorResponse) => of(this.snackBarOrFullScreenError(errorResponse))) - ) - ) + ); + }) ) ); @@ -2289,7 +2340,8 @@ export class FlowEffects { this.actions$.pipe( ofType(FlowActions.pasteSuccess), map((action) => action.response), - switchMap((response) => { + concatLatestFrom(() => this.store.select(selectCurrentProcessGroupId)), + switchMap(([response, currentProcessGroupId]) => { this.canvasView.updateCanvasVisibility(); this.birdseyeView.refresh(); @@ -2359,6 +2411,19 @@ export class FlowEffects { }) ); + if (response.pasteRequest.fitToScreen && response.pasteRequest.bbox) { + this.canvasView.centerBoundingBox(response.pasteRequest.bbox); + } + this.store.dispatch( + CopyActions.contentPasted({ + pasted: { + copyId: response.pasteRequest.copyResponse.id, + processGroupId: currentProcessGroupId, + strategy: response.pasteRequest.strategy + } + }) + ); + return of( FlowActions.selectComponents({ request: { diff --git a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/state/flow/flow.reducer.ts b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/state/flow/flow.reducer.ts index d284d643df..cd3ec9f31b 100644 --- a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/state/flow/flow.reducer.ts +++ b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/state/flow/flow.reducer.ts @@ -19,7 +19,6 @@ import { createReducer, on } from '@ngrx/store'; import { changeVersionComplete, changeVersionSuccess, - copySuccess, createComponentComplete, createComponentSuccess, createConnection, @@ -94,6 +93,9 @@ export const initialState: FlowState = { id: 'root', changeVersionRequest: null, flow: { + revision: { + version: 0 + }, permissions: { canRead: false, canWrite: false @@ -158,7 +160,6 @@ export const initialState: FlowState = { parameterProviderBulletins: [], reportingTaskBulletins: [] }, - copiedSnippet: null, dragging: false, saving: false, versionSaving: false, @@ -477,10 +478,6 @@ 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); @@ -518,8 +515,7 @@ export const flowReducer = createReducer( if (connections) { connections.push(...response.flow.connections); } - - draftState.copiedSnippet = null; + draftState.flow.revision = response.revision; }); }), on(setDragging, (state, { dragging }) => ({ diff --git a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/state/flow/flow.selectors.ts b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/state/flow/flow.selectors.ts index fb4620432e..101dcb8c77 100644 --- a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/state/flow/flow.selectors.ts +++ b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/state/flow/flow.selectors.ts @@ -36,9 +36,12 @@ export const selectVersionSaving = createSelector(selectFlowState, (state: FlowS export const selectCurrentProcessGroupId = createSelector(selectFlowState, (state: FlowState) => state.id); -export const selectRefreshRpgDetails = createSelector(selectFlowState, (state: FlowState) => state.refreshRpgDetails); +export const selectCurrentProcessGroupRevision = createSelector( + selectFlowState, + (state: FlowState) => state.flow.revision +); -export const selectCopiedSnippet = createSelector(selectFlowState, (state: FlowState) => state.copiedSnippet); +export const selectRefreshRpgDetails = createSelector(selectFlowState, (state: FlowState) => state.refreshRpgDetails); export const selectCurrentParameterContext = createSelector( selectFlowState, diff --git a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/state/flow/index.ts b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/state/flow/index.ts index d1d9041e6d..b224ce1c79 100644 --- a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/state/flow/index.ts +++ b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/state/flow/index.ts @@ -32,6 +32,7 @@ import { import { HttpErrorResponse } from '@angular/common/http'; import { BackNavigation } from '../../../../state/navigation'; import { ComponentType, SelectOption } from 'libs/shared/src'; +import { CopyResponseEntity, PasteRequestStrategy } from '../../../../state/copy'; export const flowFeatureKey = 'flowState'; @@ -453,21 +454,31 @@ export interface MoveComponentsRequest { groupId: string; } -export interface CopyComponentRequest extends SnippetComponentRequest {} - -export interface CopyRequest { - components: CopyComponentRequest[]; - origin: Position; - dimensions: any; -} - +/////////////////////////////////////////////////////////// export interface PasteRequest { - pasteLocation?: Position; + copyResponse: CopyResponseEntity; + strategy: PasteRequestStrategy; + fitToScreen?: boolean; + bbox?: any; } - -export interface PasteResponse { +export interface PasteRequestEntity { + copyResponse: CopyResponseEntity; + revision: Revision; + disconnectedNodeAcknowledged?: boolean; +} +export interface PasteRequestContext { + processGroupId: string; + pasteRequest: PasteRequestEntity; + pasteStrategy: PasteRequestStrategy; +} +export interface PasteResponseEntity { flow: Flow; + revision: Revision; } +export interface PasteResponseContext extends PasteResponseEntity { + pasteRequest: PasteRequest; +} +/////////////////////////////////////////////////////////// export interface DeleteComponentRequest { id: string; @@ -589,6 +600,7 @@ export interface ProcessGroupFlow { export interface ProcessGroupFlowEntity { permissions: Permissions; + revision: Revision; processGroupFlow: ProcessGroupFlow; } @@ -641,7 +653,6 @@ export interface FlowState { flowAnalysisOpen: boolean; versionSaving: boolean; changeVersionRequest: FlowUpdateRequestEntity | null; - copiedSnippet: CopiedSnippet | null; status: 'pending' | 'loading' | 'success' | 'complete'; } diff --git a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/canvas/canvas.component.ts b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/canvas/canvas.component.ts index 1b6fe87b67..6478a32475 100644 --- a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/canvas/canvas.component.ts +++ b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/canvas/canvas.component.ts @@ -25,6 +25,7 @@ import { editComponent, editCurrentProcessGroup, loadProcessGroup, + paste, resetFlowState, selectComponents, setSkipTransform, @@ -70,6 +71,8 @@ import { ComponentType, isDefinedAndNotNull, selectUrl, Storage } from '@nifi/sh import { CanvasUtils } from '../../service/canvas-utils.service'; import { CanvasActionsService } from '../../service/canvas-actions.service'; import { MatDialog } from '@angular/material/dialog'; +import { CopyResponseEntity } from '../../../../state/copy'; +import { snackBarError } from '../../../../state/error/error.actions'; @Component({ selector: 'fd-canvas', @@ -629,7 +632,7 @@ export class Canvas implements OnInit, OnDestroy { this.canvasView.destroy(); } - private processKeyboardEvents(event: KeyboardEvent): boolean { + private processKeyboardEvents(event: KeyboardEvent | ClipboardEvent): boolean { const source = event.target as any; let searchFieldIsEventSource = false; if (source) { @@ -696,17 +699,26 @@ export class Canvas implements OnInit, OnDestroy { } } - @HostListener('window:keydown.control.v', ['$event']) - handleKeyDownCtrlV(event: KeyboardEvent) { - if (this.executeAction('paste', event)) { - event.preventDefault(); + @HostListener('window:paste', ['$event']) + handlePasteEvent(event: ClipboardEvent) { + if (!this.processKeyboardEvents(event) || !this.canvasUtils.isPastable()) { + // don't attempt to paste flow content + return; } - } - @HostListener('window:keydown.meta.v', ['$event']) - handleKeyDownMetaV(event: KeyboardEvent) { - if (this.executeAction('paste', event)) { - event.preventDefault(); + const textToPaste = event.clipboardData?.getData('text/plain'); + if (textToPaste) { + const copyResponse: CopyResponseEntity | null = this.toCopyResponseEntity(textToPaste); + if (copyResponse) { + this.store.dispatch( + paste({ + request: copyResponse + }) + ); + event.preventDefault(); + } else { + this.store.dispatch(snackBarError({ error: 'Cannot paste: incompatible format' })); + } } } @@ -723,4 +735,35 @@ export class Canvas implements OnInit, OnDestroy { event.preventDefault(); } } + + private toCopyResponseEntity(json: string): CopyResponseEntity | null { + try { + const copyResponse: CopyResponseEntity = JSON.parse(json); + const supportedKeys: string[] = [ + 'processGroups', + 'remoteProcessGroups', + 'processors', + 'inputPorts', + 'outputPorts', + 'connections', + 'labels', + 'funnels' + ]; + + // ensure at least one of the copyable component types has something to paste + const hasCopiedContent = Object.entries(copyResponse).some((entry) => { + return supportedKeys.includes(entry[0]) && Array.isArray(entry[1]) && entry[1].length > 0; + }); + + if (hasCopiedContent) { + return copyResponse; + } + + // attempting to paste something other than CopyResponseEntity + return null; + } catch (e) { + // attempting to paste something other than CopyResponseEntity + return null; + } + } } diff --git a/nifi-frontend/src/main/frontend/apps/nifi/src/app/state/copy/copy.actions.ts b/nifi-frontend/src/main/frontend/apps/nifi/src/app/state/copy/copy.actions.ts new file mode 100644 index 0000000000..12f78be1f0 --- /dev/null +++ b/nifi-frontend/src/main/frontend/apps/nifi/src/app/state/copy/copy.actions.ts @@ -0,0 +1,25 @@ +/* + * 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 { createAction, props } from '@ngrx/store'; +import { CopiedContentInfo, CopyResponseContext } from './index'; + +export const setCopiedContent = createAction('[Copy] Copied Content', props<{ content: CopyResponseContext }>()); +export const contentPasted = createAction('[Copy] Copied Content Pasted', props<{ pasted: CopiedContentInfo }>()); +export const resetCopiedContent = createAction('[Copy] Reset Copied Content'); diff --git a/nifi-frontend/src/main/frontend/apps/nifi/src/app/state/copy/copy.effects.ts b/nifi-frontend/src/main/frontend/apps/nifi/src/app/state/copy/copy.effects.ts new file mode 100644 index 0000000000..eb374ea729 --- /dev/null +++ b/nifi-frontend/src/main/frontend/apps/nifi/src/app/state/copy/copy.effects.ts @@ -0,0 +1,23 @@ +/* + * 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'; + +@Injectable() +export class CopyEffects {} diff --git a/nifi-frontend/src/main/frontend/apps/nifi/src/app/state/copy/copy.reducer.ts b/nifi-frontend/src/main/frontend/apps/nifi/src/app/state/copy/copy.reducer.ts new file mode 100644 index 0000000000..65fb0d829e --- /dev/null +++ b/nifi-frontend/src/main/frontend/apps/nifi/src/app/state/copy/copy.reducer.ts @@ -0,0 +1,49 @@ +/* + * 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 { CopyState, PasteRequestStrategy } from './index'; +import { createReducer, on } from '@ngrx/store'; +import { contentPasted, resetCopiedContent, setCopiedContent } from './copy.actions'; +import { produce } from 'immer'; + +export const initialCopyState: CopyState = { + copiedContent: null +}; + +export const copyReducer = createReducer( + initialCopyState, + on(setCopiedContent, (state, { content }) => ({ + ...state, + copiedContent: content + })), + on(contentPasted, (state, { pasted }) => { + // update the paste count if it was pasted with an OFFSET strategy to influence positioning of future pastes + return produce(state, (draftState) => { + if ( + pasted.strategy === PasteRequestStrategy.OFFSET_FROM_ORIGINAL && + draftState.copiedContent && + draftState.copiedContent.copyResponse.id === pasted.copyId && + draftState.copiedContent.processGroupId === pasted.processGroupId + ) { + draftState.copiedContent.pasteCount++; + } + }); + }), + on(resetCopiedContent, () => ({ ...initialCopyState })) +); diff --git a/nifi-frontend/src/main/frontend/apps/nifi/src/app/state/copy/copy.selectors.ts b/nifi-frontend/src/main/frontend/apps/nifi/src/app/state/copy/copy.selectors.ts new file mode 100644 index 0000000000..615e913c6c --- /dev/null +++ b/nifi-frontend/src/main/frontend/apps/nifi/src/app/state/copy/copy.selectors.ts @@ -0,0 +1,25 @@ +/* + * 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 { createFeatureSelector, createSelector } from '@ngrx/store'; +import { copyFeatureKey, CopyState } from './index'; + +export const selectCopyState = createFeatureSelector(copyFeatureKey); + +export const selectCopiedContent = createSelector(selectCopyState, (state: CopyState) => state.copiedContent); diff --git a/nifi-frontend/src/main/frontend/apps/nifi/src/app/state/copy/index.ts b/nifi-frontend/src/main/frontend/apps/nifi/src/app/state/copy/index.ts new file mode 100644 index 0000000000..00b564e8b3 --- /dev/null +++ b/nifi-frontend/src/main/frontend/apps/nifi/src/app/state/copy/index.ts @@ -0,0 +1,73 @@ +/* + * 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 { ExternalControllerServiceReference } from '../shared'; + +export const copyFeatureKey = 'copy'; + +export interface CopyRequest { + copyRequestEntity: CopyRequestEntity; +} +export interface CopyRequestContext extends CopyRequest { + processGroupId: string; +} +export interface CopyResponseContext { + copyResponse: CopyResponseEntity; + processGroupId: string; + pasteCount: number; +} +export interface CopyRequestEntity { + processGroups?: string[]; + remoteProcessGroups?: string[]; + processors?: string[]; + inputPorts?: string[]; + outputPorts?: string[]; + connections?: string[]; + labels?: string[]; + funnels?: string[]; +} +export interface CopyResponseEntity { + id: string; + processGroups?: any[]; + remoteProcessGroups?: any[]; + processors?: any[]; + inputPorts?: any[]; + outputPorts?: any[]; + connections?: any[]; + labels?: any[]; + funnels?: any[]; + externalControllerServiceReferences?: { [key: string]: ExternalControllerServiceReference }; + parameterContexts?: { [key: string]: any }; + parameterProviders?: { [key: string]: any }; +} + +export enum PasteRequestStrategy { + CENTER_ON_CANVAS, + OFFSET_FROM_ORIGINAL +} + +export interface CopiedContentInfo { + copyId: string; + processGroupId: string; + strategy: PasteRequestStrategy; +} + +export interface CopyState { + copiedContent: CopyResponseContext | null; +} diff --git a/nifi-frontend/src/main/frontend/apps/nifi/src/app/state/index.ts b/nifi-frontend/src/main/frontend/apps/nifi/src/app/state/index.ts index f7a3c02679..e2a99c2487 100644 --- a/nifi-frontend/src/main/frontend/apps/nifi/src/app/state/index.ts +++ b/nifi-frontend/src/main/frontend/apps/nifi/src/app/state/index.ts @@ -47,6 +47,8 @@ import { bannerTextFeatureKey, BannerTextState } from './banner-text'; import { bannerTextReducer } from './banner-text/banner-text.reducer'; import { documentVisibilityFeatureKey, DocumentVisibilityState } from './document-visibility'; import { documentVisibilityReducer } from './document-visibility/document-visibility.reducer'; +import { copyFeatureKey, CopyState } from './copy'; +import { copyReducer } from './copy/copy.reducer'; export interface NiFiState { [DEFAULT_ROUTER_FEATURENAME]: RouterReducerState; @@ -65,6 +67,7 @@ export interface NiFiState { [documentVisibilityFeatureKey]: DocumentVisibilityState; [clusterSummaryFeatureKey]: ClusterSummaryState; [propertyVerificationFeatureKey]: PropertyVerificationState; + [copyFeatureKey]: CopyState; } export const rootReducers: ActionReducerMap = { @@ -83,5 +86,6 @@ export const rootReducers: ActionReducerMap = { [componentStateFeatureKey]: componentStateReducer, [documentVisibilityFeatureKey]: documentVisibilityReducer, [clusterSummaryFeatureKey]: clusterSummaryReducer, - [propertyVerificationFeatureKey]: propertyVerificationReducer + [propertyVerificationFeatureKey]: propertyVerificationReducer, + [copyFeatureKey]: copyReducer }; diff --git a/nifi-frontend/src/main/frontend/apps/nifi/src/app/state/shared/index.ts b/nifi-frontend/src/main/frontend/apps/nifi/src/app/state/shared/index.ts index 17b9126cca..dad3971841 100644 --- a/nifi-frontend/src/main/frontend/apps/nifi/src/app/state/shared/index.ts +++ b/nifi-frontend/src/main/frontend/apps/nifi/src/app/state/shared/index.ts @@ -684,3 +684,8 @@ export interface OpenChangeComponentVersionDialogRequest { fetchRequest: FetchComponentVersionsRequest; componentVersions: DocumentedType[]; } + +export interface ExternalControllerServiceReference { + identifier: string; + name: string; +}