Implement drag&drop to change connections

This commit is contained in:
Sipke Schoorstra 2021-03-09 13:46:40 +01:00
parent 6220a49708
commit dee30999b4
3 changed files with 261 additions and 114 deletions

View file

@ -19,6 +19,7 @@
"autoprefixer": "10.2.4",
"axios": "^0.21.1",
"cssnano": "^4.1.10",
"dragula": "^3.7.3",
"el-transition": "^0.0.7",
"eslint": "7.20.0",
"js-event-bus": "^1.0.3",
@ -287,6 +288,11 @@
"node": ">= 4.0.0"
}
},
"node_modules/atoa": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/atoa/-/atoa-1.0.0.tgz",
"integrity": "sha1-DMDpGkgOc4+SPrwQNnZHF3mzSkk="
},
"node_modules/autoprefixer": {
"version": "10.2.4",
"resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.2.4.tgz",
@ -527,6 +533,15 @@
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
"integrity": "sha1-2Klr13/Wjfd5OnMDajug1UBdR3s="
},
"node_modules/contra": {
"version": "1.9.4",
"resolved": "https://registry.npmjs.org/contra/-/contra-1.9.4.tgz",
"integrity": "sha1-9TveQtfltZhcrk2ZqNYQUm3o8o0=",
"dependencies": {
"atoa": "1.0.0",
"ticky": "1.0.1"
}
},
"node_modules/copyfiles": {
"version": "2.4.1",
"resolved": "https://registry.npmjs.org/copyfiles/-/copyfiles-2.4.1.tgz",
@ -591,6 +606,14 @@
"node": ">= 8"
}
},
"node_modules/crossvent": {
"version": "1.5.5",
"resolved": "https://registry.npmjs.org/crossvent/-/crossvent-1.5.5.tgz",
"integrity": "sha1-rSCHjkkh6b5z2daXb4suzQ9xoLE=",
"dependencies": {
"custom-event": "^1.0.0"
}
},
"node_modules/css-blank-pseudo": {
"version": "0.1.4",
"resolved": "https://registry.npmjs.org/css-blank-pseudo/-/css-blank-pseudo-0.1.4.tgz",
@ -943,6 +966,11 @@
"resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.14.tgz",
"integrity": "sha512-dn6wd0uw5GsdswPFfsgMp5NSB0/aDe6fK94YJV/AJDYXL6HVLWBsxeq7js7Ad+mU2K9LAlwpk6kN2D5mwCPVow=="
},
"node_modules/custom-event": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/custom-event/-/custom-event-1.0.1.tgz",
"integrity": "sha1-XQKkaFCt8bSjF5RqOSj8y1v9BCU="
},
"node_modules/debug": {
"version": "4.3.1",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.3.1.tgz",
@ -1054,6 +1082,15 @@
"node": ">=8"
}
},
"node_modules/dragula": {
"version": "3.7.3",
"resolved": "https://registry.npmjs.org/dragula/-/dragula-3.7.3.tgz",
"integrity": "sha512-/rRg4zRhcpf81TyDhaHLtXt6sEywdfpv1cRUMeFFy7DuypH2U0WUL0GTdyAQvXegviT4PJK4KuMmOaIDpICseQ==",
"dependencies": {
"contra": "1.9.4",
"crossvent": "1.5.5"
}
},
"node_modules/el-transition": {
"version": "0.0.7",
"resolved": "https://registry.npmjs.org/el-transition/-/el-transition-0.0.7.tgz",
@ -4612,6 +4649,11 @@
"xtend": "~4.0.1"
}
},
"node_modules/ticky": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/ticky/-/ticky-1.0.1.tgz",
"integrity": "sha1-t8+nHnaPHJAAxJe5FRswlHxQ5G0="
},
"node_modules/timsort": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/timsort/-/timsort-0.3.0.tgz",
@ -5185,6 +5227,11 @@
"resolved": "https://registry.npmjs.org/at-least-node/-/at-least-node-1.0.0.tgz",
"integrity": "sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg=="
},
"atoa": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/atoa/-/atoa-1.0.0.tgz",
"integrity": "sha1-DMDpGkgOc4+SPrwQNnZHF3mzSkk="
},
"autoprefixer": {
"version": "10.2.4",
"resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.2.4.tgz",
@ -5385,6 +5432,15 @@
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
"integrity": "sha1-2Klr13/Wjfd5OnMDajug1UBdR3s="
},
"contra": {
"version": "1.9.4",
"resolved": "https://registry.npmjs.org/contra/-/contra-1.9.4.tgz",
"integrity": "sha1-9TveQtfltZhcrk2ZqNYQUm3o8o0=",
"requires": {
"atoa": "1.0.0",
"ticky": "1.0.1"
}
},
"copyfiles": {
"version": "2.4.1",
"resolved": "https://registry.npmjs.org/copyfiles/-/copyfiles-2.4.1.tgz",
@ -5435,6 +5491,14 @@
"which": "^2.0.1"
}
},
"crossvent": {
"version": "1.5.5",
"resolved": "https://registry.npmjs.org/crossvent/-/crossvent-1.5.5.tgz",
"integrity": "sha1-rSCHjkkh6b5z2daXb4suzQ9xoLE=",
"requires": {
"custom-event": "^1.0.0"
}
},
"css-blank-pseudo": {
"version": "0.1.4",
"resolved": "https://registry.npmjs.org/css-blank-pseudo/-/css-blank-pseudo-0.1.4.tgz",
@ -5713,6 +5777,11 @@
}
}
},
"custom-event": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/custom-event/-/custom-event-1.0.1.tgz",
"integrity": "sha1-XQKkaFCt8bSjF5RqOSj8y1v9BCU="
},
"debug": {
"version": "4.3.1",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.3.1.tgz",
@ -5805,6 +5874,15 @@
"is-obj": "^2.0.0"
}
},
"dragula": {
"version": "3.7.3",
"resolved": "https://registry.npmjs.org/dragula/-/dragula-3.7.3.tgz",
"integrity": "sha512-/rRg4zRhcpf81TyDhaHLtXt6sEywdfpv1cRUMeFFy7DuypH2U0WUL0GTdyAQvXegviT4PJK4KuMmOaIDpICseQ==",
"requires": {
"contra": "1.9.4",
"crossvent": "1.5.5"
}
},
"el-transition": {
"version": "0.0.7",
"resolved": "https://registry.npmjs.org/el-transition/-/el-transition-0.0.7.tgz",
@ -8770,6 +8848,11 @@
"xtend": "~4.0.1"
}
},
"ticky": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/ticky/-/ticky-1.0.1.tgz",
"integrity": "sha1-t8+nHnaPHJAAxJe5FRswlHxQ5G0="
},
"timsort": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/timsort/-/timsort-0.3.0.tgz",

