diff --git a/src/designer/elsa-workflows-designer/src/components/shared/monaco-editor/monaco-editor.scss b/src/designer/elsa-workflows-designer/src/components/shared/monaco-editor/monaco-editor.scss index 16c349dd9..442da9aaf 100644 --- a/src/designer/elsa-workflows-designer/src/components/shared/monaco-editor/monaco-editor.scss +++ b/src/designer/elsa-workflows-designer/src/components/shared/monaco-editor/monaco-editor.scss @@ -9,10 +9,18 @@ left: 0; top: 0; width: 100%; - height: 100%; - max-height: 100% !important; + max-width: 100%; + min-height: 130px; + max-height: 200px !important; margin: 0; padding: 0; + --tw-border-opacity: 1; + border: 1px solid rgb(209 213 219 / var(--tw-border-opacity)); + -moz-appearance: textfield-multiline; + -webkit-appearance: textarea; + overflow: auto; + resize: both; + min-width: 100%; } .monaco-editor { diff --git a/src/designer/elsa-workflows-designer/src/components/shared/monaco-editor/monaco-editor.tsx b/src/designer/elsa-workflows-designer/src/components/shared/monaco-editor/monaco-editor.tsx index a2e9867d6..a057e0331 100644 --- a/src/designer/elsa-workflows-designer/src/components/shared/monaco-editor/monaco-editor.tsx +++ b/src/designer/elsa-workflows-designer/src/components/shared/monaco-editor/monaco-editor.tsx @@ -213,9 +213,9 @@ export class MonacoEditor { const padding = this.padding || 'pt-1.5 pl-1'; return ( -
this.container = el} class={`monaco-editor-container ${padding}`}/> +
this.container = el} class={`rounded-md monaco-editor-container ${padding}`}/> ) } diff --git a/src/designer/elsa-workflows-designer/src/modules/flowchart/flowchart.tsx b/src/designer/elsa-workflows-designer/src/modules/flowchart/flowchart.tsx index 29e81f8ba..dd55cfba9 100644 --- a/src/designer/elsa-workflows-designer/src/modules/flowchart/flowchart.tsx +++ b/src/designer/elsa-workflows-designer/src/modules/flowchart/flowchart.tsx @@ -257,7 +257,7 @@ export class FlowchartComponent implements ContainerActivityComponent { @Method() async import(root: Activity): Promise { - return await this.importInternal(root); + await this.importInternal(root); } @Method() @@ -275,6 +275,7 @@ export class FlowchartComponent implements ContainerActivityComponent { const currentActivityId = this.currentPath[this.currentPath.length - 1].activityId; const currentActivity = this.activityLookup[currentActivityId]; const parentActivity = this.activityLookup[parentActivityId] as Flowchart; + const parentActivityDescriptor = descriptorsStore.activityDescriptors.find(x => x.typeName == parentActivity.type); const indexInParent = currentActivity.activities?.findIndex(x => x == parentActivity); const portName = e.detail.port.name; @@ -385,6 +386,7 @@ export class FlowchartComponent implements ContainerActivityComponent { const currentNavigationItem = currentPath[currentPath.length - 1]; const currentPortName = currentNavigationItem?.portName; const currentScope = this.activityLookup[currentNavigationItem.activityId] as Activity; + const currentScopeDescriptor = this.getActivityDescriptor(currentScope.type); if (!!currentPortName) { @@ -445,6 +447,9 @@ export class FlowchartComponent implements ContainerActivityComponent { this.graph.freeze(); this.graph.fromJSON(model, {silent: false}); this.graph.unfreeze(); + + rebuildGraph(this.graph); + await this.scrollToStart(); }; private getFlowchartModel = (): FlowchartModel => { diff --git a/src/designer/elsa-workflows-designer/src/modules/flowchart/graph-factory.ts b/src/designer/elsa-workflows-designer/src/modules/flowchart/graph-factory.ts index 9987daa0b..f30c1b6a0 100644 --- a/src/designer/elsa-workflows-designer/src/modules/flowchart/graph-factory.ts +++ b/src/designer/elsa-workflows-designer/src/modules/flowchart/graph-factory.ts @@ -1,4 +1,5 @@ import {CellView, Graph, Node, Shape} from '@antv/x6'; +import { autoOrientConnections } from '../../utils/graph'; import './ports'; import {Activity} from "../../models"; import {Connection} from "./models"; @@ -276,5 +277,9 @@ export function createGraph( } }); + graph.on("node:moving", ({ node }) => { + autoOrientConnections(graph, node); + }); + return graph; }; diff --git a/src/designer/elsa-workflows-designer/src/modules/workflow-definitions/services/api.ts b/src/designer/elsa-workflows-designer/src/modules/workflow-definitions/services/api.ts index f7965e89f..ab50e6cb1 100644 --- a/src/designer/elsa-workflows-designer/src/modules/workflow-definitions/services/api.ts +++ b/src/designer/elsa-workflows-designer/src/modules/workflow-definitions/services/api.ts @@ -4,6 +4,9 @@ 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'; +import { adjustConnectionsInRequestModel, adjustConnectionsInResponseModel } from '../../../utils/graph'; +import { cloneDeep } from '@antv/x6/lib/util/object/object'; @Service() export class WorkflowDefinitionsApi { @@ -44,8 +47,15 @@ export class WorkflowDefinitionsApi { } async post(request: SaveWorkflowDefinitionRequest): Promise { + //TODO: Written as a workaround for different server and client models. + //To be deleted after the port model on backend is updated. + const requestClone = cloneDeep(request); + adjustConnectionsInRequestModel(requestClone.root); + const httpClient = await this.getHttpClient(); - const response = await httpClient.post('workflow-definitions', request); + const response = await httpClient.post('workflow-definitions', requestClone); + + adjustConnectionsInResponseModel(response.data.root); return response.data; } @@ -132,6 +142,11 @@ export class WorkflowDefinitionsApi { } const workflowDefinition = response.data; + + //TODO: Written as a workaround for different server and client models. + //To be deleted after the connection model on backend is updated. + adjustConnectionsInResponseModel(workflowDefinition.root); + return {workflowDefinition: workflowDefinition}; } diff --git a/src/designer/elsa-workflows-designer/src/utils/graph.ts b/src/designer/elsa-workflows-designer/src/utils/graph.ts new file mode 100644 index 000000000..b8b8745c3 --- /dev/null +++ b/src/designer/elsa-workflows-designer/src/utils/graph.ts @@ -0,0 +1,261 @@ +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 rebuildGraph(graph: Graph) { + graph.getNodes().forEach((node: Node) => { + autoOrientConnections(graph, node); + adjustPortMarkupByNode(node); + }); +} + +export function autoOrientConnections(graph: Graph, selectedNode: Node) { + const neighbors = graph.getNeighbors(selectedNode); + const selectedCenter = selectedNode.getBBox().center; + const nodeCouplesWithPositions = neighbors.map((neighbourNode) => { + return { + ...calculatePortPositionsOfNodeCouple(selectedCenter.x, selectedCenter.y, neighbourNode.getBBox().center.x, neighbourNode.getBBox().center.y), + selectedNode: selectedNode, + neighbourNode: neighbourNode + } + }); + nodeCouplesWithPositions.forEach(couple => updatePortsWithNewPositions(graph, couple.selectedNode, couple.portPositionOfSelectedNode, couple.neighbourNode as Node, couple.portPositionOfNeighbourNode)); +} + +function updatePortsWithNewPositions( + graph: Graph, + selectedNode: Node, + portPositionOfSelectedNode: "left" | "right" | "top" | "bottom", + neighbourNode: Node, + portPositionOfNeighbourNode: "left" | "right" | "top" | "bottom" +) { + updatePortsAndEdgeOfNodeCouple(graph, selectedNode, neighbourNode, portPositionOfSelectedNode, portPositionOfNeighbourNode); + updatePortsAndEdgeOfNodeCouple(graph, neighbourNode, selectedNode, portPositionOfNeighbourNode, portPositionOfSelectedNode); + + adjustPortMarkupByNode(selectedNode); + adjustPortMarkupByNode(neighbourNode); +} + +function calculatePositionsForInflexibleNode(sourceNode: Node, targetNodes: Node[]): +{ sourceNode: Node, sourceNodePosition: "left" | "right" | "top" | "bottom"; targetNode: Node, targetNodePosition: "left" | "right" | "top" | "bottom"; }[] { + const sourceNodeCenter = sourceNode.getBBox().center; + const dxAverageForTargetNodes = targetNodes.map(node => node.getBBox().center.x).reduce((a, b) => a + b, 0) / targetNodes.length; + const dyAverageForTargetNodes = targetNodes.map(node => node.getBBox().center.y).reduce((a, b) => a + b, 0) / targetNodes.length; + + const sourcePortWithNewPosition = { node: sourceNode, position: calculatePortPositionsOfNodeCouple(sourceNodeCenter.x, sourceNodeCenter.y, dxAverageForTargetNodes, dyAverageForTargetNodes).portPositionOfSelectedNode }; + return targetNodes.map((targetNode) => { + return { + sourceNode: sourcePortWithNewPosition.node, + sourceNodePosition: sourcePortWithNewPosition.position, + targetNode: targetNode, + targetNodePosition: calculatePortPositionsOfNodeCouple(sourceNode.getBBox().center.x, sourceNode.getBBox().center.y, targetNode.getBBox().center.x, targetNode.getBBox().center.y).portPositionOfNeighbourNode + } + }); +} + +function calculatePortPositionsOfNodeCouple(selectedNodeX: number, selectedNodeY: number, neighbourNodeX: number, neighbourNodeY: number): { portPositionOfSelectedNode: "left" | "right" | "top" | "bottom"; portPositionOfNeighbourNode: "left" | "right" | "top" | "bottom"; } { + const dx = selectedNodeX - neighbourNodeX; + const dy = selectedNodeY - neighbourNodeY; + if (dx >= 0 && dy >= 0) { + if (dx > dy) { + return {portPositionOfSelectedNode: "left", portPositionOfNeighbourNode: "right"}; + } else { + return {portPositionOfSelectedNode: "top", portPositionOfNeighbourNode: "bottom"}; + } + } else if (dx >= 0 && dy <= 0) { + if (dx > -dy) { + return {portPositionOfSelectedNode: "left", portPositionOfNeighbourNode: "right"}; + } else { + return {portPositionOfSelectedNode: "bottom", portPositionOfNeighbourNode: "top"}; + } + } else if (dx <= 0 && dy >= 0) { + if (-dx > dy) { + return {portPositionOfSelectedNode: "right", portPositionOfNeighbourNode: "left"}; + } else { + return {portPositionOfSelectedNode: "top", portPositionOfNeighbourNode: "bottom"}; + } + } else if (dx <= 0 && dy <= 0) { + if (dx > dy) { + return {portPositionOfSelectedNode: "right", portPositionOfNeighbourNode: "left"}; + } else { + return {portPositionOfSelectedNode: "bottom", portPositionOfNeighbourNode: "left"}; + } + } +} + +function updatePortsAndEdgeOfNodeCouple(graph: Graph, sourceNode: Node, targetNode: Node, portPositionOfSourceNode: string, portPositionOfTargetNode: string) { + const edge = graph.model.getEdges().find(({ data }) => data.source == sourceNode.id && data.target == targetNode.id); + + if (edge != null) { + const sourcePortOfConnection = edge.data.sourcePort; + if(isNewCalculationNeededForInflexiblePort(graph, sourceNode, sourcePortOfConnection)){ + const outgoingEdges = findOutgoingEdges(graph, sourceNode, sourcePortOfConnection); + const targetNodes = graph.getNodes().filter(node => outgoingEdges.map(edge => edge.data.target).includes(node.id)); + const nodeCouplesWithPositions = calculatePositionsForInflexibleNode(sourceNode, targetNodes); + + nodeCouplesWithPositions.forEach(couple => { + updatePortsAndEdge(graph, couple.sourceNode, couple.targetNode, couple.sourceNodePosition, couple.targetNodePosition); + }); + + return; + }; + + updatePortsAndEdge(graph, sourceNode, targetNode, portPositionOfSourceNode, portPositionOfTargetNode); + } +} + +function isNewCalculationNeededForInflexiblePort(graph: Graph, sourceNode: Node, inflexiblePort: any) { + const portName = getPortNameByPortId(inflexiblePort); + if (portName != null && portName != "Done") { + const outgoingEdges = findOutgoingEdges(graph, sourceNode, inflexiblePort); + if (outgoingEdges.length > 1) { + return true; + } + } + return false; +} + +function updatePortsAndEdge(graph: Graph, sourceNode: Node, targetNode: Node, newSourceNodePosition: string, newTargetNodePosition: string) { + const edge = graph.model.getEdges().find(({ data }) => data.source == sourceNode.id && data.target == targetNode.id); + + 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" && getPortNameByPortId(p.id) == getPortNameByPortId(edge.data.targetPort)); + + if (sourceNode.getPort(edge.data.sourcePort) == null || targetNode.getPort(edge.data.targetPort) == null || sourceNodePort?.position != newSourceNodePosition || targetNodePort?.position != newTargetNodePosition) { + 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 hasPortAnEdge(graph: Graph, port: PortManager.PortMetadata) { + return graph.getEdges().some(({ data }) => data.sourcePort == port.id || data.targetPort == port.id); +} + +function findMatchingPortForEdge(node: Node, position: string, portType: string, portName: string) { + return node.getPorts().find(p => p.position == position && p.type == portType && getPortNameByPortId(p.id) == portName); +} + +function getPortNameByPortId(portId: string) { + return portId.includes('_') ? (portId.split('_')[1] == 'null' ? null : portId.split('_')[1]) : portId; +} + +function findOutgoingEdges(graph: Graph, node: Node, portId: string): Edge[] { + return graph.model.getEdges().filter(({ data }) => data.source == node.id && getPortNameByPortId(data.sourcePort) == getPortNameByPortId(portId)); +} + +function updatePort(graph: Graph, node: Node, nodePort: PortManager.PortMetadata, newPortPosition: string) { + let newNodePortId = null; + if (nodePort?.position != newPortPosition) { + + if (!hasPortAnEdge(graph, nodePort)) { + node.removePort(nodePort); + } + + const matchingPort = findMatchingPortForEdge(node, newPortPosition, nodePort.type, getPortNameByPortId(nodePort.id)); + + if (matchingPort == null) { + newNodePortId = createNewPort(nodePort, node, newPortPosition); + } + else { + newNodePortId = matchingPort.id; + } + } + return newNodePortId; +} + +function createNewPort(nodePort: PortManager.PortMetadata, node: Node, newPortPosition: string) { + const newNodePortId = uuid() + '_' + getPortNameByPortId(nodePort.id); + + node.addPort({ + ...nodePort, + group: newPortPosition, + id: newNodePortId, + position: newPortPosition, + type: nodePort.type + }); + return newNodePortId; +} + +export function adjustPortMarkupByNode(node: Node) { + node.getPorts().forEach(port => { + if (port.type == 'out') { + node.setPortProp(port.id, "attrs", { + circle: { + r: 5, + magnet: true, + stroke: '#fff', + strokeWidth: 2, + fill: '#3c82f6', + }, + text: { + fontSize: 12, + fill: '#888', + }, + }); + } + else { + node.setPortProp(port.id, "attrs", { + circle: { + r: 5, + 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 + }; +} + +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); + }); + } + let activitiesWithConnections = root.activities?.filter(act => act.body?.connections?.length > 0); + activitiesWithConnections.forEach(activity => { + adjustConnectionsInRequestModel(activity.body); + }); +} + +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; + }); + } + let activitiesWithConnections = root.activities?.filter(act => act.body?.connections?.length > 0); + activitiesWithConnections.forEach(activity => { + adjustConnectionsInResponseModel(activity.body); + }); +} \ No newline at end of file