mirror of https://github.com/apache/nifi.git
[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
This commit is contained in:
parent
39aa106943
commit
df793ce14e
|
@ -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,
|
||||
|
|
|
@ -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
|
||||
}
|
||||
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<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>) {
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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 };
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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: {
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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: {
|
||||
|
|
|
@ -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 }) => ({
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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';
|
||||
}
|
||||
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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');
|
|
@ -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 {}
|
|
@ -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 }))
|
||||
);
|
|
@ -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);
|
|
@ -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;
|
||||
}
|
|
@ -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
|
||||
};
|
||||
|
|
|
@ -684,3 +684,8 @@ export interface OpenChangeComponentVersionDialogRequest {
|
|||
fetchRequest: FetchComponentVersionsRequest;
|
||||
componentVersions: DocumentedType[];
|
||||
}
|
||||
|
||||
export interface ExternalControllerServiceReference {
|
||||
identifier: string;
|
||||
name: string;
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue