Merge remote-tracking branch 'origin/v3' into v3

This commit is contained in:
Sipke Schoorstra 2022-11-28 19:12:07 +01:00
commit 90c9ed40d3
15 changed files with 325 additions and 83 deletions

View file

@ -12,7 +12,7 @@
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"applicationUrl": "https://localhost:7072;http://localhost:5072",
"applicationUrl": "https://localhost:5001;http://localhost:5072",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}

View file

@ -68,7 +68,7 @@ export namespace Components {
}
interface ElsaCanvas {
"addActivity": (args: AddActivityArgs) => Promise<Activity>;
"autoLayout": () => Promise<void>;
"autoLayout": (direction: "TB" | "BT" | "LR" | "RL") => Promise<void>;
"exportGraph": () => Promise<Activity>;
"getRootComponent": () => Promise<ContainerActivityComponent>;
"importGraph": (root: Activity) => Promise<void>;
@ -121,7 +121,7 @@ export namespace Components {
}
interface ElsaFlowchart {
"addActivity": (args: AddActivityArgs) => Promise<Activity>;
"autoLayout": () => Promise<void>;
"autoLayout": (direction: "TB" | "BT" | "LR" | "RL") => Promise<void>;
"export": () => Promise<Activity>;
"getCurrentLevel": () => Promise<Activity>;
"getGraph": () => Promise<Graph>;
@ -267,7 +267,7 @@ export namespace Components {
"workflowDefinition"?: WorkflowDefinition;
}
interface ElsaWorkflowDefinitionEditorToolbar {
"autoLayout": () => Promise<void>;
"autoLayout": (direction: "TB" | "BT" | "LR" | "RL") => Promise<void>;
"zoomToFit": () => Promise<void>;
}
interface ElsaWorkflowDefinitionEditorToolbox {
@ -955,7 +955,7 @@ declare namespace LocalJSX {
"workflowDefinition"?: WorkflowDefinition;
}
interface ElsaWorkflowDefinitionEditorToolbar {
"autoLayout"?: () => Promise<void>;
"autoLayout"?: (direction: "TB" | "BT" | "LR" | "RL") => Promise<void>;
"zoomToFit"?: () => Promise<void>;
}
interface ElsaWorkflowDefinitionEditorToolbox {

View file

@ -67,8 +67,8 @@ export class Canvas {
}
@Method()
public async autoLayout(): Promise<void> {
return await this.root.autoLayout();
public async autoLayout(direction: "TB" | "BT" | "LR" | "RL"): Promise<void> {
return await this.root.autoLayout(direction);
}
@Method()

View file

@ -50,7 +50,6 @@ export class Checkbox {
}
private onExpressionChanged = (e: CustomEvent<ExpressionChangedArs>) => {
debugger;
this.inputContext.inputChanged(e.detail.expression, e.detail.syntax);
}
}

View file

@ -4,6 +4,7 @@ import {Container, Service} from "typedi"
import {ActivityNodeHandler, CreateUINodeContext} from "./activity-node-handler";
import {PortProviderContext, PortProviderRegistry} from "../../services";
import {PortMode} from "../../models";
import {v4 as uuid} from 'uuid';
@Service()
export class DefaultNodeHandler implements ActivityNodeHandler {
@ -27,27 +28,31 @@ export class DefaultNodeHandler implements ActivityNodeHandler {
if (outPorts.length == 1)
outPorts[0].displayName = null;
const inPortModels = inPorts.map(x => ({
id: x.name,
group: 'in',
const leftPortModels = inPorts.map((x) => ({
id: uuid() + '_' + x.name,
group: 'left',
attrs: !!x.displayName ? {
text: {
text: x.displayName
}
} : null
},
} : null,
type:'in',
position:'left'
}));
const outPortModels = outPorts.map(x => ({
id: x.name,
group: 'out',
const rightPortModels = outPorts.map((x) => ({
id: uuid() + '_' + x.name,
group: 'right',
attrs: {
text: {
text: x.displayName
}
}
},
},
type: 'out',
position: 'right'
}));
const portModels = [...inPortModels, ...outPortModels];
const portModels = [...leftPortModels, ...rightPortModels];
return {
id: activity.id,

View file

@ -20,7 +20,8 @@ import PositionEventArgs = NodeView.PositionEventArgs;
import FromJSONData = Model.FromJSONData;
import PointLike = Point.PointLike;
import {generateUniqueActivityName} from "../../utils/generate-activity-name";
import {DagreLayout, OutNode} from '@antv/layout';
import { DagreLayout, OutNode} from '@antv/layout';
import { adjustPortMarkupByNode, rebuildGraph } from '../../utils/graph';
import {WorkflowDefinition} from "../workflow-definitions/models/entities";
import FlowchartTunnel, {FlowchartState} from "./state";
import WorkflowDefinitionTunnel, {WorkflowDefinitionState} from "../../state/workflow-definition-state";
@ -125,11 +126,10 @@ export class FlowchartComponent implements ContainerActivityComponent {
}
@Method()
async autoLayout() {
async autoLayout(direction: "TB" | "BT" | "LR" | "RL") {
const dagreLayout = new DagreLayout({
type: 'dagre',
rankdir: 'TB',
rankdir: direction,
align: 'UL',
ranksep: 30,
nodesep: 15,
@ -161,8 +161,7 @@ export class FlowchartComponent implements ContainerActivityComponent {
this.updateActivity({id: activity.id, originalId: activity.id, activity: activity});
});
await this.import(this.activity);
await this.scrollToStart();
this.import(this.activity);
}
@Method()
@ -191,6 +190,8 @@ export class FlowchartComponent implements ContainerActivityComponent {
const node = this.nodeFactory.createNode(descriptor, activity, sx, sy);
graph.addNode(node, {merge: true});
adjustPortMarkupByNode(graph.getNodes().find(n => n.id == node.id));
await this.updateModel();
return activity;
}
@ -257,7 +258,11 @@ export class FlowchartComponent implements ContainerActivityComponent {
@Method()
async import(root: Activity): Promise<void> {
return await this.importInternal(root);
await this.importInternal(root);
rebuildGraph(this.graph);
await this.scrollToStart();
}
@Method()

View file

@ -1,11 +1,10 @@
import {CellView, Graph, Node, Shape} from '@antv/x6';
import { autoOrientPortsAndEdges } from '../../utils/graph';
import './ports';
import {Activity} from "../../models";
import {Connection} from "./models";
import descriptorsStore from "../../data/descriptors-store";
import {generateUniqueActivityName} from "../../utils/generate-activity-name";
import {Hash} from "../../utils";
import {createActivityLookup} from "../../services";
export function createGraph(
container: HTMLElement,
@ -69,8 +68,8 @@ export function createGraph(
router: {
name: 'manhattan',
args: {
startDirections: ['right'],
endDirections: ['left'],
startDirections: ['top','right','left','bottom'],
endDirections: ['top','right','left','bottom'],
},
},
// router: {
@ -90,23 +89,34 @@ export function createGraph(
snap: {
radius: 20,
},
validateMagnet({magnet}) {
return magnet.getAttribute('port-group') !== 'in'
validateMagnet({view, magnet}) {
const node = view.cell as Node;
const sourcePort = node.getPort(magnet.getAttribute('port'));
return sourcePort.type !== 'in'
},
validateConnection({sourceView, targetView, sourceMagnet, targetMagnet}) {
if (!sourceMagnet || sourceMagnet.getAttribute('port-group') === 'in') {
if(!sourceMagnet || !targetMagnet) {
return false;
}
const sourceNode = sourceView.cell as Node;
const sourcePort = sourceNode.getPort(sourceMagnet.getAttribute('port'));
const targetNode = targetView.cell as Node;
const targetPort = targetNode.getPort(targetMagnet.getAttribute('port'));
if (sourcePort.type === 'in') {
return false
}
if (!targetMagnet || targetMagnet.getAttribute('port-group') !== 'in') {
if (targetPort.type !== 'in') {
return false
}
const portId = targetMagnet.getAttribute('port')!
const node = targetView.cell as Node
const port = node.getPort(portId)
return !(port && port.connected);
return !(targetPort && targetPort.connected);
},
createEdge() {
return graph.createEdge({
@ -276,5 +286,9 @@ export function createGraph(
}
});
graph.on("node:moving", ({ node }) => {
autoOrientPortsAndEdges(graph, node);
});
return graph;
};
};

View file

@ -1,35 +1,4 @@
import {Graph, Line, Path} from "@antv/x6";
import {toResult} from "@antv/x6/lib/registry/port-layout/util";
Graph.registerPortLayout('dynamicOut', (portsPositionArgs, elemBBox) => {
return portsPositionArgs.map((_, index) => {
const portCount = portsPositionArgs.length;
const ratio = (index + 0.5) / portCount;
const p1 = portCount <= 3 ? elemBBox.getTopRight() : elemBBox.getBottomLeft();
const p2 = portCount <= 3 ? elemBBox.getBottomRight() : elemBBox.getBottomRight();
const line = new Line(p1, p2)
const p = line.pointAt(ratio);
return toResult(p.round(), 0, {});
});
});
Graph.registerPortLayout('dynamicIn', (portsPositionArgs, elemBBox) => {
return portsPositionArgs.map((_, index) => {
const portCount = portsPositionArgs.length;
const ratio = (index + 0.5) / portCount;
const p1 = portCount <= 3 ? elemBBox.getTopLeft() : elemBBox.getTopLeft();
const p2 = portCount <= 3 ? elemBBox.getBottomLeft() : elemBBox.getTopRight();
const line = new Line(p1, p2)
const p = line.pointAt(ratio);
return toResult(p.round(), 0, {});
});
});
Graph.registerConnector(
'elsa-connector',

View file

@ -114,8 +114,8 @@ export class ActivityNodeShape extends Shape.HTML {
ActivityNodeShape.config({
ports: {
groups: {
in: {
position: 'dynamicIn',
left: {
position: 'left',
attrs: {
circle: {
r: 6,
@ -135,15 +135,57 @@ ActivityNodeShape.config({
},
},
},
out: {
position: 'dynamicOut',
right: {
position: 'right',
attrs: {
circle: {
r: 6,
magnet: true,
stroke: '#fff',
stroke: '#3c82f6',
strokeWidth: 2,
fill: '#3c82f6',
fill: '#fff',
},
text: {
fontSize: 12,
fill: '#888',
},
},
label: {
position: {
name: 'outside',
},
},
},
top: {
position: 'top',
attrs: {
circle: {
r: 6,
magnet: true,
stroke: '#3c82f6',
strokeWidth: 2,
fill: '#fff',
},
text: {
fontSize: 12,
fill: '#888',
},
},
label: {
position: {
name: 'outside',
},
},
},
bottom: {
position: 'bottom',
attrs: {
circle: {
r: 6,
magnet: true,
stroke: '#3c82f6',
strokeWidth: 2,
fill: '#fff',
},
text: {
fontSize: 12,

View file

@ -114,7 +114,6 @@ export class WorkflowDefinitionEditor {
await this.updateWorkflowDefinition(workflowDefinition);
await this.canvas.importGraph(workflowDefinition.root);
await this.eventBus.emit(WorkflowEditorEventTypes.WorkflowDefinition.Imported, this, {workflowDefinition});
await this.canvas.scrollToStart();
}
// Updates the workflow definition without importing it into the designer.
@ -229,9 +228,9 @@ export class WorkflowDefinitionEditor {
});
};
private onZoomToFit = async () => await this.canvas.zoomToFit()
private onZoomToFit = async () => await this.canvas.zoomToFit();
private onAutoLayout = async () => await this.canvas.autoLayout()
private onAutoLayout = async (direction: "TB" | "BT" | "LR" | "RL") => await this.canvas.autoLayout(direction);
private onActivityUpdated = async (e: CustomEvent<ActivityUpdatedArgs>) => {
await this.canvas.updateActivity({

View file

@ -8,13 +8,16 @@ export class Toolbar {
public zoomToFit: () => Promise<void>;
@Prop()
public autoLayout: () => Promise<void>;
public autoLayout: (direction: "TB" | "BT" | "LR" | "RL") => Promise<void>;
render() {
return (
<div class="elsa-panel-toolbar flex justify-center absolute border-b border-gray-200 top-0 px-1 pl-4 pb-2 text-sm bg-white z-10 space-x-2">
<button class="btn btn-primary" onClick={this.autoLayout}>
Auto-Layout
<button class="btn btn-primary" onClick={() => this.autoLayout("LR")}>
Auto-Layout (LR)
</button>
<button class="btn btn-primary" onClick={() => this.autoLayout("TB")}>
Auto-Layout (TB)
</button>
<button onClick={this.zoomToFit}class="btn btn-primary">
Zoom to fit

View file

@ -4,6 +4,7 @@ import {Activity, PagedList, Variable, VersionedEntity, VersionOptions} from "..
import {Service} from "typedi";
import {ElsaApiClientProvider} from "../../../services";
import {AxiosResponse} from "axios";
import { adjustConnectionsInRequestModel, adjustConnectionsInResponseModel } from '../../../utils/graph';
@Service()
export class WorkflowDefinitionsApi {
@ -44,6 +45,10 @@ export class WorkflowDefinitionsApi {
}
async post(request: SaveWorkflowDefinitionRequest): Promise<WorkflowDefinition> {
//TODO: Written as a workaround for different server and client models.
//To be deleted after the port model on backend is updated.
adjustConnectionsInRequestModel(request.root);
const httpClient = await this.getHttpClient();
const response = await httpClient.post<WorkflowDefinition>('workflow-definitions', request);
return response.data;
@ -57,6 +62,11 @@ export class WorkflowDefinitionsApi {
const queryStringText = serializeQueryString(queryString);
const httpClient = await this.getHttpClient();
const response = await httpClient.get<WorkflowDefinition>(`workflow-definitions/${request.definitionId}${queryStringText}`);
//TODO: Written as a workaround for different server and client models.
//To be deleted after the connection model on backend is updated.
adjustConnectionsInResponseModel(response.data.root);
return response.data;
}
@ -162,7 +172,6 @@ export class WorkflowDefinitionsApi {
private getHttpClient = async () => await this.provider.getHttpClient();
}
export interface SaveWorkflowDefinitionRequest {
definitionId: string;
version?: number;
@ -261,4 +270,4 @@ export interface UnpublishManyWorkflowDefinitionResponse {
retracted: string[];
notPublished: string[];
notFound: string[];
}
}

View file

@ -54,3 +54,4 @@ export class WorkflowDefinitionManager {
};
}

View file

@ -10,7 +10,7 @@ export interface ContainerActivityComponent {
export(): Promise<Activity>
import(root: Activity): Promise<void>;
zoomToFit(): Promise<void>;
autoLayout(): Promise<void>;
autoLayout(direction: "TB" | "BT" | "LR" | "RL"): Promise<void>;
scrollToStart(): Promise<void>;
reset(): Promise<void>;
getCurrentLevel(): Promise<Activity>;

View file

@ -0,0 +1,196 @@
import { Edge, Graph, Node } from "@antv/x6";
import { PortManager } from "@antv/x6/lib/model/port";
import { Connection } from "../modules/flowchart/models";
import {v4 as uuid} from 'uuid';
import { Activity } from "../models";
export function adjustPortMarkupByNode(node: Node) {
node.getPorts().forEach(port => {
if(port.type == 'out'){
node.setPortProp(port.id, "attrs", {
circle: {
r: 6,
magnet: true,
stroke: '#fff',
strokeWidth: 2,
fill: '#3c82f6',
},
text: {
fontSize: 12,
fill: '#888',
},
});
}
else {
node.setPortProp(port.id, "attrs", {
circle: {
r: 6,
magnet: true,
stroke: '#3c82f6',
strokeWidth: 2,
fill: '#fff',
},
text: {
fontSize: 12,
fill: '#888',
},
});
}
});
}
function createEdge(connection: Connection): Edge.Metadata {
return {
shape: 'elsa-edge',
zIndex: -1,
data: connection,
source: connection.source,
target: connection.target,
sourcePort: connection.sourcePort,
targetPort: connection.targetPort
};
}
function hasPortAnEdge(graph: Graph, port: PortManager.PortMetadata) {
return graph.getEdges().some(({data}) => data.sourcePort == port.id || data.targetPort == port.id);
}
function findMatchingPortForEdge(node: Node<Node.Properties>, position: string, portType: string, portName: string) {
return node.getPorts().find(p => p.position == position && p.type == portType && p.attrs?.text?.text == portName);
}
function getPortNameByPortId(portId: string) {
return portId.split('_')[1] == 'null' ? null : portId.split('_')[1];
}
function updatePortsAndEdges(
graph: Graph,
selectedNode: Node,
newSelectedNodePosition: "left" | "right" | "top" | "bottom",
neighbourNode: Node,
newNeighbourNodePosition: "left" | "right" | "top" | "bottom"
)
{
updatePortsAndEdgesOfNodeCouple(graph, selectedNode, neighbourNode, newSelectedNodePosition, newNeighbourNodePosition);
updatePortsAndEdgesOfNodeCouple(graph, neighbourNode, selectedNode, newNeighbourNodePosition, newSelectedNodePosition);
adjustPortMarkupByNode(selectedNode);
adjustPortMarkupByNode(neighbourNode);
}
function updatePortsAndEdgesOfNodeCouple(graph: Graph, sourceNode: Node<Node.Properties>, targetNode: Node<Node.Properties>, newSourceNodePosition: string, newTargetNodePosition: string)
{
const edge = graph.model.getEdges().find(({ data }) => data.source == sourceNode.id && data.target == targetNode.id);
if (edge != null) {
const sourceNodePort = sourceNode.getPort(edge.data.sourcePort) ?? sourceNode.getPorts().find(p => p.type == "out" && getPortNameByPortId(p.id) == getPortNameByPortId(edge.data.sourcePort));
const targetNodePort = targetNode.getPort(edge.data.targetPort) ?? targetNode.getPorts().find(p => p.type == "in");
graph.removeEdge(edge);
const newSourceNodePortId = updatePort(graph, sourceNode, sourceNodePort, newSourceNodePosition);
const newTargetNodePortId = updatePort(graph, targetNode, targetNodePort, newTargetNodePosition);
graph.addEdge(createEdge({
source: sourceNode.id,
target: targetNode.id,
sourcePort: newSourceNodePortId ?? sourceNodePort.id,
targetPort: newTargetNodePortId ?? targetNodePort.id
}));
}
}
function updatePort(graph: Graph, node: Node<Node.Properties>, nodePort: PortManager.PortMetadata, newNodePosition: string)
{
let newNodePortId = null;
if (nodePort.position != newNodePosition) {
if (!hasPortAnEdge(graph, nodePort)) {
node.removePort(nodePort);
}
const matchingPort = findMatchingPortForEdge(node, newNodePosition, nodePort.type, getPortNameByPortId(nodePort.id));
if(matchingPort == null){
newNodePortId = createNewPort(nodePort, node, newNodePosition);
}
else{
newNodePortId = matchingPort.id;
}
}
return newNodePortId;
}
function createNewPort(nodePort: PortManager.PortMetadata, node: Node<Node.Properties>, newNodePosition: string) {
const newNodePortId = uuid() + '_' + getPortNameByPortId(nodePort.id);
node.addPort({
...nodePort,
group: newNodePosition,
id: newNodePortId,
position: newNodePosition,
type: nodePort.type
});
return newNodePortId;
}
export function autoOrientPortsAndEdges(graph: Graph, selectedNode: Node)
{
const selectedCenter = selectedNode.getBBox().center;
const neighbors = graph.getNeighbors(selectedNode);
neighbors.forEach((neighbourNode) => {
const neighborCenter = neighbourNode.getBBox().center;
const dx = selectedCenter.x - neighborCenter.x;
const dy = selectedCenter.y - neighborCenter.y;
if (dx >= 0 && dy >= 0) {
if (dx > dy) {
updatePortsAndEdges(graph, selectedNode, "left", neighbourNode as Node, "right");
} else {
updatePortsAndEdges(graph, selectedNode, "top", neighbourNode as Node, "bottom");
}
} else if (dx >= 0 && dy <= 0) {
if (dx > -dy) {
updatePortsAndEdges(graph, selectedNode, "left", neighbourNode as Node, "right");
} else {
updatePortsAndEdges(graph, selectedNode, "bottom", neighbourNode as Node, "top");
}
} else if (dx <= 0 && dy >= 0) {
if (-dx > dy) {
updatePortsAndEdges(graph, selectedNode, "right", neighbourNode as Node, "left");
} else {
updatePortsAndEdges(graph, selectedNode, "top", neighbourNode as Node, "bottom");
}
} else if (dx <= 0 && dy <= 0) {
if (dx > dy) {
updatePortsAndEdges(graph, selectedNode, "right", neighbourNode as Node, "left");
} else {
updatePortsAndEdges(graph, selectedNode, "bottom", neighbourNode as Node, "left");
}
}
});
}
export function rebuildGraph(graph: Graph)
{
graph.getNodes().forEach((node: Node<Node.Properties>) => {
autoOrientPortsAndEdges(graph, node);
});
}
export function adjustConnectionsInRequestModel(root: Activity) {
if(root.connections.length > 0){
root.connections.forEach((connection: { sourcePort: string; targetPort: string; }) => {
connection.sourcePort = getPortNameByPortId(connection.sourcePort);
connection.targetPort = getPortNameByPortId(connection.targetPort);
});
}
}
export function adjustConnectionsInResponseModel(root: Activity) {
if(root.connections.length > 0){
root.connections.forEach((connection: { sourcePort: string; targetPort: string; }) => {
connection.sourcePort = uuid() + '_' + connection.sourcePort;
connection.targetPort = uuid() + '_' + connection.targetPort;
});
}
}