From 6531f43fd1fdc327d49bedd508673867bfb1bfdd Mon Sep 17 00:00:00 2001 From: Andrey Mishurinsky <58106986+Axeleron007@users.noreply.github.com> Date: Fri, 20 Aug 2021 14:38:25 +0300 Subject: [PATCH] Copy paste activities (#1398) * Copy/paste activities --- .../elsa-workflows-studio/src/components.d.ts | 3 + .../elsa-designer-tree/elsa-designer-tree.tsx | 144 ++++++++++++++++-- .../elsa-workflow-editor-notifications.tsx | 10 +- ...elsa-workflow-definition-editor-screen.tsx | 57 +++++++ .../localizations.ts | 6 + .../src/models/events.ts | 5 +- 6 files changed, 211 insertions(+), 14 deletions(-) diff --git a/src/designer/elsa-workflows-studio/src/components.d.ts b/src/designer/elsa-workflows-studio/src/components.d.ts index b9003ce43..79f30ae4d 100644 --- a/src/designer/elsa-workflows-studio/src/components.d.ts +++ b/src/designer/elsa-workflows-studio/src/components.d.ts @@ -47,6 +47,7 @@ export namespace Components { "activityBorderColor"?: (activity: ActivityModel) => string; "activityContextMenu"?: ActivityContextMenuState; "activityContextMenuButton"?: (activity: ActivityModel) => string; + "connectionContextMenu"?: ActivityContextMenuState; "enableMultipleConnectionsFromSingleSource": boolean; "layoutDirection": LayoutDirection; "mode": WorkflowDesignerMode; @@ -705,6 +706,7 @@ declare namespace LocalJSX { "activityBorderColor"?: (activity: ActivityModel) => string; "activityContextMenu"?: ActivityContextMenuState; "activityContextMenuButton"?: (activity: ActivityModel) => string; + "connectionContextMenu"?: ActivityContextMenuState; "enableMultipleConnectionsFromSingleSource"?: boolean; "layoutDirection"?: LayoutDirection; "mode"?: WorkflowDesignerMode; @@ -712,6 +714,7 @@ declare namespace LocalJSX { "onActivityContextMenuButtonClicked"?: (event: CustomEvent) => void; "onActivityDeselected"?: (event: CustomEvent) => void; "onActivitySelected"?: (event: CustomEvent) => void; + "onConnectionContextMenuButtonClicked"?: (event: CustomEvent) => void; "onWorkflow-changed"?: (event: CustomEvent) => void; "selectedActivityIds"?: Array; } diff --git a/src/designer/elsa-workflows-studio/src/components/designers/tree/elsa-designer-tree/elsa-designer-tree.tsx b/src/designer/elsa-workflows-studio/src/components/designers/tree/elsa-designer-tree/elsa-designer-tree.tsx index 674cd00f3..7164da6e7 100644 --- a/src/designer/elsa-workflows-studio/src/components/designers/tree/elsa-designer-tree/elsa-designer-tree.tsx +++ b/src/designer/elsa-workflows-studio/src/components/designers/tree/elsa-designer-tree/elsa-designer-tree.tsx @@ -1,4 +1,4 @@ -import {Component, Event, EventEmitter, h, Host, Method, Prop, State, Watch} from '@stencil/core'; +import {Component, Event, EventEmitter, h, Host, Method, Prop, State, Watch, Listen} from '@stencil/core'; import {v4 as uuid} from 'uuid'; import {addConnection, findActivity, getChildActivities, getInboundConnections, getOutboundConnections, Map, removeActivity, removeConnection} from '../../../../utils/utils'; import {ActivityDescriptor, ActivityDesignDisplayContext, ActivityModel, ActivityTraits, ConnectionModel, EventTypes, WorkflowModel, WorkflowPersistenceBehavior,} from '../../../../models'; @@ -21,6 +21,7 @@ export class ElsaWorkflowDesigner { @Prop() activityContextMenuButton?: (activity: ActivityModel) => string; @Prop() activityBorderColor?: (activity: ActivityModel) => string; @Prop() activityContextMenu?: ActivityContextMenuState; + @Prop() connectionContextMenu?: ActivityContextMenuState; @Prop() mode: WorkflowDesignerMode = WorkflowDesignerMode.Edit; @Prop() layoutDirection: LayoutDirection = LayoutDirection.Vertical; @Prop({attribute: 'enable-multiple-connections'}) enableMultipleConnectionsFromSingleSource: boolean; @@ -28,6 +29,7 @@ export class ElsaWorkflowDesigner { @Event() activitySelected: EventEmitter; @Event() activityDeselected: EventEmitter; @Event() activityContextMenuButtonClicked: EventEmitter; + @Event() connectionContextMenuButtonClicked: EventEmitter; @State() workflowModel: WorkflowModel; @State() activityContextMenuState: ActivityContextMenuState = { @@ -37,6 +39,13 @@ export class ElsaWorkflowDesigner { activity: null, }; + @State() connectionContextMenuState: ActivityContextMenuState = { + shown: false, + x: 0, + y: 0, + activity: null, + }; + el: HTMLElement; svg: SVGSVGElement; inner: SVGGElement; @@ -58,6 +67,11 @@ export class ElsaWorkflowDesigner { this.activityContextMenuButtonClicked.emit(state); } + handleConnectionContextMenuChange(state: ActivityContextMenuState) { + this.connectionContextMenuState = state; + this.connectionContextMenuButtonClicked.emit(state); + } + @Watch('model') handleModelChanged(newValue: WorkflowModel) { this.updateWorkflowModel(newValue, false); @@ -81,6 +95,11 @@ export class ElsaWorkflowDesigner { this.activityContextMenuState = newValue; } + @Watch('connectionContextMenu') + handleConnectionContextMenuChanged(newValue: ActivityContextMenuState) { + this.connectionContextMenuState = newValue; + } + @Method() async removeActivity(activity: ActivityModel) { this.removeActivityInternal(activity); @@ -89,16 +108,89 @@ export class ElsaWorkflowDesigner { @Method() async showActivityEditor(activity: ActivityModel, animate: boolean) { this.showActivityEditorInternal(activity, animate); + } + + @Listen('keydown', { target: 'document' }) + async handleKeyDown(event: KeyboardEvent){ + if((event.ctrlKey || event.metaKey) && event.key === 'c') { + await this.copyActivitiesToClipboard(); + } + if((event.ctrlKey || event.metaKey) && event.key === 'v') { + await this.pasteActivitiesFromClipboard(); + } + } + + async copyActivitiesToClipboard() + { + this.checkClipboardPermissions(); + await navigator.clipboard.writeText(JSON.stringify(this.selectedActivities)); + eventBus.emit(EventTypes.ClipboardCopied, this); + } + + async pasteActivitiesFromClipboard() + { + this.checkClipboardPermissions(); + + let copiedActivities: Array = []; + + await navigator.clipboard.readText().then(data => { + copiedActivities = JSON.parse(data); + }); + + this.addActivitiesFromClipboard(copiedActivities) + } + + async addActivitiesFromClipboard(copiedActivities: Array) + { + let sourceActivityId: string; + this.parentActivityId = null; + this.parentActivityOutcome = null; + + for (const key in this.selectedActivities) { + sourceActivityId = this.selectedActivities[key].activityId; + } + + if (sourceActivityId != undefined) + { + this.parentActivityId = sourceActivityId; + this.parentActivityOutcome = this.selectedActivities[sourceActivityId].outcomes[0]; + } + + for (const key in copiedActivities) { + this.addingActivity = true; + copiedActivities[key].activityId = uuid(); + + eventBus.emit(EventTypes.UpdateActivity, this, copiedActivities[key]); + + this.parentActivityId = copiedActivities[key].activityId; + this.parentActivityOutcome = copiedActivities[key].outcomes[0]; + } + + this.selectedActivities = {}; + // Set to null to avoid conflict with on Activity node click event + this.parentActivityId = null; + this.parentActivityOutcome = null; + } + + checkClipboardPermissions() + { + navigator.permissions.query({ name: "clipboard-read" }).then((result) => { + if (result.state == 'denied') + eventBus.emit(EventTypes.ClipboardPermissionDenied, this); + }); } connectedCallback() { eventBus.on(EventTypes.ActivityPicked, this.onActivityPicked); eventBus.on(EventTypes.UpdateActivity, this.onUpdateActivity); + eventBus.on(EventTypes.PasteActivity, this.onPasteActivity); + } disconnectedCallback() { eventBus.detach(EventTypes.ActivityPicked, this.onActivityPicked); eventBus.detach(EventTypes.UpdateActivity, this.onUpdateActivity); + eventBus.detach(EventTypes.PasteActivity, this.onPasteActivity); d3.selectAll('.node').on('click', null); d3.selectAll('.edgePath').on('contextmenu', null); } @@ -485,6 +577,15 @@ export class ElsaWorkflowDesigner { } }; + onPasteActivity = async args => { + const activityModel = args as ActivityModel; + + this.selectedActivities = {}; + activityModel.outcomes[0] = this.parentActivityOutcome; + this.selectedActivities[activityModel.activityId] = activityModel; + this.pasteActivitiesFromClipboard(); + }; + tryRerenderTree(waitTime?: number, attempt?: number) { const maxTries = 3; @@ -539,6 +640,13 @@ export class ElsaWorkflowDesigner { }) .on("mouseout", e => { d3.select(node.elem).select('svg').classed('elsa-text-green-400', false).classed('hover:elsa-text-blue-500', true); + }) + .on('contextmenu', e => { + e.preventDefault(); + e.stopPropagation(); + this.parentActivityId = node.activity.activityId; + this.parentActivityOutcome = node.outcome; + this.handleConnectionContextMenuChange({x: e.clientX, y: e.clientY, shown: true, activity: node.activity}); }); }); @@ -576,21 +684,35 @@ export class ElsaWorkflowDesigner { const activity = node.activity; const activityId = activity.activityId; - d3.select(node.elem).on('click', () => { + d3.select(node.elem).on('click', e => { // If a parent activity was selected to connect to: if (this.mode == WorkflowDesignerMode.Edit && this.parentActivityId && this.parentActivityOutcome) { this.addConnection(this.parentActivityId, activityId, this.parentActivityOutcome); } else { - // When clicking an activity: - if (!!this.selectedActivities[activityId]) - delete this.selectedActivities[activityId]; - else { - for (const key in this.selectedActivities) { - this.activityDeselected.emit(this.selectedActivities[key]); + // When clicking an activity with shift: + if (e.shiftKey) + { + if (!!this.selectedActivities[activityId]) + delete this.selectedActivities[activityId]; + else + { + this.selectedActivities[activityId] = activity; + this.activitySelected.emit(activity); + } + } + // When clicking an activity: + else + { + if (!!this.selectedActivities[activityId]) + delete this.selectedActivities[activityId]; + else { + for (const key in this.selectedActivities) { + this.activityDeselected.emit(this.selectedActivities[key]); + } + this.selectedActivities = {}; + this.selectedActivities[activityId] = activity; + this.activitySelected.emit(activity); } - this.selectedActivities = {}; - this.selectedActivities[activityId] = activity; - this.activitySelected.emit(activity); } this.rerenderTree(); diff --git a/src/designer/elsa-workflows-studio/src/components/screens/workflow-definition-editor/elsa-workflow-definition-editor-notifications/elsa-workflow-editor-notifications.tsx b/src/designer/elsa-workflows-studio/src/components/screens/workflow-definition-editor/elsa-workflow-definition-editor-notifications/elsa-workflow-editor-notifications.tsx index ba69ef6c1..6b22becec 100644 --- a/src/designer/elsa-workflows-studio/src/components/screens/workflow-definition-editor/elsa-workflow-definition-editor-notifications/elsa-workflow-editor-notifications.tsx +++ b/src/designer/elsa-workflows-studio/src/components/screens/workflow-definition-editor/elsa-workflow-definition-editor-notifications/elsa-workflow-editor-notifications.tsx @@ -14,15 +14,21 @@ export class ElsaWorkflowEditorNotifications { eventBus.on(EventTypes.WorkflowPublished, this.onWorkflowPublished); eventBus.on(EventTypes.WorkflowRetracted, this.onWorkflowRetracted); eventBus.on(EventTypes.WorkflowImported, this.onWorkflowImported); + eventBus.on(EventTypes.ClipboardPermissionDenied, this.onClipboardPermissionsDenied); + eventBus.on(EventTypes.ClipboardCopied, this.onClipboardCopied); } disconnectedCallback() { eventBus.detach(EventTypes.WorkflowPublished, this.onWorkflowPublished); - eventBus.detach(EventTypes.WorkflowRetracted, this.onWorkflowRetracted); + eventBus.detach(EventTypes.WorkflowRetracted, this.onWorkflowRetracted); eventBus.detach(EventTypes.WorkflowImported, this.onWorkflowImported); + eventBus.detach(EventTypes.ClipboardPermissionDenied, this.onClipboardPermissionsDenied); + eventBus.detach(EventTypes.ClipboardCopied, this.onClipboardCopied); } onWorkflowPublished = (workflowDefinition: WorkflowDefinition) => toastNotificationService.show('Workflow Published', `Workflow successfully published at version ${workflowDefinition.version}.`, 1500); onWorkflowRetracted = (workflowDefinition: WorkflowDefinition) => toastNotificationService.show('Workflow Retracted', `Workflow successfully retracted at version ${workflowDefinition.version}.`, 1500); - onWorkflowImported = (workflowDefinition: WorkflowDefinition) => toastNotificationService.show('Workflow Imported', `Workflow successfully imported.`, 1500); + onWorkflowImported = () => toastNotificationService.show('Workflow Imported', `Workflow successfully imported.`, 1500); + onClipboardPermissionsDenied = () => toastNotificationService.show('Clipboard Error', `Clipboard pemission denied.`, 1500); + onClipboardCopied = () => toastNotificationService.show('Copy to Clipboard', `Activities successfully copied to Clipboard.`, 1500); } diff --git a/src/designer/elsa-workflows-studio/src/components/screens/workflow-definition-editor/elsa-workflow-definition-editor-screen/elsa-workflow-definition-editor-screen.tsx b/src/designer/elsa-workflows-studio/src/components/screens/workflow-definition-editor/elsa-workflow-definition-editor-screen/elsa-workflow-definition-editor-screen.tsx index e1e4de247..b65e0635f 100644 --- a/src/designer/elsa-workflows-studio/src/components/screens/workflow-definition-editor/elsa-workflow-definition-editor-screen/elsa-workflow-definition-editor-screen.tsx +++ b/src/designer/elsa-workflows-studio/src/components/screens/workflow-definition-editor/elsa-workflow-definition-editor-screen/elsa-workflow-definition-editor-screen.tsx @@ -51,6 +51,13 @@ export class ElsaWorkflowDefinitionEditorScreen { activity: null, }; + @State() connectionContextMenuState: ActivityContextMenuState = { + shown: false, + x: 0, + y: 0, + activity: null, + }; + i18next: i18n; el: HTMLElement; designer: HTMLElsaDesignerTreeElement; @@ -241,6 +248,7 @@ export class ElsaWorkflowDefinitionEditorScreen { try { console.debug("Saving workflow..."); + workflowDefinition = await client.workflowDefinitionsApi.save(request); this.workflowDefinition = workflowDefinition; this.workflowModel = this.mapWorkflowModel(workflowDefinition); @@ -319,6 +327,10 @@ export class ElsaWorkflowDefinitionEditorScreen { this.activityContextMenuState = state; } + handleConnectionContextMenuChange(state: ActivityContextMenuState) { + this.connectionContextMenuState = state; + } + onShowWorkflowSettingsClick() { eventBus.emit(EventTypes.ShowWorkflowSettings); } @@ -351,10 +363,21 @@ export class ElsaWorkflowDefinitionEditorScreen { this.handleContextMenuChange({x: 0, y: 0, shown: false, activity: null}); } + async onPasteActivityClick(e: Event) { + e.preventDefault(); + let activityModel = this.connectionContextMenuState.activity; + eventBus.emit(EventTypes.PasteActivity, this, activityModel); + this.handleConnectionContextMenuChange({x: 0, y: 0, shown: false, activity: null}); + } + onActivityContextMenuButtonClicked(e: CustomEvent) { this.activityContextMenuState = e.detail; } + onConnectionContextMenuButtonClicked(e: CustomEvent) { + this.connectionContextMenuState = e.detail; + } + private onUpdateWorkflowSettings = async (workflowDefinition: WorkflowDefinition) => { this.updateWorkflowDefinition(workflowDefinition); await this.saveWorkflowInternal(this.workflowModel); @@ -399,6 +422,7 @@ export class ElsaWorkflowDefinitionEditorScreen { mode={WorkflowDesignerMode.Edit} activityContextMenuButton={activityContextMenuButton} onActivityContextMenuButtonClicked={e => this.onActivityContextMenuButtonClicked(e)} + onConnectionContextMenuButtonClicked={e => this.onConnectionContextMenuButtonClicked(e)} activityContextMenu={this.activityContextMenuState} enableMultipleConnectionsFromSingleSource={false} class="elsa-flex-1" @@ -406,6 +430,7 @@ export class ElsaWorkflowDefinitionEditorScreen { {this.renderPropertiesPanel()} {this.renderWorkflowSettingsButton()} {this.renderActivityContextMenu()} + {this.renderConnectionContextMenu()}
@@ -461,6 +486,38 @@ export class ElsaWorkflowDefinitionEditorScreen {
} + renderConnectionContextMenu() { + const t = this.t; + + return + } + renderActivityPicker() { return ; } diff --git a/src/designer/elsa-workflows-studio/src/components/screens/workflow-definition-editor/elsa-workflow-definition-editor-screen/localizations.ts b/src/designer/elsa-workflows-studio/src/components/screens/workflow-definition-editor/elsa-workflow-definition-editor-screen/localizations.ts index da730fe72..e09fb4ff5 100644 --- a/src/designer/elsa-workflows-studio/src/components/screens/workflow-definition-editor/elsa-workflow-definition-editor-screen/localizations.ts +++ b/src/designer/elsa-workflows-studio/src/components/screens/workflow-definition-editor/elsa-workflow-definition-editor-screen/localizations.ts @@ -6,6 +6,9 @@ "ActivityContextMenu":{ "Edit": "Edit", "Delete": "Delete" + }, + "ConnectionContextMenu":{ + "Paste": "Paste", } } }, @@ -16,6 +19,9 @@ "ActivityContextMenu":{ "Edit": "Bewerken", "Delete": "Verwijderen" + }, + "ConnectionContextMenu":{ + "Paste": "Plakken", } } } diff --git a/src/designer/elsa-workflows-studio/src/models/events.ts b/src/designer/elsa-workflows-studio/src/models/events.ts index b7cb0d302..0c23019fa 100644 --- a/src/designer/elsa-workflows-studio/src/models/events.ts +++ b/src/designer/elsa-workflows-studio/src/models/events.ts @@ -21,7 +21,10 @@ export const EventTypes = { ShowConfirmDialog: 'show-confirm-dialog', HideConfirmDialog: 'hide-confirm-dialog', ShowToastNotification: 'show-toast-notification', - HideToastNotification: 'hide-toast-notification' + HideToastNotification: 'hide-toast-notification', + ClipboardPermissionDenied: 'clipboard-permission-denied', + ClipboardCopied: 'clipboard-copied', + PasteActivity: 'paste-activity', }; export interface AddActivityEventArgs {