View file

@ -204,119 +204,6 @@ export class ElsaWorkflowDesigner {
this.workflowChanged.emit(model);
}
onConnectionCreated(connection: ConnectionModel) {
const workflowModel = {...this.workflowModel};
workflowModel.connections = [...workflowModel.connections, connection];
this.updateWorkflowModel(workflowModel);
}
onConnectionDetached(connection: ConnectionModel) {
const workflowModel = {...this.workflowModel};
workflowModel.connections = workflowModel.connections.filter(x => !(x.sourceId == connection.sourceId && x.targetId == connection.targetId && x.outcome == connection.outcome));
this.updateWorkflowModel(workflowModel);
}
onAddButtonClick() {
this.showActivityPicker();
}
onOutcomeButtonClick(e: Event, outcome?: string, activityId?: string) {
e.preventDefault();
this.parentActivityId = activityId;
this.parentActivityOutcome = outcome;
this.showActivityPicker();
}
render() {
const renderedActivities = new Set<string>();
return (
<Host class="workflow-canvas flex-1 flex" ref={el => this.el = el}>
<div class="flex-1 text-gray-200">
<div class="p-10">
<div class="canvas select-none" ref={el => this.canvasElement = el}>
<div class="tree">
<ul>
<li>
<div class="inline-flex flex flex-col items-center">
<button id="start-button"
onClick={() => this.onAddButtonClick()}
type="button"
class="px-6 py-3 border border-transparent text-base leading-6 font-medium rounded-md text-white bg-green-600 hover:bg-green-500 focus:outline-none focus:border-green-700 focus:shadow-outline-green active:bg-green-700 transition ease-in-out duration-150">
Start
</button>
</div>
{this.renderTree(this.getRootActivities(), true, renderedActivities)}
</li>
</ul>
</div>
</div>
</div>
</div>
</Host>
);
}
renderTree(activities: Array<ActivityModel>, isRoot: boolean, renderedActivities: Set<string>): any {
const list = activities.filter(x => !renderedActivities.has(x.activityId));
const cssClass = isRoot ? "root" : undefined;
for (const activity of list)
renderedActivities.add(activity.activityId);
if (list.length == 0)
return null;
return (
<ul class={cssClass}>
{activities.map(x => {
const activityId = x.activityId;
const children = getChildActivities(this.workflowModel, activityId);
const displayContext = this.activityDisplayContexts[activityId];
return <li key={x.activityId}>
<div class="inline-flex flex flex-col items-center">
{isRoot ? this.renderOutcomeButton(`start-button-plus-${x.activityId}`, '', e => this.onOutcomeButtonClick(e, null, null)) : undefined}
{this.renderActivity(displayContext)}
{this.renderOutcomeButtons(displayContext)}
</div>
{children.length > 0 ? this.renderTree(children, false, renderedActivities) : undefined}
</li>;
})}
</ul>
);
}
renderOutcomeButtons(displayContext: ActivityDesignDisplayContext) {
const activityId = displayContext.activityModel.activityId;
return (
<div class="flex flex-row space-x-6">
{displayContext.outcomes.map(x => this.renderOutcomeButton(`${activityId}-${x}`, x, e => this.onOutcomeButtonClick(e, x, activityId)))}
</div>
);
}
renderOutcomeButton(id: string, outcome: string, clickHandler: (e: MouseEvent) => void) {
return (
<div class="my-6 flex flex-col items-center">
{outcome && outcome.length > 0 ? <div
class="mb-4 relative z-10 px-2.5 py-0.5 rounded-full text-xs font-medium leading-4 bg-cool-gray-100 text-cool-gray-800 capitalize">{outcome}</div> : undefined}
<a key={id} id={id} href="#" onClick={e => clickHandler(e)}>
<svg class="h-8 w-8 text-gray-400 hover:text-blue-500 cursor-pointer" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v3m0 0v3m0-3h3m-3 0H9m12 0a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
</a>
</div>
);
}
renderActivity(displayContext: ActivityDesignDisplayContext) {
return <elsa-designer-tree-activity displayContext={displayContext}/>;
}
getJsPlumbConnections(): Array<any> {
const rootActivities = getChildActivities(this.workflowModel, null);
@ -395,4 +282,181 @@ export class ElsaWorkflowDesigner {
}
));
}
onConnectionCreated(connection: ConnectionModel) {
const workflowModel = {...this.workflowModel};
workflowModel.connections = [...workflowModel.connections, connection];
this.updateWorkflowModel(workflowModel);
}
onConnectionDetached(connection: ConnectionModel) {
const workflowModel = {...this.workflowModel};
workflowModel.connections = workflowModel.connections.filter(x => !(x.sourceId == connection.sourceId && x.targetId == connection.targetId && x.outcome == connection.outcome));
this.updateWorkflowModel(workflowModel);
}
onAddButtonClick() {
this.showActivityPicker();
}
onOutcomeButtonClick(e: Event, outcome?: string, activityId?: string) {
e.preventDefault();
this.parentActivityId = activityId;
this.parentActivityOutcome = outcome;
this.showActivityPicker();
}
async onDragStartActivity(e: DragEvent, activityDisplayContext: ActivityDesignDisplayContext) {
const activityModel = activityDisplayContext.activityModel;
e.dataTransfer.setData("text", activityModel.activityId);
e.dataTransfer.effectAllowed = 'move';
}
onDragOverOutcome(e: DragEvent, outcome: string, sourceActivityId: string) {
e.preventDefault();
}
onDropOnOutcome(e: DragEvent, outcome: string, sourceActivityId) {
const targetActivityId = e.dataTransfer.getData('text');
if (sourceActivityId === targetActivityId)
return;
e.preventDefault();
const workflowModel = {...this.workflowModel};
const targetActivityDisplayContext = this.activityDisplayContexts[targetActivityId];
let connections = [...workflowModel.connections];
// Remove current connection where dropped activity is target.
connections = connections.filter(x => x.targetId !== targetActivityId)
// Check if there is already a connection.
const existingConnection = connections.find(x => x.sourceId == sourceActivityId && x.outcome == outcome);
if (existingConnection) {
// Try to find a matching outcome on the dropped activity to attach the existing activity to.
const newOutcome = targetActivityDisplayContext.outcomes.find(x => x == existingConnection.outcome) || targetActivityDisplayContext.outcomes[0];
existingConnection.sourceId = targetActivityId;
existingConnection.outcome = newOutcome;
// Create new connection.
const newConnection: ConnectionModel = {
sourceId: sourceActivityId,
targetId: targetActivityId,
outcome: outcome
};
workflowModel.connections = [...connections, newConnection];
}
else
{
// Create new connection.
const newConnection: ConnectionModel = {
sourceId: sourceActivityId,
targetId: targetActivityId,
outcome: outcome
};
workflowModel.connections = [...connections, newConnection];
}
this.updateWorkflowModel(workflowModel);
}
render() {
const renderedActivities = new Set<string>();
return (
<Host class="workflow-canvas flex-1 flex" ref={el => this.el = el}>
<div class="flex-1 text-gray-200">
<div class="p-10">
<div class="canvas select-none" ref={el => this.canvasElement = el}>
<div class="tree">
<ul>
<li>
<div class="inline-flex flex flex-col items-center">
<button id="start-button"
onClick={() => this.onAddButtonClick()}
type="button"
class="px-6 py-3 border border-transparent text-base leading-6 font-medium rounded-md text-white bg-green-600 hover:bg-green-500 focus:outline-none focus:border-green-700 focus:shadow-outline-green active:bg-green-700 transition ease-in-out duration-150">
Start
</button>
</div>
{this.renderTree(this.getRootActivities(), true, renderedActivities)}
</li>
</ul>
</div>
</div>
</div>
</div>
</Host>
);
}
renderTree(activities: Array<ActivityModel>, isRoot: boolean, renderedActivities: Set<string>): any {
const list = activities.filter(x => !renderedActivities.has(x.activityId));
const cssClass = isRoot ? "root" : undefined;
for (const activity of list)
renderedActivities.add(activity.activityId);
if (list.length == 0)
return null;
return (
<ul class={cssClass}>
{activities.map(x => {
const activityId = x.activityId;
const children = getChildActivities(this.workflowModel, activityId);
const displayContext = this.activityDisplayContexts[activityId];
return <li key={x.activityId}>
<div class="inline-flex flex flex-col items-center">
{isRoot ? this.renderOutcomeButton(`start-button-plus-${x.activityId}`, '', activityId, e => this.onOutcomeButtonClick(e, null, null)) : undefined}
{this.renderActivity(displayContext)}
{this.renderOutcomeButtons(displayContext)}
</div>
{children.length > 0 ? this.renderTree(children, false, renderedActivities) : undefined}
</li>;
})}
</ul>
);
}
renderOutcomeButtons(displayContext: ActivityDesignDisplayContext) {
const activityId = displayContext.activityModel.activityId;
return (
<div class="flex flex-row space-x-6">
{displayContext.outcomes.map(x => this.renderOutcomeButton(`${activityId}-${x}`, x, activityId, e => this.onOutcomeButtonClick(e, x, activityId)))}
</div>
);
}
renderOutcomeButton(id: string, outcome: string, activityId: string, clickHandler: (e: MouseEvent) => void) {
return (
<div class="my-6 flex flex-col items-center">
{outcome && outcome.length > 0 ? <div
class="mb-4 relative z-10 px-2.5 py-0.5 rounded-full text-xs font-medium leading-4 bg-cool-gray-100 text-cool-gray-800 capitalize">{outcome}</div> : undefined}
<a key={id} id={id} href="#" onClick={e => clickHandler(e)}
onDragOver={e => this.onDragOverOutcome(e, outcome, activityId)}
onDrop={e => this.onDropOnOutcome(e, outcome, activityId)}
>
<svg class="h-8 w-8 text-gray-400 hover:text-blue-500 cursor-pointer" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v3m0 0v3m0-3h3m-3 0H9m12 0a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
</a>
</div>
);
}
renderActivity(displayContext: ActivityDesignDisplayContext) {
return <elsa-designer-tree-activity displayContext={displayContext} draggable={true}
onDragStart={e => this.onDragStartActivity(e, displayContext)}
/>;
}
}

View file

@ -73,7 +73,7 @@ namespace Elsa.Server.Api.Endpoints.WorkflowDefinitions
from connection in request.Connections
let sourceActivity = request.Activities.FirstOrDefault(x => x.ActivityId == connection.SourceActivityId)
let targetActivity = request.Activities.FirstOrDefault(x => x.ActivityId == connection.TargetActivityId)
where sourceActivity != null && targetActivity != null
where sourceActivity != null && targetActivity != null && connection.Outcome is not null and not ""
select connection;
return validConnections;