Skip to content

Commit

Permalink
Workflow Diagram: "edge merging" (new edge creation) (#2160)
Browse files Browse the repository at this point in the history
* workflow diagram: add basic dagre layout

* workflow-dialog: create dummy drag handlers

* workflow diagram: create new edge

* styling for interactions

* workflow-diagram: update drop target validation rules

* style tweak

* workflow-diagram: prevent invalid edges being created

Very suboptimal implementation, but it works

* tidy

* workfklow-diagram: fix edge positions

* workflow-diagram: tweak layout to look more like it did

* workflow-diagram: slightly better edge connection styling

* workflow-diagram: rough experiment with bezier edges

* edit: stub in path deletion button

* workflow-diagram: fixes to dagre layout

* workflow-diagram: tweak zoom padding

* workflow-diagram: give error feedback on hover

* workflow-diagram: adjust error styling

* workflow-diagram: add controls with autofit toggle

* workflow-diagram: tweak highlight styles

* workflow-diagram: change error message

* workflow-diagram: styling

* workflow-diagram: remove old stuff

* workflow-diagram: fix an issue where the edge icon prevented the handle getting a click

* workflow-diagram: move controls to bottom left

* workflow-diagram: simplify and improve error reporting

* delete edges (still needs tests & permissions)

* tests for edge deletion

* center the buttons

* handle style, cleanup assigns

* remove dupe

* unbreak my heart

* add dagre vendor file, tidy dependencies (#2178)

---------

Co-authored-by: Taylor Downs <[email protected]>
  • Loading branch information
josephjclark and taylordowns2000 authored Jun 5, 2024
1 parent b1117bb commit 18d3d6b
Show file tree
Hide file tree
Showing 22 changed files with 5,022 additions and 140 deletions.
1 change: 1 addition & 0 deletions .prettierignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
assets/vendor
32 changes: 27 additions & 5 deletions assets/js/workflow-diagram/WorkflowDiagram.tsx
Original file line number Diff line number Diff line change
@@ -1,17 +1,21 @@
import React, { useCallback, useEffect, useRef, useState } from 'react';
import ReactFlow, {
Controls,
ControlButton,
NodeChange,
ReactFlowInstance,
ReactFlowProvider,
applyNodeChanges,
} from 'reactflow';
import { useStore, StoreApi } from 'zustand';
import { shallow } from 'zustand/shallow';
import { ViewfinderCircleIcon, XMarkIcon } from '@heroicons/react/24/outline';

import layout from './layout';
import nodeTypes from './nodes';
import edgeTypes from './edges';
import usePlaceholders from './usePlaceholders';
import useConnect from './useConnect';
import fromWorkflow from './util/from-workflow';
import throttle from './util/throttle';
import updateSelectionStyles from './util/update-selection';
Expand Down Expand Up @@ -39,6 +43,8 @@ export default React.forwardRef<HTMLElement, WorkflowDiagramProps>(

const [model, setModel] = useState<Flow.Model>({ nodes: [], edges: [] });

const [autofit, setAutofit] = useState<boolean>(true);

const updateSelection = useCallback(
(id?: string | null) => {
id = id || null;
Expand Down Expand Up @@ -96,10 +102,12 @@ export default React.forwardRef<HTMLElement, WorkflowDiagramProps>(

if (layoutId) {
chartCache.current.lastLayout = layoutId;
layout(newModel, setModel, flow, 300).then(positions => {
// Note we don't update positions until the animation has finished
chartCache.current.positions = positions;
});
layout(newModel, setModel, flow, { duration: 300, autofit }).then(
positions => {
// Note we don't update positions until the animation has finished
chartCache.current.positions = positions;
}
);
} else {
// If layout is id, ensure nodes have positions
// This is really only needed when there's a single trigger node
Expand Down Expand Up @@ -184,6 +192,8 @@ export default React.forwardRef<HTMLElement, WorkflowDiagramProps>(
}
}, [flow, ref]);

const connectHandlers = useConnect(model, setModel, store);

return (
<ReactFlowProvider>
<ReactFlow
Expand All @@ -201,7 +211,19 @@ export default React.forwardRef<HTMLElement, WorkflowDiagramProps>(
deleteKeyCode={null}
fitView
fitViewOptions={{ padding: FIT_PADDING }}
/>
{...connectHandlers}
>
<Controls showInteractive={false} position="bottom-left">
<ControlButton
onClick={() => {
setAutofit(!autofit);
}}
title="Automatically fit view"
>
<ViewfinderCircleIcon style={{ opacity: autofit ? 1 : 0.4 }} />
</ControlButton>
</Controls>
</ReactFlow>
</ReactFlowProvider>
);
}
Expand Down
15 changes: 15 additions & 0 deletions assets/js/workflow-diagram/components/ErrorMessage.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import React from 'react';

import { ExclamationCircleIcon } from '@heroicons/react/24/outline';

const ErrorMessage: React.FC<React.ComponentProps<any>> = ({ children }) => {
console.log(children);
return (
<p className="line-clamp-2 align-left text-xs text-red-500 flex items-center">
<ExclamationCircleIcon className="mr-1 w-5" />
{children ?? 'An error occurred'}
</p>
);
};

export default ErrorMessage;
35 changes: 35 additions & 0 deletions assets/js/workflow-diagram/components/PathButton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import React from 'react';
import { Handle } from 'reactflow';
import { LinkIcon } from '@heroicons/react/24/outline';

function PathButton() {
return (
<Handle
type="source"
style={{
position: 'relative',
height: 24,
width: 'auto',

// These values come from tailwind but have to be set on styles to override reactflow stuff
borderRadius: '0.5rem',
borderWidth: '0',

// override react flow stuff
transform: 'translate(0,0)',
left: 'auto',
top: 'auto',
cursor: 'pointer',
}}
className="transition duration-150 ease-in-out pointer-events-auto rounded-lg
!bg-indigo-600 hover:!bg-indigo-500 py-1 px-2 text-[0.8125rem] font-semibold leading-5 text-white"
>
<LinkIcon
className="inline h-4 w-4"
style={{ marginTop: '-6px', pointerEvents: 'none' }}
/>
</Handle>
);
}

export default PathButton;
5 changes: 3 additions & 2 deletions assets/js/workflow-diagram/components/PlusButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,9 @@ function PlusButton() {
return (
<button
name="add-node"
className="transition duration-150 ease-in-out pointer-events-auto rounded-full
bg-indigo-600 py-1 px-4 text-[0.8125rem] font-semibold leading-5 text-white hover:bg-indigo-500"
className="transition duration-150 ease-in-out pointer-events-auto rounded-lg
bg-indigo-600 py-1 px-2 text-[0.8125rem] font-semibold leading-5 text-white
hover:bg-indigo-500 mr-1"
>
<svg
xmlns="http://www.w3.org/2000/svg"
Expand Down
6 changes: 1 addition & 5 deletions assets/js/workflow-diagram/constants.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,3 @@
export const FIT_DURATION = 180;

export const FIT_PADDING = 0.3;

export const NODE_WIDTH = 180;

export const NODE_HEIGHT = 40;
export const FIT_PADDING = 0.2;
24 changes: 24 additions & 0 deletions assets/js/workflow-diagram/edges/Connection.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import React from 'react';
import { EDGE_COLOR_SELECTED } from '../styles';
import { BezierEdge } from 'reactflow';

export default props => {
const { fromX, fromY, toX, toY } = props;
return (
<BezierEdge
id="tmp"
sourceX={fromX}
sourceY={fromY}
targetX={toX}
targetY={toY}
animated={true}
zIndex={-1}
style={{
stroke: EDGE_COLOR_SELECTED,
strokeWidth: 4,
strokeDasharray: '4, 4',
opacity: 0.7,
}}
/>
);
};
12 changes: 10 additions & 2 deletions assets/js/workflow-diagram/edges/Edge.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
import React, { FC } from 'react';
import { SmoothStepEdge, EdgeProps, EdgeLabelRenderer } from 'reactflow';
import {
SmoothStepEdge,
BezierEdge,
Edge,
EdgeProps,
EdgeLabelRenderer,
} from 'reactflow';
import { labelStyles } from '../styles';

const CustomEdge: FC<EdgeProps> = props => {
Expand All @@ -12,7 +18,9 @@ const CustomEdge: FC<EdgeProps> = props => {
const labelY = (sourceY + targetY) / 2;
return (
<>
<SmoothStepEdge {...stepEdgeProps} />
{/* Curvature does nothing?? */}
<BezierEdge {...stepEdgeProps} pathOptions={{ curvature: 0 }} />
{/* <SmoothStepEdge {...stepEdgeProps} pathOptions={{ borderRadius: 500 }} /> */}
{label && (
<EdgeLabelRenderer>
<div
Expand Down
71 changes: 38 additions & 33 deletions assets/js/workflow-diagram/layout.ts
Original file line number Diff line number Diff line change
@@ -1,44 +1,46 @@
import { stratify, tree } from 'd3-hierarchy';
import Dagre from '../../vendor/dagre';
import { timer } from 'd3-timer';
import { getRectOfNodes, Node, ReactFlowInstance } from 'reactflow';
import { getRectOfNodes, ReactFlowInstance } from 'reactflow';

import { FIT_PADDING, NODE_HEIGHT, NODE_WIDTH } from './constants';
import { FIT_PADDING } from './constants';
import { Flow, Positions } from './types';
import { styleItem } from './styles';

const layout = tree<Node>()
// the node size configures the spacing between the nodes ([width, height])
.nodeSize([200, 200])
// this is needed for creating equal space between all nodes
.separation(() => 2);
export type LayoutOpts = { duration: number | false; autofit: boolean };

const calculateLayout = async (
model: Flow.Model,
update: (newModel: Flow.Model) => any,
flow: ReactFlowInstance,
duration: number | false = 500
options: LayoutOuts = {}
): Promise<Positions> => {
const { nodes, edges } = model;
const { duration } = options;

const g = new Dagre.graphlib.Graph().setDefaultEdgeLabel(() => ({}));
g.setGraph({
rankdir: 'TB',
// nodesep: 400,
// edgesep: 200,
// ranksep: 400,
});

edges.forEach(edge => g.setEdge(edge.source, edge.target));
nodes.forEach(node =>
g.setNode(node.id, { ...node, width: 350, height: 200 })
);

Dagre.layout(g, { disableOptimalOrderHeuristic: true });

const newModel = {
nodes: nodes.map(node => {
const { x, y, width, height } = g.node(node.id);

return { ...node, position: { x, y }, width, height };
}),
edges,
};

const hierarchy = stratify<Node>()
.id(d => d.id)
// get the id of each node by searching through the edges
// this only works if every node has one connection
.parentId(d => edges.find(e => e.target === d.id)?.source)(nodes);

// run the layout algorithm with the hierarchy data structure
const root = layout(hierarchy);
const newNodes = root.descendants().map(d => ({
...d.data,
position: { x: d.x, y: d.y },
// Ensure nodes have a width/height so that we can later do a fit to bounds
width: NODE_WIDTH,
height: NODE_HEIGHT,
}));

const newModel = { nodes: newNodes, edges };

const finalPositions = newNodes.reduce((obj, next) => {
const finalPositions = newModel.nodes.reduce((obj, next) => {
obj[next.id] = next.position;
return obj;
}, {} as Positions);
Expand All @@ -47,7 +49,7 @@ const calculateLayout = async (

// If the old model had no positions, this is a first load and we should not animate
if (hasOldPositions && duration) {
await animate(model, newModel, update, flow, duration);
await animate(model, newModel, update, flow, options);
} else {
update(newModel);
}
Expand All @@ -62,8 +64,9 @@ export const animate = (
to: Flow.Model,
setModel: (newModel: Flow.Model) => void,
flowInstance: ReactFlowInstance,
duration = 500
options: LayoutOpts
) => {
const { duration = 500, autofit = true } = options;
return new Promise<void>(resolve => {
const transitions = to.nodes.map(node => {
// We usually animate a node from its previous position
Expand All @@ -85,7 +88,7 @@ export const animate = (

// create a timer to animate the nodes to their new positions
const t = timer((elapsed: number) => {
const s = elapsed / duration;
const s = elapsed / (duration || 0);

const currNodes = transitions.map(({ node, from, to }) => ({
...node,
Expand All @@ -100,7 +103,9 @@ export const animate = (
if (isFirst) {
// Synchronise a fit to the final position with the same duration
const bounds = getRectOfNodes(to.nodes);
flowInstance.fitBounds(bounds, { duration, padding: FIT_PADDING });
if (autofit) {
flowInstance.fitBounds(bounds, { duration, padding: FIT_PADDING });
}
isFirst = false;
}

Expand Down
10 changes: 7 additions & 3 deletions assets/js/workflow-diagram/nodes/Job.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import React, { memo, useEffect, useState } from 'react';
import React, { memo } from 'react';
import { Position, NodeProps } from 'reactflow';
import Node from './Node';
import PlusButton from '../components/PlusButton';
import PathButton from '../components/PathButton';
import getAdaptorName from '../util/get-adaptor-name';
import useAdaptorIcons, { AdaptorIconData } from '../useAdaptorIcons';

Expand All @@ -12,7 +13,9 @@ const JobNode = ({
sourcePosition = Position.Bottom,
...props
}: NodeProps<NodeData>) => {
const toolbar = () => props.data?.allowPlaceholder && <PlusButton />;
const toolbar = () => [
props.data?.allowPlaceholder && [<PlusButton />, <PathButton />],
];

const adaptorIconsData = useAdaptorIcons();

Expand All @@ -25,9 +28,10 @@ const JobNode = ({
label={props.data?.name}
primaryIcon={icon}
sublabel={adaptor}
isConnectable={true}
targetPosition={targetPosition}
sourcePosition={sourcePosition}
allowSource
// allowSource
toolbar={toolbar}
errors={props.data?.errors}
/>
Expand Down
Loading

0 comments on commit 18d3d6b

Please sign in to comment.