NIFI-13059: (#8661)

- Adding support for copying and pasting on the canvas.

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,118 @@
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { Injectable } from '@angular/core';
import { Observable } from 'rxjs';
import { HttpClient, HttpParams } from '@angular/common/http';
import { Snippet, SnippetComponentRequest } from '../state/flow';
import { ClusterConnectionService } from '../../../service/cluster-connection.service';
import { ComponentType } from '../../../state/shared';
import { Client } from '../../../service/client.service';
import { Position } from '../state/shared';
@Injectable({ providedIn: 'root' })
export class SnippetService {
private static readonly API: string = '../nifi-api';
constructor(
private httpClient: HttpClient,
private client: Client,
private clusterConnectionService: ClusterConnectionService
) {}
marshalSnippet(components: SnippetComponentRequest[], processGroupId: string): Snippet {
return components.reduce(
(snippet, component) => {
switch (component.type) {
case ComponentType.Processor:
snippet.processors[component.id] = this.client.getRevision(component.entity);
break;
case ComponentType.InputPort:
snippet.inputPorts[component.id] = this.client.getRevision(component.entity);
break;
case ComponentType.OutputPort:
snippet.outputPorts[component.id] = this.client.getRevision(component.entity);
break;
case ComponentType.ProcessGroup:
snippet.processGroups[component.id] = this.client.getRevision(component.entity);
break;
case ComponentType.RemoteProcessGroup:
snippet.remoteProcessGroups[component.id] = this.client.getRevision(component.entity);
break;
case ComponentType.Funnel:
snippet.funnels[component.id] = this.client.getRevision(component.entity);
break;
case ComponentType.Label:
snippet.labels[component.id] = this.client.getRevision(component.entity);
break;
case ComponentType.Connection:
snippet.connections[component.id] = this.client.getRevision(component.entity);
break;
}
return snippet;
},
{
parentGroupId: processGroupId,
processors: {},
funnels: {},
inputPorts: {},
outputPorts: {},
remoteProcessGroups: {},
processGroups: {},
connections: {},
labels: {}
} as Snippet
);
}
createSnippet(snippet: Snippet): Observable<any> {
return this.httpClient.post(`${SnippetService.API}/snippets`, {
disconnectedNodeAcknowledged: this.clusterConnectionService.isDisconnectionAcknowledged(),
snippet
});
}
moveSnippet(snippetId: string, groupId: string): Observable<any> {
const payload: any = {
disconnectedNodeAcknowledged: this.clusterConnectionService.isDisconnectionAcknowledged(),
snippet: {
id: snippetId,
parentGroupId: groupId
}
};
return this.httpClient.put(`${SnippetService.API}/snippets/${snippetId}`, payload);
}
copySnippet(snippetId: string, pasteLocation: Position, groupId: string): Observable<any> {
const payload: any = {
disconnectedNodeAcknowledged: this.clusterConnectionService.isDisconnectionAcknowledged(),
originX: pasteLocation.x,
originY: pasteLocation.y,
snippetId
};
return this.httpClient.post(`${SnippetService.API}/process-groups/${groupId}/snippet-instance`, payload);
}
deleteSnippet(snippetId: string): Observable<any> {
const params = new HttpParams({
fromObject: {
disconnectedNodeAcknowledged: this.clusterConnectionService.isDisconnectionAcknowledged()
}
});
return this.httpClient.delete(`${SnippetService.API}/snippets/${snippetId}`, { params });
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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