This is an automated email from the ASF dual-hosted git repository.
mcgilman pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/nifi.git
The following commit(s) were added to refs/heads/main by this push:
new df793ce14e [NIFI-13977] - Updated Copy/Paste in UI to align with new
backend API (#9536)
df793ce14e is described below
commit df793ce14e0ce50988c0f217b72cbe89a553bde7
Author: Rob Fellows <[email protected]>
AuthorDate: Tue Dec 3 16:57:04 2024 -0500
[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
---
.../main/frontend/apps/nifi/src/app/app.module.ts | 4 +-
.../service/canvas-actions.service.ts | 130 +++++---
.../flow-designer/service/canvas-utils.service.ts | 10 +-
.../flow-designer/service/canvas-view.service.ts | 35 +-
.../flow-designer/service/copy-paste.service.ts | 354 +++++++++++++++++++++
.../pages/flow-designer/service/snippet.service.ts | 11 -
.../pages/flow-designer/state/flow/flow.actions.ts | 12 +-
.../pages/flow-designer/state/flow/flow.effects.ts | 153 ++++++---
.../pages/flow-designer/state/flow/flow.reducer.ts | 12 +-
.../flow-designer/state/flow/flow.selectors.ts | 7 +-
.../app/pages/flow-designer/state/flow/index.ts | 35 +-
.../flow-designer/ui/canvas/canvas.component.ts | 63 +++-
.../apps/nifi/src/app/state/copy/copy.actions.ts | 25 ++
.../apps/nifi/src/app/state/copy/copy.effects.ts | 23 ++
.../apps/nifi/src/app/state/copy/copy.reducer.ts | 49 +++
.../apps/nifi/src/app/state/copy/copy.selectors.ts | 25 ++
.../frontend/apps/nifi/src/app/state/copy/index.ts | 73 +++++
.../main/frontend/apps/nifi/src/app/state/index.ts | 6 +-
.../apps/nifi/src/app/state/shared/index.ts | 5 +
19 files changed, 876 insertions(+), 156 deletions(-)
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<any, any, any,
any>) => boolean;
export type CanvasActionFunction = (selection: d3.Selection<any, any, any,
any>, extraArgs?: any) => void;
@@ -139,45 +141,90 @@ export class CanvasActionsService {
return this.canvasUtils.isCopyable(selection);
},
action: (selection: d3.Selection<any, any, any, any>) => {
- const origin = this.canvasUtils.getOrigin(selection);
- const dimensions =
this.canvasView.getSelectionBoundingClientRect(selection);
-
- const components: CopyComponentRequest[] = [];
+ 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
- }
- })
- );
- }
- },
- 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
+ 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' });
})
- );
+ });
+ 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<CanvasState>,
private canvasUtils: CanvasUtils,
private canvasView: CanvasView,
private dialog: MatDialog,
- private client: Client
+ private client: Client,
+ private copyService: CopyPasteService
) {}
private select(selection: d3.Selection<any, any, any, any>) {
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<NiFiState>
+ ) {}
+
+ copy(copyRequest: CopyRequestContext): Observable<CopyResponseEntity> {
+ return this.httpClient.post(
+
`${CopyPasteService.API}/process-groups/${copyRequest.processGroupId}/copy`,
+ copyRequest.copyRequestEntity
+ ) as Observable<CopyResponseEntity>;
+ }
+
+ paste(pasteRequest: PasteRequestContext): Observable<any> {
+ 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<any> {
- const payload: any = {
- disconnectedNodeAcknowledged:
this.clusterConnectionService.isDisconnectionAcknowledged(),
- originX: pasteLocation.x,
- originY: pasteLocation.y,
- snippetId
- };
- return
this.httpClient.post(`${SnippetService.API}/process-groups/${groupId}/snippet-instance`,
payload);
- }
-
deleteSnippet(snippetId: string): Observable<any> {
const params = new HttpParams({
fromObject: {
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;
-
- 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
- };
- }
+ switchMap(([request, processGroupId, revision, copiedContent]) => {
+ let pasteRequest: PasteRequest | null = null;
+
+ // 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
+ );
}
+ }
+ }
+
+ // If no paste request was created before, create one that is
centered in the current canvas view
+ if (!pasteRequest) {
+ pasteRequest =
this.copyPasteService.toCenteredPasteRequest(request);
+ }
- return from(
-
this.snippetService.copySnippet(response.snippet.id, pasteLocation,
processGroupId)
- ).pipe(map((response) => FlowActions.pasteSuccess({
response })));
+ 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<CopyState>(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<NiFiState> = {
@@ -83,5 +86,6 @@ export const rootReducers: ActionReducerMap<NiFiState> = {
[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;
+}