Copy paste activities (#1398)

* Copy/paste activities
This commit is contained in:
Andrey Mishurinsky 2021-08-20 14:38:25 +03:00 committed by GitHub
parent cbdc3e4375
commit 6531f43fd1
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
6 changed files with 211 additions and 14 deletions

View file

@ -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<ActivityContextMenuState>) => void;
"onActivityDeselected"?: (event: CustomEvent<ActivityModel>) => void;
"onActivitySelected"?: (event: CustomEvent<ActivityModel>) => void;
"onConnectionContextMenuButtonClicked"?: (event: CustomEvent<ActivityContextMenuState>) => void;
"onWorkflow-changed"?: (event: CustomEvent<WorkflowModel>) => void;
"selectedActivityIds"?: Array<string>;
}

View file

@ -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<ActivityModel>;
@Event() activityDeselected: EventEmitter<ActivityModel>;
@Event() activityContextMenuButtonClicked: EventEmitter<ActivityContextMenuState>;
@Event() connectionContextMenuButtonClicked: EventEmitter<ActivityContextMenuState>;
@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<ActivityModel> = [];
await navigator.clipboard.readText().then(data => {
copiedActivities = JSON.parse(data);
});
this.addActivitiesFromClipboard(copiedActivities)
}
async addActivitiesFromClipboard(copiedActivities: Array<ActivityModel>)
{
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();

View file

@ -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);
}

View file

@ -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<ActivityContextMenuState>) {
this.activityContextMenuState = e.detail;
}
onConnectionContextMenuButtonClicked(e: CustomEvent<ActivityContextMenuState>) {
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()}
<elsa-workflow-settings-modal workflowDefinition={this.workflowDefinition}/>
<elsa-workflow-definition-editor-notifications/>
<div class="elsa-fixed elsa-bottom-10 elsa-right-12">
@ -461,6 +486,38 @@ export class ElsaWorkflowDefinitionEditorScreen {
</div>
}
renderConnectionContextMenu() {
const t = this.t;
return <div
data-transition-enter="elsa-transition elsa-ease-out elsa-duration-100"
data-transition-enter-start="elsa-transform elsa-opacity-0 elsa-scale-95"
data-transition-enter-end="elsa-transform elsa-opacity-100 elsa-scale-100"
data-transition-leave="elsa-transition elsa-ease-in elsa-duration-75"
data-transition-leave-start="elsa-transform elsa-opacity-100 elsa-scale-100"
data-transition-leave-end="elsa-transform elsa-opacity-0 elsa-scale-95"
class={`${this.connectionContextMenuState.shown ? '' : 'hidden'} context-menu elsa-z-10 elsa-mx-3 elsa-w-48 elsa-mt-1 elsa-rounded-md elsa-shadow-lg elsa-absolute`}
style={{left: `${this.connectionContextMenuState.x}px`, top: `${this.connectionContextMenuState.y - 64}px`}}
ref={el =>
registerClickOutside(this, el, () => {
this.handleConnectionContextMenuChange({x: 0, y: 0, shown: false, activity: null});
})
}
>
<div class="elsa-rounded-md elsa-bg-white elsa-shadow-xs" role="menu" aria-orientation="vertical" aria-labelledby="pinned-project-options-menu-0">
<div class="elsa-py-1">
<a
onClick={e => this.onPasteActivityClick(e)}
href="#"
class="elsa-block elsa-px-4 elsa-py-2 elsa-text-sm elsa-leading-5 elsa-text-gray-700 hover:elsa-bg-gray-100 hover:elsa-text-gray-900 focus:elsa-outline-none focus:elsa-bg-gray-100 focus:elsa-text-gray-900"
role="menuitem">
{t('ConnectionContextMenu.Paste')}
</a>
</div>
</div>
</div>
}
renderActivityPicker() {
return <elsa-activity-picker-modal/>;
}

View file

@ -6,6 +6,9 @@
"ActivityContextMenu":{
"Edit": "Edit",
"Delete": "Delete"
},
"ConnectionContextMenu":{
"Paste": "Paste",
}
}
},
@ -16,6 +19,9 @@
"ActivityContextMenu":{
"Edit": "Bewerken",
"Delete": "Verwijderen"
},
"ConnectionContextMenu":{
"Paste": "Plakken",
}
}
}

View file

@ -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 {