Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Workflow Diagram: "edge merging" (new edge creation) #2160

Merged
merged 32 commits into from
Jun 5, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
16cc4da
workflow diagram: add basic dagre layout
josephjclark May 29, 2024
15d6ab6
workflow-dialog: create dummy drag handlers
josephjclark May 29, 2024
905c0e5
workflow diagram: create new edge
josephjclark May 29, 2024
65e4df1
styling for interactions
josephjclark May 29, 2024
7e9dbbe
workflow-diagram: update drop target validation rules
josephjclark May 30, 2024
6af903a
style tweak
josephjclark May 30, 2024
90cd62f
workflow-diagram: prevent invalid edges being created
josephjclark May 30, 2024
6cc3596
tidy
josephjclark May 30, 2024
a741e70
workfklow-diagram: fix edge positions
josephjclark May 30, 2024
bfc0490
workflow-diagram: tweak layout to look more like it did
josephjclark May 30, 2024
986283a
workflow-diagram: slightly better edge connection styling
josephjclark May 30, 2024
2938ef7
workflow-diagram: rough experiment with bezier edges
josephjclark May 30, 2024
914ef15
edit: stub in path deletion button
josephjclark May 30, 2024
f3029fc
workflow-diagram: fixes to dagre layout
josephjclark May 30, 2024
d5f42db
workflow-diagram: tweak zoom padding
josephjclark May 30, 2024
807aa6a
workflow-diagram: give error feedback on hover
josephjclark May 30, 2024
ad3f328
workflow-diagram: adjust error styling
josephjclark Jun 3, 2024
b7a3152
workflow-diagram: add controls with autofit toggle
josephjclark Jun 3, 2024
b7a697d
workflow-diagram: tweak highlight styles
josephjclark Jun 4, 2024
e8a7547
workflow-diagram: change error message
josephjclark Jun 4, 2024
c8c3c08
workflow-diagram: styling
josephjclark Jun 4, 2024
48d3828
workflow-diagram: remove old stuff
josephjclark Jun 4, 2024
e368198
workflow-diagram: fix an issue where the edge icon prevented the hand…
josephjclark Jun 4, 2024
2e7841f
workflow-diagram: move controls to bottom left
josephjclark Jun 4, 2024
8f90821
workflow-diagram: simplify and improve error reporting
josephjclark Jun 4, 2024
423c3e5
delete edges (still needs tests & permissions)
taylordowns2000 Jun 4, 2024
5c85e37
tests for edge deletion
taylordowns2000 Jun 4, 2024
83933df
center the buttons
taylordowns2000 Jun 4, 2024
0502f63
handle style, cleanup assigns
taylordowns2000 Jun 5, 2024
6102a5e
remove dupe
taylordowns2000 Jun 5, 2024
b155407
unbreak my heart
taylordowns2000 Jun 5, 2024
8eacc59
add dagre vendor file, tidy dependencies (#2178)
josephjclark Jun 5, 2024
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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