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

New Project Workflow Flowchart UI #1538

Merged
merged 12 commits into from
Jun 12, 2024

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions .yarnrc.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,9 @@ packageExtensions:
'@whatwg-node/fetch@*':
peerDependenciesMeta:
'@types/node': { optional: true }
'css-minimizer-webpack-plugin@*':
peerDependencies:
'clean-css': '*'
'ahooks@*':
peerDependencies:
'@babel/runtime': '*'
Expand Down
3 changes: 3 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
"dependencies": {
"@apollo/client": "3.7.*",
"@babel/runtime": "^7.23.2",
"@dagrejs/dagre": "^1.1.2",
"@date-io/core": "^2.17.0",
"@date-io/luxon": "^2.17.0",
"@editorjs/checklist": "^1.5.0",
Expand Down Expand Up @@ -105,6 +106,7 @@
"react-pdf": "^5.7.2",
"react-router": "^6.17.0",
"react-router-dom": "^6.17.0",
"reactflow": "^11.11.3",
"response-time": "^2.3.2",
"rifm": "^0.12.1",
"serialize-query-params": "^1.3.6",
Expand Down Expand Up @@ -220,6 +222,7 @@
},
"resolutions": {
"@iarna/rtf-to-html/rtf-parser": "patch:rtf-parser@npm:1.3.3#.yarn/patches/rtf-parser-npm-1.3.3-c57888a546.patch",
"css-minimizer-webpack-plugin@npm:^1.2.0": "patch:css-minimizer-webpack-plugin@npm%3A1.3.0#~/.yarn/patches/css-minimizer-webpack-plugin-npm-1.3.0-c66c75884d.patch",
"razzle/react-refresh": "^0.13.0",
"razzle/@pmmmwh/react-refresh-webpack-plugin": "^0.5.6",
"react-dev-utils/browserslist": "^4.14.2",
Expand Down
5 changes: 4 additions & 1 deletion razzle.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,10 @@ const modifyWebpackOptions = ({
options.babelRule.include.push(
require.resolve('@seedcompany/common').replace('.cjs', '.js'),
require.resolve('@editorjs/editorjs').replace('.umd.js', '.mjs'),
(path) => path.includes('/@mui-')
(path) =>
path.includes('/@mui-') ||
path.includes('reactflow') ||
path.includes('dagrejs')
);

return options;
Expand Down
4 changes: 2 additions & 2 deletions src/common/transitionTypeStyles.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import { ButtonProps } from '@mui/material';
import { TransitionType } from '~/api/schema/schema.graphql';

export const transitionTypeStyles: Record<TransitionType, ButtonProps> = {
export const transitionTypeStyles = {
Approve: { color: 'primary', variant: 'contained' },
Neutral: { color: 'secondary', variant: 'text' },
Reject: { color: 'error', variant: 'text' },
};
} satisfies Record<TransitionType, ButtonProps>;
30 changes: 15 additions & 15 deletions src/components/PaperTooltip/PaperTooltip.tsx
Original file line number Diff line number Diff line change
@@ -1,17 +1,17 @@
import { Tooltip } from '@mui/material';
import { Tooltip, TooltipProps } from '@mui/material';
import { styled } from '@mui/material/styles';

export const PaperTooltip = styled(Tooltip)(
({ theme: { palette, spacing, typography, shadows } }) => ({
tooltip: {
backgroundColor: palette.background.paper,
color: palette.text.primary,
...typography.body1,
padding: spacing(1),
boxShadow: shadows[8],
},
arrow: {
color: palette.background.paper,
},
})
);
export const PaperTooltip = styled(({ className, ...props }: TooltipProps) => (
<Tooltip {...props} classes={{ popper: className }} />
))(({ theme: { palette, spacing, typography, shadows } }) => ({
'& .MuiTooltip-tooltip': {
backgroundColor: palette.background.paper,
color: palette.text.primary,
...typography.body1,
padding: spacing(1),
boxShadow: shadows[8],
},
'& .MuiTooltip-arrow': {
color: palette.background.paper,
},
}));
111 changes: 111 additions & 0 deletions src/components/Workflow/Flowchart.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
import { TypedDocumentNode as DocumentNode, useQuery } from '@apollo/client';
import { mapEntries } from '@seedcompany/common';
import { useDebounceFn, useLocalStorageState } from 'ahooks';
import { OperationDefinitionNode } from 'graphql';
import { ComponentType, useCallback, useMemo } from 'react';
import ReactFlow, {
applyNodeChanges,
Background,
Controls,
EdgeTypes,
NodeProps,
OnNodesChange,
ReactFlowProvider,
useEdgesState,
useNodesState,
XYPosition,
} from 'reactflow';
import {
Edge as EdgeComponent,
FlowchartStyles,
StateNode,
TransitionNode,
} from './nodes';
import { NodeTypes, parseWorkflow } from './parse-node-edges';
import { useAutoLayout } from './useAutoLayout';
import { WorkflowFragment } from './workflow.graphql';

interface Props {
doc: DocumentNode<{ workflow: WorkflowFragment }, Record<string, never>>;
}

const WrappedFlowchart = (props: Props) => (
<ReactFlowProvider>
<Flowchart {...props} />
</ReactFlowProvider>
);
// eslint-disable-next-line react/display-name,import/no-default-export
export default WrappedFlowchart;

const Flowchart = (props: Props) => {
const opName = useMemo(
() =>
props.doc.definitions.find(
(d): d is OperationDefinitionNode => d.kind === 'OperationDefinition'
)!.name!.value,
[props.doc]
);

const [storedPos, setStoredPos] = useLocalStorageState<
Record<string, XYPosition>
>(`${opName}-flowchart-node-position-map`);
const [nodes, setNodes] = useNodesState([]);
const [edges, setEdges, onEdgesChange] = useEdgesState([]);
const autoLayout = useAutoLayout(setNodes);

useQuery(props.doc, {
onCompleted: ({ workflow }) => {
autoLayout.reset();
const { nodes, edges } = parseWorkflow(workflow);
const persistedPosNodes = nodes.map((node) =>
storedPos?.[node.id] ? { ...node, position: storedPos[node.id]! } : node
);
setNodes(persistedPosNodes);
setEdges(edges);
},
});

const persist = useDebounceFn((nextNodes: typeof nodes) => {
setStoredPos(
mapEntries(nextNodes, ({ id, position }) => [id, position]).asRecord
);
});

const onNodesChange: OnNodesChange = useCallback(
(changes) => {
setNodes((prev) => {
const next = applyNodeChanges(changes, prev);
persist.run(next);
return next;
});
},
[setNodes, persist]
);

return (
<FlowchartStyles sx={autoLayout.showSx}>
<ReactFlow
nodes={nodes}
onNodesChange={onNodesChange}
edges={edges}
onEdgesChange={onEdgesChange}
nodeTypes={nodeTypes}
edgeTypes={edgeTypes}
minZoom={0.1}
nodesConnectable={false}
>
<Background />
<Controls />
</ReactFlow>
</FlowchartStyles>
);
};

export const nodeTypes: Record<NodeTypes, ComponentType<NodeProps>> = {
state: StateNode,
transition: TransitionNode,
};

const edgeTypes = {
default: EdgeComponent,
} satisfies EdgeTypes;
120 changes: 120 additions & 0 deletions src/components/Workflow/layout.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
import Dagre from '@dagrejs/dagre';
import { mapKeys, simpleSwitch } from '@seedcompany/common';
import { Edge as E, Node as N, Position as Side, XYPosition } from 'reactflow';
import { NodeTypes } from './parse-node-edges';
import {
WorkflowStateFragment as State,
WorkflowTransitionFragment as Transition,
} from './workflow.graphql';

type Node = N<State | Transition, NodeTypes>;
type Edge = E<Transition>;

export const determinePositions = (nodes: Node[], edges: Edge[]) => {
const g = new Dagre.graphlib.Graph()
.setDefaultEdgeLabel(() => ({}))
.setGraph({
ranksep: 80,
acyclicer: 'greedy',
});

const nodeMap = mapKeys.fromList(nodes, (n) => n.id).asMap;

nodes.forEach((node) =>
g.setNode(node.id, {
...node,
width: node.width!,
height: node.height!,
})
);
edges.forEach((edge) =>
g.setEdge(edge.source, edge.target, {
weight:
simpleSwitch(edge.data!.type, {
Approve: 10,
Neutral: 2,
Reject: 1,
})! + (nodeMap.get(edge.source)!.type === 'state' ? 2 : 0),
})
);

Dagre.layout(g);

const max = {
x: Math.max(...nodes.map((n) => g.node(n.id).x)),
y: Math.max(...nodes.map((n) => g.node(n.id).y)),
};

return nodes.map((node) => {
// node position was persisted keep user placement.
if (node.position.x > 0 || node.position.y > 0) {
return node;
}

const position = g.node(node.id);
// Convert anchor point from dagre to react flow
// center/center -> top/left
let x = position.x - node.width! / 2;
const y = position.y - node.height! / 2;

// Invert x-axis because for some reason, dagre puts the starting
// point on the right.
x = max.x - x;

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

/**
* Returns the intersection point of the line between the center of the
* intersectionNode and the target node
*/
export function getNodeIntersection(
intersectionNode: Node,
targetNode: Node,
margin: XYPosition = { x: 10, y: 0 }
) {
// https://math.stackexchange.com/questions/1724792/an-algorithm-for-finding-the-intersection-point-between-a-center-of-vision-and-a
const w = intersectionNode.width! / 2 - margin.x;
const h = intersectionNode.height! / 2 - margin.y;

const x2 = intersectionNode.positionAbsolute!.x + w + margin.x;
const y2 = intersectionNode.positionAbsolute!.y + h + margin.y;
const x1 = targetNode.positionAbsolute!.x + targetNode.width! / 2;
const y1 = targetNode.positionAbsolute!.y + targetNode.height! / 2;

const xx1 = (x1 - x2) / (2 * w) - (y1 - y2) / (2 * h);
const yy1 = (x1 - x2) / (2 * w) + (y1 - y2) / (2 * h);
const a = 1 / (Math.abs(xx1) + Math.abs(yy1));
const xx3 = a * xx1;
const yy3 = a * yy1;
const x = w * (xx3 + yy3) + x2;
const y = h * (-xx3 + yy3) + y2;

return { x, y };
}

/**
* Returns the Side/"Position" of the edge where the intersection point is
*/
export function getEdgeSide(node: Node, intersectsAt: XYPosition) {
const n = { ...node.positionAbsolute, ...node };
const nx = Math.round(n.x!);
const ny = Math.round(n.y!);
const px = Math.round(intersectsAt.x);
const py = Math.round(intersectsAt.y);

if (px <= nx + 1) {
return Side.Left;
}
if (px >= nx + n.width! - 1) {
return Side.Right;
}
if (py <= ny + 1) {
return Side.Top;
}
if (py >= n.y! + n.height! - 1) {
return Side.Bottom;
}
return Side.Top;
}
Loading
Loading