Visual Workflow Designer for BPM

Build a drag-and-drop React Flow designer that saves workflows to your BPM API.

Introduction

30 Second Summary

Think about drawing a flowchart on a whiteboard versus writing it out as a numbered list. The visual version communicates instantly to everyone in the room.

In this project, you will build a visual drag-and-drop workflow designer that lets users design business processes by placing nodes on a canvas, connecting them with transitions, and saving the result directly to your existing BPM engine's API.

What You'll Build

A React Flow-based visual workflow designer integrated into your existing React and ASP.NET Core application from Project 1.

By the end of this project, you'll have:

  • A visual workflow canvas with custom StepNode and GatewayNode components that you can pan, zoom, and connect interactively.
  • A configuration sidebar for editing step names, roles, and transition labels in real time.
  • A working save and load pipeline that serializes visual designs into your engine's JSON format and persists them via your existing API.
  • Secret Mission: A drag-and-drop node palette so users can drag new step and gateway nodes from a sidebar onto the canvas.

Are there any prerequisites?

Yes. You need to have completed Project 1 (BPM Workflow Engine and Task Portal) with a working ASP.NET Core API, SQL Server database, and React frontend.

Your React project should already have Node.js and npm installed from that earlier project.

Install Dependencies and Create the Designer Structure

Your BPM engine from Project 1 can already run workflows defined in JSON. Now you need a visual way to create those definitions.

In this step, you will install two libraries that power the visual designer. You will also scaffold the folder structure and connect the designer page to your existing app.

In this step, get ready to:
  • Install @xyflow/react and @dagrejs/dagre into your React project.
  • Create the designer folder with all required empty files.
  • Add a navigation route so you can reach the designer page in your browser.
Install the visual editor packages

The designer relies on two npm packages. React Flow provides the interactive node-based canvas (pan, zoom, drag, connect). Dagre is a graph layout algorithm that arranges nodes in a clean hierarchy.

  • In your terminal, make sure you are inside your React project directory (the folder containing package.json).
  • Install both packages by running this command:
npm install @xyflow/react @dagrejs/dagre

What do these packages do?

@xyflow/react (React Flow v12) gives you a full-featured canvas with built-in controls, a minimap, background patterns, and the ability to define custom node components.

@dagrejs/dagre calculates optimal positions for nodes in a directed graph. You will use it later to auto-arrange workflow steps from top to bottom.

  • Confirm the packages installed by running:
ls node_modules/@xyflow/react node_modules/@dagrejs/dagre

You should see the contents of both directories listed in your terminal output.

Seeing 'No such file or directory'?

Make sure you ran the install command from the same folder that contains your package.json. If you are in the wrong directory, cd into your React project folder and try again.

help me troubleshoot why npm install failed for @xyflow/react or @dagrejs/dagre

Create the designer folder structure

All visual designer code lives in its own src/designer/ folder. This keeps the new feature isolated from the rest of your app.

  • Create the folder and its files by running these commands:
mkdir src/designer
touch src/designer/WorkflowDesigner.jsx src/designer/StepNode.jsx src/designer/GatewayNode.jsx src/designer/NodeConfigSidebar.jsx src/designer/workflowUtils.js src/designer/designer.css

What are these files for?

  • WorkflowDesigner.jsx is the main canvas component that ties everything together.
  • StepNode.jsx and GatewayNode.jsx are the custom node components that appear on the canvas.
  • NodeConfigSidebar.jsx is the property editor panel that appears when you select a node or edge.
  • workflowUtils.js holds layout, serialization, and validation logic.
  • designer.css contains all styles for the designer components.
  • Confirm the files exist by running:
ls src/designer/

You should see all six files listed: WorkflowDesigner.jsx, StepNode.jsx, GatewayNode.jsx, NodeConfigSidebar.jsx, workflowUtils.js, and designer.css.

Add a navigation entry to the designer

Your existing app needs a way to reach the new designer page. You will add a link or button that renders the WorkflowDesigner component.

  • Open your main app component (often App.jsx or your routing file from Project 1).
  • Add an import at the top of the file: import WorkflowDesigner from './designer/WorkflowDesigner'.
  • Add a route or navigation link that renders <WorkflowDesigner /> when the user clicks it. If your app uses React Router, add a new Route entry. If it uses simple conditional rendering, add a nav button that switches to the designer view.
  • Save your changes.

Keep it simple for now

The designer files are all empty, so the page will be blank. That is expected. You just need to confirm the route works and nothing crashes.

  • Start your React app by running:
npm start
  • Open your browser and navigate to your designer page using the link or button you added.
  • Confirm the page loads without errors in the browser console. The page itself will be blank.

Seeing a module not found error?

Double check that your import path points to ./designer/WorkflowDesigner (relative to your app component's location). The path is case-sensitive.

Also confirm that the file src/designer/WorkflowDesigner.jsx exists. An empty file is fine. A missing file is not.

help me debug a module not found error when importing WorkflowDesigner

Your project structure is ready and the designer page is wired up. Next, you will fill in those empty files with a working React Flow canvas and custom node components.

Build the Workflow Canvas with Custom Nodes

Your designer page is set up and ready to go. Now it is time to fill that blank canvas with something visual.

In this step, you will build the core visual layer of your workflow designer. You will create custom node components that represent workflow steps and decision gateways, then wire them into a React Flow canvas where users can pan, zoom, and connect nodes together.

In this step, get ready to:
  • Build a StepNode component with connection handles and a color-coded header.
  • Build a diamond-shaped GatewayNode for decision branching.
  • Assemble the WorkflowDesigner canvas with sample nodes and edge drawing.
Build the StepNode component

Each workflow step needs a visual representation on the canvas. The StepNode component renders a card with a colored header bar indicating whether it is a Start step (green), End step (red), or regular step (indigo).

  • Open src/designer/StepNode.jsx in VS Code and paste the following code:
import { Handle, Position } from '@xyflow/react';

function StepNode({ data, selected }) {
  // Determine header color class based on step type
  let headerClass = 'step-node-header';
  if (data.isStart) headerClass += ' start';
  if (data.isEnd) headerClass += ' end';

  // Determine header label text
  let headerLabel = 'Step';
  if (data.isStart) headerLabel = 'Start';
  if (data.isEnd) headerLabel = 'End';

  return (
    <div className={`step-node${selected ? ' selected' : ''}`}>
      <Handle type="target" position={Position.Top} />
      <div className={headerClass}>{headerLabel}</div>
      <div className="step-node-body">
        <div className="name">{data.name || 'Untitled Step'}</div>
        <div className="role">{data.role || 'No role assigned'}</div>
      </div>
      <Handle type="source" position={Position.Bottom} />
    </div>
  );
}

export default StepNode;

What does this code do?

The StepNode component receives data (name, role, isStart, isEnd) and selected props from React Flow automatically.

  • The Handle components create connection points. The target handle on top accepts incoming edges. The source handle on bottom lets users draw outgoing edges.
  • The header class switches between green (.start), red (.end), or indigo (default) depending on the step type.
  • Save StepNode.jsx.
  • You won't see a change yet. The next substep builds the GatewayNode, and then substep 3 wires both into the canvas.

Seeing a syntax error on save?

  • Make sure the import at the top uses curly braces around Handle and Position. These are named exports from the library.
  • Check that every opening brace and parenthesis has a matching closing one. Template literals use backticks, not single quotes.

Help me debug my StepNode component.

✔️ Awesome, I've got everything!

Great. Make sure you have saved the file before moving on.

⨯ I'd like to double check the full code

import { Handle, Position } from '@xyflow/react';

function StepNode({ data, selected }) {
  let headerClass = 'step-node-header';
  if (data.isStart) headerClass += ' start';
  if (data.isEnd) headerClass += ' end';

  let headerLabel = 'Step';
  if (data.isStart) headerLabel = 'Start';
  if (data.isEnd) headerLabel = 'End';

  return (
    <div className={`step-node${selected ? ' selected' : ''}`}>
      <Handle type="target" position={Position.Top} />
      <div className={headerClass}>{headerLabel}</div>
      <div className="step-node-body">
        <div className="name">{data.name || 'Untitled Step'}</div>
        <div className="role">{data.role || 'No role assigned'}</div>
      </div>
      <Handle type="source" position={Position.Bottom} />
    </div>
  );
}

export default StepNode;
Build the GatewayNode component

Workflows often need decision points where the path branches based on a condition. The GatewayNode renders as a diamond shape with two outgoing handles for "Yes" and "No" paths.

  • Open src/designer/GatewayNode.jsx and paste the following code:
import { Handle, Position } from '@xyflow/react';

function GatewayNode({ data, selected }) {
  return (
    <div className={`gateway-node${selected ? ' selected' : ''}`}>
      {/* Target handle on top, rotated to align with the diamond shape */}
      <Handle type="target" position={Position.Top} style={{ transform: 'rotate(-45deg)' }} />
      <div className="gateway-node-label">{data.condition || '?'}</div>
      {/* Two source handles at different positions for Yes/No branching */}
      <Handle
        type="source"
        position={Position.Bottom}
        id="yes"
        style={{ left: '25%', transform: 'rotate(-45deg)' }}
      />
      <Handle
        type="source"
        position={Position.Bottom}
        id="no"
        style={{ left: '75%', transform: 'rotate(-45deg)' }}
      />
    </div>
  );
}

export default GatewayNode;

What does this code do?

  • The diamond shape is achieved purely through CSS (transform: rotate(45deg) on the container). The label and handles are counter-rotated so text stays readable.
  • Each source handle has a unique id ("yes" and "no"). This lets edges connect to a specific handle rather than a generic output, which is essential for conditional branching.
  • Save GatewayNode.jsx.
  • You won't see a change yet. The next substep brings both node components together on the canvas.

Seeing red underlines in your editor?

  • Check that the import path matches exactly. It should be '@xyflow/react' with the @ symbol and forward slash.
  • Make sure the style objects use double curly braces inside JSX. The outer pair enters JavaScript, and the inner pair is the object literal.

Help me fix my GatewayNode component.

✔️ Awesome, I've got everything!

Great. Make sure you have saved the file before moving on.

⨯ I'd like to double check the full code

import { Handle, Position } from '@xyflow/react';

function GatewayNode({ data, selected }) {
  return (
    <div className={`gateway-node${selected ? ' selected' : ''}`}>
      <Handle type="target" position={Position.Top} style={{ transform: 'rotate(-45deg)' }} />
      <div className="gateway-node-label">{data.condition || '?'}</div>
      <Handle
        type="source"
        position={Position.Bottom}
        id="yes"
        style={{ left: '25%', transform: 'rotate(-45deg)' }}
      />
      <Handle
        type="source"
        position={Position.Bottom}
        id="no"
        style={{ left: '75%', transform: 'rotate(-45deg)' }}
      />
    </div>
  );
}

export default GatewayNode;
Build the WorkflowDesigner canvas

Now it is time to bring everything together. The WorkflowDesigner component sets up the React Flow canvas, registers your custom node types, and renders sample nodes so you can immediately see the result.

  • Open src/designer/designer.css and paste the following styles:
/* Layout for the designer page */
.workflow-designer {
  display: flex;
  height: calc(100vh - 60px);
}

.designer-canvas {
  flex: 1;
  position: relative;
}

/* Step node card styling */
.step-node {
  background: white;
  border: 2px solid #e5e7eb;
  border-radius: 8px;
  min-width: 160px;
  box-shadow: 0 1px 4px rgba(0, 0, 0, 0.08);
}

.step-node.selected {
  border-color: #4f46e5;
}

/* Header bar: indigo default, green for start, red for end */
.step-node-header {
  background: #4f46e5;
  color: white;
  padding: 6px 12px;
  border-radius: 6px 6px 0 0;
  font-size: 11px;
  font-weight: 600;
  text-transform: uppercase;
}

.step-node-header.start {
  background: #10b981;
}

.step-node-header.end {
  background: #ef4444;
}

.step-node-body {
  padding: 10px 12px;
}

.step-node-body .name {
  font-size: 13px;
  font-weight: 600;
  color: #1f2937;
}

.step-node-body .role {
  font-size: 11px;
  color: #6b7280;
  margin-top: 2px;
}

/* Gateway diamond shape */
.gateway-node {
  width: 80px;
  height: 80px;
  background: #fbbf24;
  border: 2px solid #d97706;
  transform: rotate(45deg);
  display: flex;
  align-items: center;
  justify-content: center;
}

.gateway-node.selected {
  border-color: #4f46e5;
}

.gateway-node-label {
  transform: rotate(-45deg);
  font-size: 10px;
  font-weight: 600;
  text-align: center;
  color: #1f2937;
  max-width: 60px;
  overflow: hidden;
  text-overflow: ellipsis;
}

What do these styles do?

  • The .workflow-designer uses flexbox to lay out the canvas and (later) the sidebar side by side.
  • The .step-node styles create a card appearance. The header color switches via the .start and .end modifiers your StepNode component applies.
  • The .gateway-node uses transform: rotate(45deg) to turn a square into a diamond. The inner label rotates back to stay horizontal.
  • Save designer.css.
  • You won't see a visible change yet. The next code block wires the canvas component that uses these styles.
  • Open src/designer/WorkflowDesigner.jsx and paste the following code:
import { useCallback, useState } from 'react';
import {
  ReactFlow,
  Background,
  Controls,
  MiniMap,
  useNodesState,
  useEdgesState,
  addEdge,
} from '@xyflow/react';
import '@xyflow/react/dist/style.css';

import StepNode from './StepNode';
import GatewayNode from './GatewayNode';
import './designer.css';

// Register custom node types so React Flow knows how to render them
const nodeTypes = {
  stepNode: StepNode,
  gatewayNode: GatewayNode,
};

// Sample nodes to populate the canvas on first load
const initialNodes = [
  {
    id: 'step-1',
    type: 'stepNode',
    position: { x: 250, y: 0 },
    data: { name: 'Submit Request', role: 'Requester', isStart: true, isEnd: false },
  },
  {
    id: 'step-2',
    type: 'stepNode',
    position: { x: 250, y: 150 },
    data: { name: 'Manager Review', role: 'Manager', isStart: false, isEnd: false },
  },
  {
    id: 'step-3',
    type: 'stepNode',
    position: { x: 250, y: 300 },
    data: { name: 'Complete', role: '', isStart: false, isEnd: true },
  },
];

// Sample edges connecting the nodes with labeled transitions
const initialEdges = [
  { id: 'e-1', source: 'step-1', target: 'step-2', label: 'Submit', animated: true },
  { id: 'e-2', source: 'step-2', target: 'step-3', label: 'Approve', animated: true },
];

let nodeIdCounter = 4;

function WorkflowDesigner() {
  const [nodes, setNodes, onNodesChange] = useNodesState(initialNodes);
  const [edges, setEdges, onEdgesChange] = useEdgesState(initialEdges);

  // Allow users to draw new edges between nodes
  const onConnect = useCallback(
    (connection) => {
      setEdges((eds) => addEdge({ ...connection, label: 'Action', animated: true }, eds));
    },
    [setEdges]
  );

  return (
    <div className="workflow-designer">
      <div className="designer-canvas">
        <ReactFlow
          nodes={nodes}
          edges={edges}
          onNodesChange={onNodesChange}
          onEdgesChange={onEdgesChange}
          onConnect={onConnect}
          nodeTypes={nodeTypes}
          fitView
        >
          <Background variant="dots" gap={20} size={1} />
          <Controls />
          <MiniMap />
        </ReactFlow>
      </div>
    </div>
  );
}

export default WorkflowDesigner;

What does this code do?

  • useNodesState and useEdgesState are React Flow hooks that manage the node and edge arrays in state. They also provide the change handlers for drag, select, and delete interactions.
  • The nodeTypes object maps the string "stepNode" to your StepNode component. When React Flow sees a node with type: 'stepNode', it renders your custom component instead of the default.
  • The onConnect callback fires when a user drags from one handle to another. It adds a new animated edge with the label "Action" by default.
  • fitView automatically zooms and pans to show all nodes when the canvas first renders.
  • Save WorkflowDesigner.jsx.
  • Switch to your browser and navigate to the designer page. You should see three connected step nodes: "Submit Request" (green header), "Manager Review" (indigo header), and "Complete" (red header). Animated edges labeled "Submit" and "Approve" connect them.
  • Try panning (click and drag the background), zooming (scroll wheel), selecting a node (click it), and drawing a new edge (drag from the bottom handle of one node to the top handle of another).

Canvas is blank or nodes look unstyled?

  • Make sure the React Flow stylesheet is imported. Check that you have import '@xyflow/react/dist/style.css' at the top of WorkflowDesigner.jsx. Without it, React Flow renders with zero dimensions.
  • Verify that the parent container has an explicit height. The .workflow-designer class in your CSS sets height: calc(100vh - 60px). React Flow needs a sized container to render.
  • Check your browser console for errors. A common mistake is defining nodeTypes inside the component function. It must be defined outside to avoid re-creating the object on every render.

Help me debug my workflow canvas.

✔️ Awesome, I've got everything!

Great. Double check that you have saved all three files before continuing.

⨯ I'd like to double check the full code

.workflow-designer {
  display: flex;
  height: calc(100vh - 60px);
}

.designer-canvas {
  flex: 1;
  position: relative;
}

.step-node {
  background: white;
  border: 2px solid #e5e7eb;
  border-radius: 8px;
  min-width: 160px;
  box-shadow: 0 1px 4px rgba(0, 0, 0, 0.08);
}

.step-node.selected {
  border-color: #4f46e5;
}

.step-node-header {
  background: #4f46e5;
  color: white;
  padding: 6px 12px;
  border-radius: 6px 6px 0 0;
  font-size: 11px;
  font-weight: 600;
  text-transform: uppercase;
}

.step-node-header.start {
  background: #10b981;
}

.step-node-header.end {
  background: #ef4444;
}

.step-node-body {
  padding: 10px 12px;
}

.step-node-body .name {
  font-size: 13px;
  font-weight: 600;
  color: #1f2937;
}

.step-node-body .role {
  font-size: 11px;
  color: #6b7280;
  margin-top: 2px;
}

.gateway-node {
  width: 80px;
  height: 80px;
  background: #fbbf24;
  border: 2px solid #d97706;
  transform: rotate(45deg);
  display: flex;
  align-items: center;
  justify-content: center;
}

.gateway-node.selected {
  border-color: #4f46e5;
}

.gateway-node-label {
  transform: rotate(-45deg);
  font-size: 10px;
  font-weight: 600;
  text-align: center;
  color: #1f2937;
  max-width: 60px;
  overflow: hidden;
  text-overflow: ellipsis;
}
import { useCallback, useState } from 'react';
import {
  ReactFlow,
  Background,
  Controls,
  MiniMap,
  useNodesState,
  useEdgesState,
  addEdge,
} from '@xyflow/react';
import '@xyflow/react/dist/style.css';

import StepNode from './StepNode';
import GatewayNode from './GatewayNode';
import './designer.css';

const nodeTypes = {
  stepNode: StepNode,
  gatewayNode: GatewayNode,
};

const initialNodes = [
  {
    id: 'step-1',
    type: 'stepNode',
    position: { x: 250, y: 0 },
    data: { name: 'Submit Request', role: 'Requester', isStart: true, isEnd: false },
  },
  {
    id: 'step-2',
    type: 'stepNode',
    position: { x: 250, y: 150 },
    data: { name: 'Manager Review', role: 'Manager', isStart: false, isEnd: false },
  },
  {
    id: 'step-3',
    type: 'stepNode',
    position: { x: 250, y: 300 },
    data: { name: 'Complete', role: '', isStart: false, isEnd: true },
  },
];

const initialEdges = [
  { id: 'e-1', source: 'step-1', target: 'step-2', label: 'Submit', animated: true },
  { id: 'e-2', source: 'step-2', target: 'step-3', label: 'Approve', animated: true },
];

let nodeIdCounter = 4;

function WorkflowDesigner() {
  const [nodes, setNodes, onNodesChange] = useNodesState(initialNodes);
  const [edges, setEdges, onEdgesChange] = useEdgesState(initialEdges);

  const onConnect = useCallback(
    (connection) => {
      setEdges((eds) => addEdge({ ...connection, label: 'Action', animated: true }, eds));
    },
    [setEdges]
  );

  return (
    <div className="workflow-designer">
      <div className="designer-canvas">
        <ReactFlow
          nodes={nodes}
          edges={edges}
          onNodesChange={onNodesChange}
          onEdgesChange={onEdgesChange}
          onConnect={onConnect}
          nodeTypes={nodeTypes}
          fitView
        >
          <Background variant="dots" gap={20} size={1} />
          <Controls />
          <MiniMap />
        </ReactFlow>
      </div>
    </div>
  );
}

export default WorkflowDesigner;

Your canvas is alive. You have custom nodes rendering, edges connecting them, and full pan/zoom interaction. Next up, you will build a configuration sidebar so users can edit node properties in real time without touching code.

Add Configuration Sidebar and Node Toolbar

Your canvas renders workflow nodes, but you cannot edit them yet. A real visual designer needs a properties panel where you select an element and change its details on the spot.

In this step, you will build a sidebar that reacts to whatever is selected on the canvas. You will also add a toolbar with buttons to create and delete nodes directly.

In this step, get ready to:
  • Build a configuration sidebar that shows editable properties for the selected node or edge.
  • Wire click events on the canvas so the sidebar knows what is selected.
  • Add toolbar buttons to create new step nodes, gateway nodes, and delete selected elements.
Build the NodeConfigSidebar component

The sidebar displays different form fields depending on what the user selected. A step node needs name and role inputs. A gateway node needs a condition label. An edge needs an action label. When nothing is selected, it shows a helpful placeholder message.

  • Open src/designer/NodeConfigSidebar.jsx in VS Code and paste the following code:
function NodeConfigSidebar({ selectedElement, elementType, onUpdate }) {
  // When nothing is selected, show placeholder text
  if (!selectedElement) {
    return (
      <div className="config-sidebar">
        <h3>Properties</h3>
        <p style={{ fontSize: '13px', color: '#6b7280' }}>
          Select a node or edge to edit its properties.
        </p>
      </div>
    );
  }

  // Step node: show name, role, start/end checkboxes
  if (elementType === 'stepNode') {
    return (
      <div className="config-sidebar">
        <h3>Step Properties</h3>
        <div className="config-field">
          <label>Step Name</label>
          <input
            type="text"
            value={selectedElement.data.name || ''}
            onChange={(e) => onUpdate({ ...selectedElement.data, name: e.target.value })}
          />
        </div>
        <div className="config-field">
          <label>Assigned Role</label>
          <select
            value={selectedElement.data.role || ''}
            onChange={(e) => onUpdate({ ...selectedElement.data, role: e.target.value })}
          >
            <option value="">-- Select Role --</option>
            <option value="Requester">Requester</option>
            <option value="Manager">Manager</option>
            <option value="Admin">Admin</option>
            <option value="HR">HR</option>
            <option value="Finance">Finance</option>
          </select>
        </div>
        <div className="config-field">
          <label>
            <input
              type="checkbox"
              checked={selectedElement.data.isStart || false}
              onChange={(e) => onUpdate({ ...selectedElement.data, isStart: e.target.checked, isEnd: false })}
            />
            Start Step
          </label>
        </div>
        <div className="config-field">
          <label>
            <input
              type="checkbox"
              checked={selectedElement.data.isEnd || false}
              onChange={(e) => onUpdate({ ...selectedElement.data, isEnd: e.target.checked, isStart: false })}
            />
            End Step
          </label>
        </div>
      </div>
    );
  }

  // Gateway node: show condition label
  if (elementType === 'gatewayNode') {
    return (
      <div className="config-sidebar">
        <h3>Gateway Properties</h3>
        <div className="config-field">
          <label>Condition Label</label>
          <input
            type="text"
            value={selectedElement.data.condition || ''}
            onChange={(e) => onUpdate({ ...selectedElement.data, condition: e.target.value })}
          />
        </div>
      </div>
    );
  }

  // Edge: show action label
  if (elementType === 'edge') {
    return (
      <div className="config-sidebar">
        <h3>Transition Properties</h3>
        <div className="config-field">
          <label>Action Label</label>
          <input
            type="text"
            value={selectedElement.label || ''}
            onChange={(e) => onUpdate({ label: e.target.value })}
          />
        </div>
      </div>
    );
  }

  return null;
}

export default NodeConfigSidebar;

What does this code do?

The component receives three props: selectedElement (the currently clicked node or edge), elementType (which kind it is), and onUpdate (a callback that pushes changes back to the canvas).

  • Each onChange handler spreads the existing data and overwrites one property, so edits are non-destructive.
  • The Start/End checkboxes are mutually exclusive. Checking one unchecks the other.
  • The role dropdown lists common BPM roles. You can customize these for your own workflow later.
  • Save NodeConfigSidebar.jsx.

You will not see any change in the browser yet. The next substep wires this component into the canvas.

Wire node/edge selection to the sidebar

React Flow fires callbacks when the user clicks a node, an edge, or an empty area of the canvas. You will track which element is selected in state and pass it to the sidebar so the correct form fields appear.

  • Open src/designer/WorkflowDesigner.jsx and replace the entire file contents with the following code:
import { useCallback, useState } from 'react';
import {
  ReactFlow,
  Background,
  Controls,
  MiniMap,
  useNodesState,
  useEdgesState,
  addEdge,
} from '@xyflow/react';
import '@xyflow/react/dist/style.css';

import StepNode from './StepNode';
import GatewayNode from './GatewayNode';
import NodeConfigSidebar from './NodeConfigSidebar';
import './designer.css';

const nodeTypes = {
  stepNode: StepNode,
  gatewayNode: GatewayNode,
};

const initialNodes = [
  {
    id: 'step-1',
    type: 'stepNode',
    position: { x: 250, y: 0 },
    data: { name: 'Submit Request', role: 'Requester', isStart: true, isEnd: false },
  },
  {
    id: 'step-2',
    type: 'stepNode',
    position: { x: 250, y: 150 },
    data: { name: 'Manager Review', role: 'Manager', isStart: false, isEnd: false },
  },
  {
    id: 'step-3',
    type: 'stepNode',
    position: { x: 250, y: 300 },
    data: { name: 'Complete', role: '', isStart: false, isEnd: true },
  },
];

const initialEdges = [
  { id: 'e-1', source: 'step-1', target: 'step-2', label: 'Submit', animated: true },
  { id: 'e-2', source: 'step-2', target: 'step-3', label: 'Approve', animated: true },
];

let nodeIdCounter = 4;

function WorkflowDesigner() {
  const [nodes, setNodes, onNodesChange] = useNodesState(initialNodes);
  const [edges, setEdges, onEdgesChange] = useEdgesState(initialEdges);
  // Track which element is currently selected
  const [selectedElement, setSelectedElement] = useState(null);
  const [elementType, setElementType] = useState(null);

  const onConnect = useCallback(
    (connection) => {
      setEdges((eds) => addEdge({ ...connection, label: 'Action', animated: true }, eds));
    },
    [setEdges]
  );

  // Selection callbacks for nodes, edges, and empty canvas
  const onNodeClick = useCallback((event, node) => {
    setSelectedElement(node);
    setElementType(node.type);
  }, []);

  const onEdgeClick = useCallback((event, edge) => {
    setSelectedElement(edge);
    setElementType('edge');
  }, []);

  const onPaneClick = useCallback(() => {
    setSelectedElement(null);
    setElementType(null);
  }, []);

  // Update handlers push sidebar edits back into React Flow state
  const handleUpdateNode = useCallback(
    (newData) => {
      setNodes((nds) =>
        nds.map((n) => {
          if (n.id === selectedElement.id) {
            return { ...n, data: newData };
          }
          return n;
        })
      );
      setSelectedElement((prev) => ({ ...prev, data: newData }));
    },
    [selectedElement, setNodes]
  );

  const handleUpdateEdge = useCallback(
    (newData) => {
      setEdges((eds) =>
        eds.map((e) => {
          if (e.id === selectedElement.id) {
            return { ...e, ...newData };
          }
          return e;
        })
      );
      setSelectedElement((prev) => ({ ...prev, ...newData }));
    },
    [selectedElement, setEdges]
  );

  const handleUpdate = useCallback(
    (newData) => {
      if (elementType === 'edge') {
        handleUpdateEdge(newData);
      } else {
        handleUpdateNode(newData);
      }
    },
    [elementType, handleUpdateEdge, handleUpdateNode]
  );

  return (
    <div className="workflow-designer">
      <div className="designer-canvas">
        <ReactFlow
          nodes={nodes}
          edges={edges}
          onNodesChange={onNodesChange}
          onEdgesChange={onEdgesChange}
          onConnect={onConnect}
          onNodeClick={onNodeClick}
          onEdgeClick={onEdgeClick}
          onPaneClick={onPaneClick}
          nodeTypes={nodeTypes}
          fitView
        >
          <Background variant="dots" gap={20} size={1} />
          <Controls />
          <MiniMap />
        </ReactFlow>
      </div>

      <NodeConfigSidebar
        selectedElement={selectedElement}
        elementType={elementType}
        onUpdate={handleUpdate}
      />
    </div>
  );
}

export default WorkflowDesigner;

What's happening here?

  • selectedElement holds the full node or edge object that was clicked. elementType tells the sidebar which form to render.
  • onNodeClick and onEdgeClick store the clicked element. onPaneClick clears the selection when you click empty canvas space.
  • handleUpdateNode maps over all nodes and replaces the data of the matching one. It also updates selectedElement so the sidebar stays in sync.
  • handleUpdate dispatches to the correct handler based on whether the selection is a node or an edge.
  • Next, add the CSS for the config sidebar. Open src/designer/designer.css and add the following rules at the bottom of the file:
.config-sidebar {
  width: 280px;
  border-left: 1px solid #e5e7eb;
  padding: 16px;
  background: #fafafa;
  overflow-y: auto;
}

.config-sidebar h3 {
  margin: 0 0 16px 0;
  font-size: 14px;
  text-transform: uppercase;
  color: #6b7280;
}

.config-field {
  margin-bottom: 12px;
}

.config-field label {
  display: block;
  font-size: 12px;
  font-weight: 600;
  color: #374151;
  margin-bottom: 4px;
}

.config-field input,
.config-field select {
  width: 100%;
  padding: 8px;
  border: 1px solid #d1d5db;
  border-radius: 4px;
  font-size: 13px;
}

.config-field input[type="checkbox"] {
  width: auto;
  margin-right: 8px;
}

What does this CSS do?

The .config-sidebar class creates a fixed-width panel on the right side of the canvas. The .config-field classes style each form input with consistent spacing and borders.

  • Save both WorkflowDesigner.jsx and designer.css.
  • Refresh your browser and click on the "Submit Request" node. You should see the sidebar on the right show "Step Properties" with the name "Submit Request" and role "Requester" pre-filled.
  • Change the step name in the sidebar. The node on the canvas should update in real time.

Sidebar not appearing?

  • Check that you imported NodeConfigSidebar at the top of WorkflowDesigner.jsx and that the file name matches exactly.
  • Make sure onNodeClick is passed as a prop to the <ReactFlow> component. Without it, clicks will not register.
  • If the sidebar appears but fields are empty, confirm that the .config-sidebar CSS rules are saved in designer.css.

Help me debug why my NodeConfigSidebar is not rendering when I click a node.

Add toolbar buttons for creating new nodes

A workflow designer needs a way to add new steps to the canvas without writing code. You will add a floating toolbar with buttons for adding step nodes, gateway nodes, and deleting the currently selected element.

  • In src/designer/WorkflowDesigner.jsx, find the handleUpdate function. Add the following three functions directly below it (before the return statement):
  const addStepNode = useCallback(() => {
    const id = `step-${nodeIdCounter++}`;
    const newNode = {
      id,
      type: 'stepNode',
      position: { x: 100 + Math.random() * 200, y: 100 + Math.random() * 200 },
      data: { name: 'New Step', role: '', isStart: false, isEnd: false },
    };
    setNodes((nds) => [...nds, newNode]);
  }, [setNodes]);

  const addGatewayNode = useCallback(() => {
    const id = `gateway-${nodeIdCounter++}`;
    const newNode = {
      id,
      type: 'gatewayNode',
      position: { x: 100 + Math.random() * 200, y: 100 + Math.random() * 200 },
      data: { condition: 'Condition?' },
    };
    setNodes((nds) => [...nds, newNode]);
  }, [setNodes]);

  const deleteSelected = useCallback(() => {
    if (!selectedElement) return;
    if (elementType === 'edge') {
      // Remove the selected edge
      setEdges((eds) => eds.filter((e) => e.id !== selectedElement.id));
    } else {
      // Remove the selected node and any connected edges
      setNodes((nds) => nds.filter((n) => n.id !== selectedElement.id));
      setEdges((eds) =>
        eds.filter((e) => e.source !== selectedElement.id && e.target !== selectedElement.id)
      );
    }
    setSelectedElement(null);
    setElementType(null);
  }, [selectedElement, elementType, setNodes, setEdges]);

What does this code do?

  • addStepNode creates a new step node with a unique ID (from nodeIdCounter) and places it at a random position so multiple new nodes do not stack on top of each other.
  • addGatewayNode does the same for diamond-shaped gateway nodes with a default condition placeholder.
  • deleteSelected removes the selected element. When deleting a node, it also removes all edges connected to that node so you do not end up with dangling transitions.
  • Now update the return statement in the same file to include the toolbar. Replace the existing return (...) block with this:
  return (
    <div className="workflow-designer">
      <div className="designer-canvas">
        <div className="designer-toolbar">
          <button onClick={addStepNode}>+ Step</button>
          <button onClick={addGatewayNode}>+ Gateway</button>
          {selectedElement && (
            <button className="delete-btn" onClick={deleteSelected}>Delete</button>
          )}
        </div>

        <ReactFlow
          nodes={nodes}
          edges={edges}
          onNodesChange={onNodesChange}
          onEdgesChange={onEdgesChange}
          onConnect={onConnect}
          onNodeClick={onNodeClick}
          onEdgeClick={onEdgeClick}
          onPaneClick={onPaneClick}
          nodeTypes={nodeTypes}
          fitView
        >
          <Background variant="dots" gap={20} size={1} />
          <Controls />
          <MiniMap />
        </ReactFlow>
      </div>

      <NodeConfigSidebar
        selectedElement={selectedElement}
        elementType={elementType}
        onUpdate={handleUpdate}
      />
    </div>
  );

What changed in the JSX?

  • A .designer-toolbar div now sits above the ReactFlow canvas. It floats in the top-left corner using absolute positioning.
  • The Delete button only renders when something is selected. This prevents accidental deletions.
  • Finally, add the toolbar styles to src/designer/designer.css. Add these rules after the .designer-canvas rule and before the .config-sidebar rules you just added:
.designer-toolbar {
  position: absolute;
  top: 10px;
  left: 10px;
  z-index: 10;
  display: flex;
  gap: 8px;
  background: white;
  padding: 8px 12px;
  border-radius: 8px;
  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}

.designer-toolbar button {
  padding: 6px 12px;
  border: 1px solid #ddd;
  border-radius: 4px;
  background: white;
  cursor: pointer;
  font-size: 13px;
}

.designer-toolbar button:hover {
  background: #f0f0f0;
}

.designer-toolbar button.save-btn {
  background: #4f46e5;
  color: white;
  border-color: #4f46e5;
}

.designer-toolbar button.save-btn:hover {
  background: #4338ca;
}

.designer-toolbar button.delete-btn {
  background: #ef4444;
  color: white;
  border-color: #ef4444;
}

.designer-toolbar button.delete-btn:hover {
  background: #dc2626;
}

What does this CSS do?

The toolbar is positioned absolutely inside the canvas container so it floats above the React Flow graph. The z-index: 10 ensures it stays on top of the canvas elements. The .delete-btn class uses a red background to clearly signal a destructive action.

  • Save both WorkflowDesigner.jsx and designer.css.
  • Refresh your browser. You should see a white toolbar in the top-left corner with "+ Step" and "+ Gateway" buttons.
  • Click "+ Step" to add a new node. Click the new node to select it. A red "Delete" button should appear in the toolbar and the sidebar should show "Step Properties" with the name "New Step".

Toolbar not visible or buttons not working?

  • If the toolbar is hidden behind the canvas, check that .designer-toolbar has z-index: 10 and position: absolute in your CSS.
  • If clicking "+ Step" does nothing, confirm that the addStepNode function is defined inside the WorkflowDesigner function and that it references setNodes in its dependency array.
  • If you see a console error about nodeIdCounter, make sure the let nodeIdCounter = 4 declaration is outside the component function (at module level).

Help me debug why my toolbar buttons are not adding new nodes to the React Flow canvas.

✔️ Awesome, I've got everything!

Great. Double check you have saved all three files before moving on.

ⓧ I'd like to double check the full code

Here is the complete code for each file at the end of this step:

function NodeConfigSidebar({ selectedElement, elementType, onUpdate }) {
  if (!selectedElement) {
    return (
      <div className="config-sidebar">
        <h3>Properties</h3>
        <p style={{ fontSize: '13px', color: '#6b7280' }}>
          Select a node or edge to edit its properties.
        </p>
      </div>
    );
  }

  if (elementType === 'stepNode') {
    return (
      <div className="config-sidebar">
        <h3>Step Properties</h3>
        <div className="config-field">
          <label>Step Name</label>
          <input
            type="text"
            value={selectedElement.data.name || ''}
            onChange={(e) => onUpdate({ ...selectedElement.data, name: e.target.value })}
          />
        </div>
        <div className="config-field">
          <label>Assigned Role</label>
          <select
            value={selectedElement.data.role || ''}
            onChange={(e) => onUpdate({ ...selectedElement.data, role: e.target.value })}
          >
            <option value="">-- Select Role --</option>
            <option value="Requester">Requester</option>
            <option value="Manager">Manager</option>
            <option value="Admin">Admin</option>
            <option value="HR">HR</option>
            <option value="Finance">Finance</option>
          </select>
        </div>
        <div className="config-field">
          <label>
            <input
              type="checkbox"
              checked={selectedElement.data.isStart || false}
              onChange={(e) => onUpdate({ ...selectedElement.data, isStart: e.target.checked, isEnd: false })}
            />
            Start Step
          </label>
        </div>
        <div className="config-field">
          <label>
            <input
              type="checkbox"
              checked={selectedElement.data.isEnd || false}
              onChange={(e) => onUpdate({ ...selectedElement.data, isEnd: e.target.checked, isStart: false })}
            />
            End Step
          </label>
        </div>
      </div>
    );
  }

  if (elementType === 'gatewayNode') {
    return (
      <div className="config-sidebar">
        <h3>Gateway Properties</h3>
        <div className="config-field">
          <label>Condition Label</label>
          <input
            type="text"
            value={selectedElement.data.condition || ''}
            onChange={(e) => onUpdate({ ...selectedElement.data, condition: e.target.value })}
          />
        </div>
      </div>
    );
  }

  if (elementType === 'edge') {
    return (
      <div className="config-sidebar">
        <h3>Transition Properties</h3>
        <div className="config-field">
          <label>Action Label</label>
          <input
            type="text"
            value={selectedElement.label || ''}
            onChange={(e) => onUpdate({ label: e.target.value })}
          />
        </div>
      </div>
    );
  }

  return null;
}

export default NodeConfigSidebar;
import { useCallback, useState } from 'react';
import {
  ReactFlow,
  Background,
  Controls,
  MiniMap,
  useNodesState,
  useEdgesState,
  addEdge,
} from '@xyflow/react';
import '@xyflow/react/dist/style.css';

import StepNode from './StepNode';
import GatewayNode from './GatewayNode';
import NodeConfigSidebar from './NodeConfigSidebar';
import './designer.css';

const nodeTypes = {
  stepNode: StepNode,
  gatewayNode: GatewayNode,
};

const initialNodes = [
  {
    id: 'step-1',
    type: 'stepNode',
    position: { x: 250, y: 0 },
    data: { name: 'Submit Request', role: 'Requester', isStart: true, isEnd: false },
  },
  {
    id: 'step-2',
    type: 'stepNode',
    position: { x: 250, y: 150 },
    data: { name: 'Manager Review', role: 'Manager', isStart: false, isEnd: false },
  },
  {
    id: 'step-3',
    type: 'stepNode',
    position: { x: 250, y: 300 },
    data: { name: 'Complete', role: '', isStart: false, isEnd: true },
  },
];

const initialEdges = [
  { id: 'e-1', source: 'step-1', target: 'step-2', label: 'Submit', animated: true },
  { id: 'e-2', source: 'step-2', target: 'step-3', label: 'Approve', animated: true },
];

let nodeIdCounter = 4;

function WorkflowDesigner() {
  const [nodes, setNodes, onNodesChange] = useNodesState(initialNodes);
  const [edges, setEdges, onEdgesChange] = useEdgesState(initialEdges);
  const [selectedElement, setSelectedElement] = useState(null);
  const [elementType, setElementType] = useState(null);

  const onConnect = useCallback(
    (connection) => {
      setEdges((eds) => addEdge({ ...connection, label: 'Action', animated: true }, eds));
    },
    [setEdges]
  );

  const onNodeClick = useCallback((event, node) => {
    setSelectedElement(node);
    setElementType(node.type);
  }, []);

  const onEdgeClick = useCallback((event, edge) => {
    setSelectedElement(edge);
    setElementType('edge');
  }, []);

  const onPaneClick = useCallback(() => {
    setSelectedElement(null);
    setElementType(null);
  }, []);

  const handleUpdateNode = useCallback(
    (newData) => {
      setNodes((nds) =>
        nds.map((n) => {
          if (n.id === selectedElement.id) {
            return { ...n, data: newData };
          }
          return n;
        })
      );
      setSelectedElement((prev) => ({ ...prev, data: newData }));
    },
    [selectedElement, setNodes]
  );

  const handleUpdateEdge = useCallback(
    (newData) => {
      setEdges((eds) =>
        eds.map((e) => {
          if (e.id === selectedElement.id) {
            return { ...e, ...newData };
          }
          return e;
        })
      );
      setSelectedElement((prev) => ({ ...prev, ...newData }));
    },
    [selectedElement, setEdges]
  );

  const handleUpdate = useCallback(
    (newData) => {
      if (elementType === 'edge') {
        handleUpdateEdge(newData);
      } else {
        handleUpdateNode(newData);
      }
    },
    [elementType, handleUpdateEdge, handleUpdateNode]
  );

  const addStepNode = useCallback(() => {
    const id = `step-${nodeIdCounter++}`;
    const newNode = {
      id,
      type: 'stepNode',
      position: { x: 100 + Math.random() * 200, y: 100 + Math.random() * 200 },
      data: { name: 'New Step', role: '', isStart: false, isEnd: false },
    };
    setNodes((nds) => [...nds, newNode]);
  }, [setNodes]);

  const addGatewayNode = useCallback(() => {
    const id = `gateway-${nodeIdCounter++}`;
    const newNode = {
      id,
      type: 'gatewayNode',
      position: { x: 100 + Math.random() * 200, y: 100 + Math.random() * 200 },
      data: { condition: 'Condition?' },
    };
    setNodes((nds) => [...nds, newNode]);
  }, [setNodes]);

  const deleteSelected = useCallback(() => {
    if (!selectedElement) return;
    if (elementType === 'edge') {
      setEdges((eds) => eds.filter((e) => e.id !== selectedElement.id));
    } else {
      setNodes((nds) => nds.filter((n) => n.id !== selectedElement.id));
      setEdges((eds) =>
        eds.filter((e) => e.source !== selectedElement.id && e.target !== selectedElement.id)
      );
    }
    setSelectedElement(null);
    setElementType(null);
  }, [selectedElement, elementType, setNodes, setEdges]);

  return (
    <div className="workflow-designer">
      <div className="designer-canvas">
        <div className="designer-toolbar">
          <button onClick={addStepNode}>+ Step</button>
          <button onClick={addGatewayNode}>+ Gateway</button>
          {selectedElement && (
            <button className="delete-btn" onClick={deleteSelected}>Delete</button>
          )}
        </div>

        <ReactFlow
          nodes={nodes}
          edges={edges}
          onNodesChange={onNodesChange}
          onEdgesChange={onEdgesChange}
          onConnect={onConnect}
          onNodeClick={onNodeClick}
          onEdgeClick={onEdgeClick}
          onPaneClick={onPaneClick}
          nodeTypes={nodeTypes}
          fitView
        >
          <Background variant="dots" gap={20} size={1} />
          <Controls />
          <MiniMap />
        </ReactFlow>
      </div>

      <NodeConfigSidebar
        selectedElement={selectedElement}
        elementType={elementType}
        onUpdate={handleUpdate}
      />
    </div>
  );
}

export default WorkflowDesigner;
.workflow-designer {
  display: flex;
  height: calc(100vh - 60px);
}

.designer-canvas {
  flex: 1;
  position: relative;
}

.designer-toolbar {
  position: absolute;
  top: 10px;
  left: 10px;
  z-index: 10;
  display: flex;
  gap: 8px;
  background: white;
  padding: 8px 12px;
  border-radius: 8px;
  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}

.designer-toolbar button {
  padding: 6px 12px;
  border: 1px solid #ddd;
  border-radius: 4px;
  background: white;
  cursor: pointer;
  font-size: 13px;
}

.designer-toolbar button:hover {
  background: #f0f0f0;
}

.designer-toolbar button.save-btn {
  background: #4f46e5;
  color: white;
  border-color: #4f46e5;
}

.designer-toolbar button.save-btn:hover {
  background: #4338ca;
}

.designer-toolbar button.delete-btn {
  background: #ef4444;
  color: white;
  border-color: #ef4444;
}

.designer-toolbar button.delete-btn:hover {
  background: #dc2626;
}

.config-sidebar {
  width: 280px;
  border-left: 1px solid #e5e7eb;
  padding: 16px;
  background: #fafafa;
  overflow-y: auto;
}

.config-sidebar h3 {
  margin: 0 0 16px 0;
  font-size: 14px;
  text-transform: uppercase;
  color: #6b7280;
}

.config-field {
  margin-bottom: 12px;
}

.config-field label {
  display: block;
  font-size: 12px;
  font-weight: 600;
  color: #374151;
  margin-bottom: 4px;
}

.config-field input,
.config-field select {
  width: 100%;
  padding: 8px;
  border: 1px solid #d1d5db;
  border-radius: 4px;
  font-size: 13px;
}

.config-field input[type="checkbox"] {
  width: auto;
  margin-right: 8px;
}

.step-node {
  background: white;
  border: 2px solid #e5e7eb;
  border-radius: 8px;
  min-width: 160px;
  box-shadow: 0 1px 4px rgba(0, 0, 0, 0.08);
}

.step-node.selected {
  border-color: #4f46e5;
}

.step-node-header {
  background: #4f46e5;
  color: white;
  padding: 6px 12px;
  border-radius: 6px 6px 0 0;
  font-size: 11px;
  font-weight: 600;
  text-transform: uppercase;
}

.step-node-header.start {
  background: #10b981;
}

.step-node-header.end {
  background: #ef4444;
}

.step-node-body {
  padding: 10px 12px;
}

.step-node-body .name {
  font-size: 13px;
  font-weight: 600;
  color: #1f2937;
}

.step-node-body .role {
  font-size: 11px;
  color: #6b7280;
  margin-top: 2px;
}

.gateway-node {
  width: 80px;
  height: 80px;
  background: #fbbf24;
  border: 2px solid #d97706;
  transform: rotate(45deg);
  display: flex;
  align-items: center;
  justify-content: center;
}

.gateway-node.selected {
  border-color: #4f46e5;
}

.gateway-node-label {
  transform: rotate(-45deg);
  font-size: 10px;
  font-weight: 600;
  text-align: center;
  color: #1f2937;
  max-width: 60px;
  overflow: hidden;
  text-overflow: ellipsis;
}

Your designer now has a fully interactive properties panel and a toolbar for managing nodes. Next up, you will build the serialization layer that converts this visual design into the JSON format your workflow engine consumes.

Serialize and Save to Your API

Your visual canvas now lets you create nodes, connect them, and configure their properties in real time. That is the "design-time" experience.

The missing piece is persistence. Right now, everything on the canvas disappears when you refresh the page. In this step, you will build the serialization layer that translates your visual graph into the JSON format your workflow engine consumes, then save it to your existing API.

In this step, get ready to:
  • Build a serialize function that converts React Flow nodes and edges into your engine's workflow JSON.
  • Build a deserialize function that converts stored workflow JSON back into React Flow nodes and edges.
  • Wire Save and Load buttons that call your existing ASP.NET Core workflow API.
Build the serializeWorkflow function

Your workflow engine expects a specific JSON shape: a name, an array of step objects, and an array of transition objects. The serializeWorkflow function translates React Flow's internal node/edge data into that shape.

  • In src/designer/workflowUtils.js, add the following serializeWorkflow function:
export function serializeWorkflow(nodes, edges, workflowName) {
  // Filter to step nodes and extract the data your engine needs
  const steps = nodes
    .filter((n) => n.type === 'stepNode')
    .map((n) => ({
      id: n.id,
      name: n.data.name || 'Untitled',
      role: n.data.role || null,
      isStart: n.data.isStart || false,
      isEnd: n.data.isEnd || false,
    }));

  // Map edges to transition objects with source/target and action label
  const transitions = edges.map((e) => ({
    fromStepId: e.source,
    toStepId: e.target,
    action: e.label || 'Next',
    sourceHandle: e.sourceHandle || null,
  }));

  return {
    name: workflowName,
    steps,
    transitions,
  };
}

What does this code do?

The function takes in all React Flow nodes, all edges, and the workflow name. It filters nodes to only step nodes (ignoring gateway nodes for now) and maps each one to a flat object your API expects.

For transitions, each edge becomes an object with fromStepId (the edge source), toStepId (the edge target), and the action label you assigned in the sidebar. The sourceHandle field tracks which handle a gateway edge connects from ("yes" or "no").

  • Save workflowUtils.js.

You won't see a change yet. The next function and the wiring in the designer component will use this.

Build the deserializeWorkflow function

Loading a saved workflow means doing the reverse: converting your engine's JSON back into React Flow nodes and edges so they appear on the canvas.

  • In workflowUtils.js, add the deserializeWorkflow function below the one you just wrote:
export function deserializeWorkflow(workflowDef) {
  // Convert each stored step back into a React Flow node
  const nodes = workflowDef.steps.map((step, index) => ({
    id: step.id,
    type: 'stepNode',
    position: { x: 0, y: index * 120 },
    data: {
      name: step.name,
      role: step.role,
      isStart: step.isStart,
      isEnd: step.isEnd,
    },
  }));

  // Convert each stored transition back into a React Flow edge
  const edges = workflowDef.transitions.map((t, index) => ({
    id: `e-${index}`,
    source: t.fromStepId,
    target: t.toStepId,
    label: t.action,
    sourceHandle: t.sourceHandle || null,
    animated: true,
  }));

  return { nodes, edges };
}

What does this code do?

Each stored step becomes a stepNode with its properties mapped back into the data object that your custom node component reads. Positions are stacked vertically for now using index * 120. In the next step, you will replace this with dagre auto-layout for a cleaner arrangement.

Each stored transition becomes an animated edge with the action label displayed on the canvas.

  • Save workflowUtils.js.

You won't see a change yet. The next chunk wires these functions into the designer component.

✔️ Awesome, I've got everything!

Great. Double check you have saved workflowUtils.js with both exported functions.

ⓧ I'd like to double check the full code

export function serializeWorkflow(nodes, edges, workflowName) {
  // Filter to step nodes and extract the data your engine needs
  const steps = nodes
    .filter((n) => n.type === 'stepNode')
    .map((n) => ({
      id: n.id,
      name: n.data.name || 'Untitled',
      role: n.data.role || null,
      isStart: n.data.isStart || false,
      isEnd: n.data.isEnd || false,
    }));

  // Map edges to transition objects with source/target and action label
  const transitions = edges.map((e) => ({
    fromStepId: e.source,
    toStepId: e.target,
    action: e.label || 'Next',
    sourceHandle: e.sourceHandle || null,
  }));

  return {
    name: workflowName,
    steps,
    transitions,
  };
}

export function deserializeWorkflow(workflowDef) {
  // Convert each stored step back into a React Flow node
  const nodes = workflowDef.steps.map((step, index) => ({
    id: step.id,
    type: 'stepNode',
    position: { x: 0, y: index * 120 },
    data: {
      name: step.name,
      role: step.role,
      isStart: step.isStart,
      isEnd: step.isEnd,
    },
  }));

  // Convert each stored transition back into a React Flow edge
  const edges = workflowDef.transitions.map((t, index) => ({
    id: `e-${index}`,
    source: t.fromStepId,
    target: t.toStepId,
    label: t.action,
    sourceHandle: t.sourceHandle || null,
    animated: true,
  }));

  return { nodes, edges };
}
Wire the Save and Load buttons

With the utility functions ready, you need to connect them to the designer UI. You will add a workflow name input, a Save button that POSTs to your API, and a Load button that fetches existing workflows.

  • In WorkflowDesigner.jsx, find this import block near the top:
import StepNode from './StepNode';
import GatewayNode from './GatewayNode';
import NodeConfigSidebar from './NodeConfigSidebar';
import './designer.css';
  • Replace it with the following to add the utility imports:
import StepNode from './StepNode';
import GatewayNode from './GatewayNode';
import NodeConfigSidebar from './NodeConfigSidebar';
import { serializeWorkflow, deserializeWorkflow } from './workflowUtils';
import './designer.css';

Now add the workflow name state and the two handler functions inside the WorkflowDesigner component.

  • Find the line that says const [elementType, setElementType] = useState(null); and add this new state line directly below it:
  const [workflowName, setWorkflowName] = useState('New Workflow');
  • Find the deleteSelected callback. Below its closing });, add the following two handler functions:
  const handleSave = useCallback(async () => {
    const workflowDef = serializeWorkflow(nodes, edges, workflowName);
    try {
      const response = await fetch('/api/workflows', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(workflowDef),
      });
      if (response.ok) {
        alert('Workflow saved successfully!');
      } else {
        alert('Failed to save workflow.');
      }
    } catch (error) {
      alert('Error saving workflow: ' + error.message);
    }
  }, [nodes, edges, workflowName]);

  const handleLoad = useCallback(async () => {
    try {
      const response = await fetch('/api/workflows');
      const workflows = await response.json();
      if (workflows.length === 0) {
        alert('No saved workflows found.');
        return;
      }
      const latest = workflows[workflows.length - 1];
      const { nodes: loadedNodes, edges: loadedEdges } = deserializeWorkflow(latest);
      setNodes(loadedNodes);
      setEdges(loadedEdges);
      setWorkflowName(latest.name || 'Loaded Workflow');
    } catch (error) {
      alert('Error loading workflow: ' + error.message);
    }
  }, [setNodes, setEdges]);

What do these handlers do?

handleSave calls serializeWorkflow to build the JSON payload, then POSTs it to /api/workflows. This is the same endpoint your ASP.NET Core API already exposes from Project 1.

handleLoad fetches all saved workflows, picks the most recent one, and calls deserializeWorkflow to convert it back into canvas-ready nodes and edges. The loaded workflow name is also restored into the text input.

Finally, update the toolbar JSX to include the workflow name input and the new buttons.

  • In the return statement, find the designer-toolbar div. Replace it with the following updated toolbar:
        <div className="designer-toolbar">
          <input
            type="text"
            value={workflowName}
            onChange={(e) => setWorkflowName(e.target.value)}
            style={{ width: '150px', padding: '6px 8px', border: '1px solid #ddd', borderRadius: '4px' }}
          />
          <button onClick={addStepNode}>+ Step</button>
          <button onClick={addGatewayNode}>+ Gateway</button>
          {selectedElement && (
            <button className="delete-btn" onClick={deleteSelected}>Delete</button>
          )}
          <button onClick={handleLoad}>Load</button>
          <button className="save-btn" onClick={handleSave}>Save</button>
        </div>

What changed in the toolbar?

You added a text input bound to workflowName state so the learner can name their workflow before saving. The Load button triggers handleLoad and the Save button (styled purple with the save-btn class) triggers handleSave.

  • Save WorkflowDesigner.jsx.
  • Make sure your ASP.NET Core API is running.
  • In the browser, refresh the designer page. You should see a text input showing "New Workflow" and purple Save and Load buttons in the toolbar.
  • Click Save. You should see an alert saying "Workflow saved successfully!" if your API is running.
  • Refresh the page (so the canvas resets to initial nodes), then click Load. Your saved workflow should reappear on the canvas.

Save button showing an error?

  • Make sure your ASP.NET Core API is running and listening on the expected port. The fetch call goes to /api/workflows. If your API runs on a different port, you may need to update the proxy config or use the full URL.
  • Check the browser console for CORS errors. If you see one, ensure your ASP.NET Core API allows requests from your React dev server's origin.
  • If your API expects a different JSON shape than what serializeWorkflow produces, you may need to adjust the field names in the function to match your endpoint's expected model.

Help me debug why my workflow isn't saving to the API.

Now add the CSS for validation error messages that you will use in the next step.

  • In designer.css, add the following styles at the bottom of the file:
.validation-errors {
  position: absolute;
  bottom: 10px;
  left: 10px;
  z-index: 10;
  background: #fef2f2;
  border: 1px solid #fecaca;
  border-radius: 8px;
  padding: 8px 12px;
  max-width: 300px;
}

.validation-errors p {
  margin: 0 0 4px 0;
  font-size: 12px;
  color: #dc2626;
}
  • Save designer.css.

✔️ Awesome, I've got everything!

Great. Make sure you have saved both WorkflowDesigner.jsx and designer.css.

ⓧ I'd like to double check the full code

import { useCallback, useState } from 'react';
import {
  ReactFlow,
  Background,
  Controls,
  MiniMap,
  useNodesState,
  useEdgesState,
  addEdge,
} from '@xyflow/react';
import '@xyflow/react/dist/style.css';

import StepNode from './StepNode';
import GatewayNode from './GatewayNode';
import NodeConfigSidebar from './NodeConfigSidebar';
import { serializeWorkflow, deserializeWorkflow } from './workflowUtils';
import './designer.css';

const nodeTypes = {
  stepNode: StepNode,
  gatewayNode: GatewayNode,
};

const initialNodes = [
  {
    id: 'step-1',
    type: 'stepNode',
    position: { x: 250, y: 0 },
    data: { name: 'Submit Request', role: 'Requester', isStart: true, isEnd: false },
  },
  {
    id: 'step-2',
    type: 'stepNode',
    position: { x: 250, y: 150 },
    data: { name: 'Manager Review', role: 'Manager', isStart: false, isEnd: false },
  },
  {
    id: 'step-3',
    type: 'stepNode',
    position: { x: 250, y: 300 },
    data: { name: 'Complete', role: '', isStart: false, isEnd: true },
  },
];

const initialEdges = [
  { id: 'e-1', source: 'step-1', target: 'step-2', label: 'Submit', animated: true },
  { id: 'e-2', source: 'step-2', target: 'step-3', label: 'Approve', animated: true },
];

let nodeIdCounter = 4;

function WorkflowDesigner() {
  const [nodes, setNodes, onNodesChange] = useNodesState(initialNodes);
  const [edges, setEdges, onEdgesChange] = useEdgesState(initialEdges);
  const [selectedElement, setSelectedElement] = useState(null);
  const [elementType, setElementType] = useState(null);
  const [workflowName, setWorkflowName] = useState('New Workflow');

  const onConnect = useCallback(
    (connection) => {
      setEdges((eds) => addEdge({ ...connection, label: 'Action', animated: true }, eds));
    },
    [setEdges]
  );

  const onNodeClick = useCallback((event, node) => {
    setSelectedElement(node);
    setElementType(node.type);
  }, []);

  const onEdgeClick = useCallback((event, edge) => {
    setSelectedElement(edge);
    setElementType('edge');
  }, []);

  const onPaneClick = useCallback(() => {
    setSelectedElement(null);
    setElementType(null);
  }, []);

  const handleUpdateNode = useCallback(
    (newData) => {
      setNodes((nds) =>
        nds.map((n) => {
          if (n.id === selectedElement.id) {
            return { ...n, data: newData };
          }
          return n;
        })
      );
      setSelectedElement((prev) => ({ ...prev, data: newData }));
    },
    [selectedElement, setNodes]
  );

  const handleUpdateEdge = useCallback(
    (newData) => {
      setEdges((eds) =>
        eds.map((e) => {
          if (e.id === selectedElement.id) {
            return { ...e, ...newData };
          }
          return e;
        })
      );
      setSelectedElement((prev) => ({ ...prev, ...newData }));
    },
    [selectedElement, setEdges]
  );

  const handleUpdate = useCallback(
    (newData) => {
      if (elementType === 'edge') {
        handleUpdateEdge(newData);
      } else {
        handleUpdateNode(newData);
      }
    },
    [elementType, handleUpdateEdge, handleUpdateNode]
  );

  const addStepNode = useCallback(() => {
    const id = `step-${nodeIdCounter++}`;
    const newNode = {
      id,
      type: 'stepNode',
      position: { x: 100 + Math.random() * 200, y: 100 + Math.random() * 200 },
      data: { name: 'New Step', role: '', isStart: false, isEnd: false },
    };
    setNodes((nds) => [...nds, newNode]);
  }, [setNodes]);

  const addGatewayNode = useCallback(() => {
    const id = `gateway-${nodeIdCounter++}`;
    const newNode = {
      id,
      type: 'gatewayNode',
      position: { x: 100 + Math.random() * 200, y: 100 + Math.random() * 200 },
      data: { condition: 'Condition?' },
    };
    setNodes((nds) => [...nds, newNode]);
  }, [setNodes]);

  const deleteSelected = useCallback(() => {
    if (!selectedElement) return;
    if (elementType === 'edge') {
      setEdges((eds) => eds.filter((e) => e.id !== selectedElement.id));
    } else {
      setNodes((nds) => nds.filter((n) => n.id !== selectedElement.id));
      setEdges((eds) =>
        eds.filter((e) => e.source !== selectedElement.id && e.target !== selectedElement.id)
      );
    }
    setSelectedElement(null);
    setElementType(null);
  }, [selectedElement, elementType, setNodes, setEdges]);

  const handleSave = useCallback(async () => {
    const workflowDef = serializeWorkflow(nodes, edges, workflowName);
    try {
      const response = await fetch('/api/workflows', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(workflowDef),
      });
      if (response.ok) {
        alert('Workflow saved successfully!');
      } else {
        alert('Failed to save workflow.');
      }
    } catch (error) {
      alert('Error saving workflow: ' + error.message);
    }
  }, [nodes, edges, workflowName]);

  const handleLoad = useCallback(async () => {
    try {
      const response = await fetch('/api/workflows');
      const workflows = await response.json();
      if (workflows.length === 0) {
        alert('No saved workflows found.');
        return;
      }
      const latest = workflows[workflows.length - 1];
      const { nodes: loadedNodes, edges: loadedEdges } = deserializeWorkflow(latest);
      setNodes(loadedNodes);
      setEdges(loadedEdges);
      setWorkflowName(latest.name || 'Loaded Workflow');
    } catch (error) {
      alert('Error loading workflow: ' + error.message);
    }
  }, [setNodes, setEdges]);

  return (
    <div className="workflow-designer">
      <div className="designer-canvas">
        <div className="designer-toolbar">
          <input
            type="text"
            value={workflowName}
            onChange={(e) => setWorkflowName(e.target.value)}
            style={{ width: '150px', padding: '6px 8px', border: '1px solid #ddd', borderRadius: '4px' }}
          />
          <button onClick={addStepNode}>+ Step</button>
          <button onClick={addGatewayNode}>+ Gateway</button>
          {selectedElement && (
            <button className="delete-btn" onClick={deleteSelected}>Delete</button>
          )}
          <button onClick={handleLoad}>Load</button>
          <button className="save-btn" onClick={handleSave}>Save</button>
        </div>

        <ReactFlow
          nodes={nodes}
          edges={edges}
          onNodesChange={onNodesChange}
          onEdgesChange={onEdgesChange}
          onConnect={onConnect}
          onNodeClick={onNodeClick}
          onEdgeClick={onEdgeClick}
          onPaneClick={onPaneClick}
          nodeTypes={nodeTypes}
          fitView
        >
          <Background variant="dots" gap={20} size={1} />
          <Controls />
          <MiniMap />
        </ReactFlow>
      </div>

      <NodeConfigSidebar
        selectedElement={selectedElement}
        elementType={elementType}
        onUpdate={handleUpdate}
      />
    </div>
  );
}

export default WorkflowDesigner;

Your visual designs now persist to the database through the same API your workflow engine uses. Next up, you will add dagre-based auto-layout so loaded workflows arrange themselves cleanly, and validation rules that catch errors before save.

Add Auto-Layout and Validation

Your designer can create and save workflows, but the visual result depends entirely on where the user happens to drop nodes. Loading an existing workflow is even worse because stored definitions have no position data at all.

In this step, you will add an auto-layout algorithm that arranges nodes into a clean top-to-bottom hierarchy. You will also add validation rules that catch structural problems before the workflow reaches your engine.

In this step, get ready to:
  • Build an auto-layout function using dagre that arranges nodes top-to-bottom.
  • Add pre-save validation that blocks workflows with missing Start/End steps or disconnected nodes.
  • Wire auto-layout into the load flow and add snap-to-grid for cleaner positioning.
Build the getLayoutedElements function using dagre

The dagre library is a graph layout engine. You give it a list of nodes with dimensions and a list of edges, and it calculates optimal x/y positions so nothing overlaps.

  • In workflowUtils.js, replace the entire file contents with the following code that adds the dagre import, layout constants, and the getLayoutedElements function above your existing serialize/deserialize functions:
import dagre from '@dagrejs/dagre';

const NODE_WIDTH = 180;
const NODE_HEIGHT = 80;
const GATEWAY_SIZE = 100;

// Arrange nodes in a clean top-to-bottom hierarchy using dagre
export function getLayoutedElements(nodes, edges) {
  const g = new dagre.Graph();
  g.setDefaultEdgeLabel(() => ({}));
  g.setGraph({ rankdir: 'TB', nodesep: 60, ranksep: 80 });

  // Register each node with its dimensions
  nodes.forEach((node) => {
    const width = node.type === 'gatewayNode' ? GATEWAY_SIZE : NODE_WIDTH;
    const height = node.type === 'gatewayNode' ? GATEWAY_SIZE : NODE_HEIGHT;
    g.setNode(node.id, { width, height });
  });

  // Register each edge so dagre knows the connections
  edges.forEach((edge) => {
    g.setEdge(edge.source, edge.target);
  });

  // Run the layout algorithm
  dagre.layout(g);

  // Map calculated positions back onto React Flow nodes
  const layoutedNodes = nodes.map((node) => {
    const nodeWithPosition = g.node(node.id);
    const width = node.type === 'gatewayNode' ? GATEWAY_SIZE : NODE_WIDTH;
    const height = node.type === 'gatewayNode' ? GATEWAY_SIZE : NODE_HEIGHT;

    return {
      ...node,
      position: {
        x: nodeWithPosition.x - width / 2,
        y: nodeWithPosition.y - height / 2,
      },
    };
  });

  return { nodes: layoutedNodes, edges };
}

export function serializeWorkflow(nodes, edges, workflowName) {
  const steps = nodes
    .filter((n) => n.type === 'stepNode')
    .map((n) => ({
      id: n.id,
      name: n.data.name || 'Untitled',
      role: n.data.role || null,
      isStart: n.data.isStart || false,
      isEnd: n.data.isEnd || false,
    }));

  const transitions = edges.map((e) => ({
    fromStepId: e.source,
    toStepId: e.target,
    action: e.label || 'Next',
    sourceHandle: e.sourceHandle || null,
  }));

  return {
    name: workflowName,
    steps,
    transitions,
  };
}

export function deserializeWorkflow(workflowDef) {
  const nodes = workflowDef.steps.map((step, index) => ({
    id: step.id,
    type: 'stepNode',
    position: { x: 0, y: index * 120 },
    data: {
      name: step.name,
      role: step.role,
      isStart: step.isStart,
      isEnd: step.isEnd,
    },
  }));

  const edges = workflowDef.transitions.map((t, index) => ({
    id: `e-${index}`,
    source: t.fromStepId,
    target: t.toStepId,
    label: t.action,
    sourceHandle: t.sourceHandle || null,
    animated: true,
  }));

  return { nodes, edges };
}

What does this code do?

The getLayoutedElements function creates a dagre directed graph with rankdir: 'TB' (top-to-bottom flow). It registers every node with its pixel dimensions so dagre can calculate spacing.

After running dagre.layout(g), each node gets a calculated center-point. The function subtracts half the width and height to convert to React Flow's top-left origin.

  • Save workflowUtils.js.

You will not see a visible change yet. The next substep adds the validation function, and then you will wire both into the designer component.

Add workflow validation before save

A workflow missing a Start step or containing disconnected nodes will break your engine at runtime. Catching these problems at design time is far better than a confusing runtime error.

  • In workflowUtils.js, add the following validateWorkflow function at the bottom of the file, after deserializeWorkflow:
// Validate workflow structure before saving
export function validateWorkflow(nodes, edges) {
  const errors = [];
  const stepNodes = nodes.filter((n) => n.type === 'stepNode');

  // Check for exactly one Start step
  const startNodes = stepNodes.filter((n) => n.data.isStart);
  if (startNodes.length === 0) {
    errors.push('Workflow must have exactly one Start step.');
  } else if (startNodes.length > 1) {
    errors.push('Workflow must have only one Start step.');
  }

  // Check for at least one End step
  const endNodes = stepNodes.filter((n) => n.data.isEnd);
  if (endNodes.length === 0) {
    errors.push('Workflow must have at least one End step.');
  }

  // Check that all non-End nodes have outgoing edges
  nodes.forEach((node) => {
    if (node.data.isEnd) return;
    const outgoing = edges.filter((e) => e.source === node.id);
    if (outgoing.length === 0) {
      errors.push(`"${node.data.name || node.id}" has no outgoing transitions.`);
    }
  });

  // Check that all non-Start nodes have incoming edges
  nodes.forEach((node) => {
    if (node.data.isStart) return;
    const incoming = edges.filter((e) => e.target === node.id);
    if (incoming.length === 0) {
      errors.push(`"${node.data.name || node.id}" has no incoming transitions.`);
    }
  });

  return errors;
}

What does this code do?

The function runs four structural checks against your nodes and edges. It verifies there is exactly one Start step, at least one End step, every non-End node has somewhere to go, and every non-Start node can be reached.

It returns an array of error strings. An empty array means the workflow is valid.

  • Save workflowUtils.js.

You will not see a change yet. The next substep wires both functions into the designer UI.

Polish the canvas interactions

Now you will connect the layout and validation utilities to the designer. You will also update deserializeWorkflow so loaded workflows auto-arrange, and add snap-to-grid for cleaner manual positioning.

  • In workflowUtils.js, find the deserializeWorkflow function. Locate this line at the end of the function:
  return { nodes, edges };
}
  • Replace those two lines with the following, so deserializeWorkflow auto-layouts loaded workflows:
  const layouted = getLayoutedElements(nodes, edges);
  return layouted;
}
  • Save workflowUtils.js.

Now update the designer component to use both new functions.

  • Replace the entire contents of WorkflowDesigner.jsx with the following updated code:
import { useCallback, useState } from 'react';
import {
  ReactFlow,
  Background,
  Controls,
  MiniMap,
  useNodesState,
  useEdgesState,
  addEdge,
} from '@xyflow/react';
import '@xyflow/react/dist/style.css';

import StepNode from './StepNode';
import GatewayNode from './GatewayNode';
import NodeConfigSidebar from './NodeConfigSidebar';
import {
  getLayoutedElements,
  serializeWorkflow,
  deserializeWorkflow,
  validateWorkflow,
} from './workflowUtils';
import './designer.css';

const nodeTypes = {
  stepNode: StepNode,
  gatewayNode: GatewayNode,
};

const initialNodes = [
  {
    id: 'step-1',
    type: 'stepNode',
    position: { x: 250, y: 0 },
    data: { name: 'Submit Request', role: 'Requester', isStart: true, isEnd: false },
  },
  {
    id: 'step-2',
    type: 'stepNode',
    position: { x: 250, y: 150 },
    data: { name: 'Manager Review', role: 'Manager', isStart: false, isEnd: false },
  },
  {
    id: 'step-3',
    type: 'stepNode',
    position: { x: 250, y: 300 },
    data: { name: 'Complete', role: '', isStart: false, isEnd: true },
  },
];

const initialEdges = [
  { id: 'e-1', source: 'step-1', target: 'step-2', label: 'Submit', animated: true },
  { id: 'e-2', source: 'step-2', target: 'step-3', label: 'Approve', animated: true },
];

let nodeIdCounter = 4;

function WorkflowDesigner() {
  const [nodes, setNodes, onNodesChange] = useNodesState(initialNodes);
  const [edges, setEdges, onEdgesChange] = useEdgesState(initialEdges);
  const [selectedElement, setSelectedElement] = useState(null);
  const [elementType, setElementType] = useState(null);
  const [validationErrors, setValidationErrors] = useState([]);
  const [workflowName, setWorkflowName] = useState('New Workflow');

  const onConnect = useCallback(
    (connection) => {
      setEdges((eds) => addEdge({ ...connection, label: 'Action', animated: true }, eds));
    },
    [setEdges]
  );

  const onNodeClick = useCallback((event, node) => {
    setSelectedElement(node);
    setElementType(node.type);
  }, []);

  const onEdgeClick = useCallback((event, edge) => {
    setSelectedElement(edge);
    setElementType('edge');
  }, []);

  const onPaneClick = useCallback(() => {
    setSelectedElement(null);
    setElementType(null);
  }, []);

  const handleUpdateNode = useCallback(
    (newData) => {
      setNodes((nds) =>
        nds.map((n) => {
          if (n.id === selectedElement.id) {
            return { ...n, data: newData };
          }
          return n;
        })
      );
      setSelectedElement((prev) => ({ ...prev, data: newData }));
    },
    [selectedElement, setNodes]
  );

  const handleUpdateEdge = useCallback(
    (newData) => {
      setEdges((eds) =>
        eds.map((e) => {
          if (e.id === selectedElement.id) {
            return { ...e, ...newData };
          }
          return e;
        })
      );
      setSelectedElement((prev) => ({ ...prev, ...newData }));
    },
    [selectedElement, setEdges]
  );

  const handleUpdate = useCallback(
    (newData) => {
      if (elementType === 'edge') {
        handleUpdateEdge(newData);
      } else {
        handleUpdateNode(newData);
      }
    },
    [elementType, handleUpdateEdge, handleUpdateNode]
  );

  const addStepNode = useCallback(() => {
    const id = `step-${nodeIdCounter++}`;
    const newNode = {
      id,
      type: 'stepNode',
      position: { x: 100 + Math.random() * 200, y: 100 + Math.random() * 200 },
      data: { name: 'New Step', role: '', isStart: false, isEnd: false },
    };
    setNodes((nds) => [...nds, newNode]);
  }, [setNodes]);

  const addGatewayNode = useCallback(() => {
    const id = `gateway-${nodeIdCounter++}`;
    const newNode = {
      id,
      type: 'gatewayNode',
      position: { x: 100 + Math.random() * 200, y: 100 + Math.random() * 200 },
      data: { condition: 'Condition?' },
    };
    setNodes((nds) => [...nds, newNode]);
  }, [setNodes]);

  const deleteSelected = useCallback(() => {
    if (!selectedElement) return;
    if (elementType === 'edge') {
      setEdges((eds) => eds.filter((e) => e.id !== selectedElement.id));
    } else {
      setNodes((nds) => nds.filter((n) => n.id !== selectedElement.id));
      setEdges((eds) =>
        eds.filter((e) => e.source !== selectedElement.id && e.target !== selectedElement.id)
      );
    }
    setSelectedElement(null);
    setElementType(null);
  }, [selectedElement, elementType, setNodes, setEdges]);

  // Trigger dagre auto-layout on current nodes and edges
  const handleAutoLayout = useCallback(() => {
    const { nodes: layoutedNodes, edges: layoutedEdges } = getLayoutedElements(nodes, edges);
    setNodes(layoutedNodes);
    setEdges(layoutedEdges);
  }, [nodes, edges, setNodes, setEdges]);

  // Validate before saving, block save if errors exist
  const handleSave = useCallback(async () => {
    const errors = validateWorkflow(nodes, edges);
    if (errors.length > 0) {
      setValidationErrors(errors);
      return;
    }
    setValidationErrors([]);
    const workflowDef = serializeWorkflow(nodes, edges, workflowName);
    try {
      const response = await fetch('/api/workflows', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(workflowDef),
      });
      if (response.ok) {
        alert('Workflow saved successfully!');
      } else {
        alert('Failed to save workflow.');
      }
    } catch (error) {
      alert('Error saving workflow: ' + error.message);
    }
  }, [nodes, edges, workflowName]);

  const handleLoad = useCallback(async () => {
    try {
      const response = await fetch('/api/workflows');
      const workflows = await response.json();
      if (workflows.length === 0) {
        alert('No saved workflows found.');
        return;
      }
      const latest = workflows[workflows.length - 1];
      const { nodes: loadedNodes, edges: loadedEdges } = deserializeWorkflow(latest);
      setNodes(loadedNodes);
      setEdges(loadedEdges);
      setWorkflowName(latest.name || 'Loaded Workflow');
    } catch (error) {
      alert('Error loading workflow: ' + error.message);
    }
  }, [setNodes, setEdges]);

  return (
    <div className="workflow-designer">
      <div className="designer-canvas">
        <div className="designer-toolbar">
          <input
            type="text"
            value={workflowName}
            onChange={(e) => setWorkflowName(e.target.value)}
            style={{ width: '150px', padding: '6px 8px', border: '1px solid #ddd', borderRadius: '4px' }}
          />
          <button onClick={addStepNode}>+ Step</button>
          <button onClick={addGatewayNode}>+ Gateway</button>
          {selectedElement && (
            <button className="delete-btn" onClick={deleteSelected}>Delete</button>
          )}
          <button onClick={handleAutoLayout}>Auto Layout</button>
          <button onClick={handleLoad}>Load</button>
          <button className="save-btn" onClick={handleSave}>Save</button>
        </div>

        <ReactFlow
          nodes={nodes}
          edges={edges}
          onNodesChange={onNodesChange}
          onEdgesChange={onEdgesChange}
          onConnect={onConnect}
          onNodeClick={onNodeClick}
          onEdgeClick={onEdgeClick}
          onPaneClick={onPaneClick}
          nodeTypes={nodeTypes}
          snapToGrid={true}
          snapGrid={[20, 20]}
          fitView
        >
          <Background variant="dots" gap={20} size={1} />
          <Controls />
          <MiniMap />
        </ReactFlow>

        {validationErrors.length > 0 && (
          <div className="validation-errors">
            {validationErrors.map((err, i) => (
              <p key={i}>{err}</p>
            ))}
          </div>
        )}
      </div>

      <NodeConfigSidebar
        selectedElement={selectedElement}
        elementType={elementType}
        onUpdate={handleUpdate}
      />
    </div>
  );
}

export default WorkflowDesigner;

What changed in WorkflowDesigner.jsx?

  • The import from workflowUtils now includes getLayoutedElements and validateWorkflow.
  • A new validationErrors state array holds any errors returned by the validator.
  • handleAutoLayout calls getLayoutedElements and updates the canvas positions in one click.
  • handleSave now validates first. If errors exist it sets validationErrors and returns early without calling the API.
  • snapToGrid={true} and snapGrid={[20, 20]} make manual dragging align to a 20px grid.
  • A red error panel below the toolbar renders any validation errors.
  • Save both workflowUtils.js and WorkflowDesigner.jsx.
  • Refresh your browser and click the Auto Layout button. Your nodes should snap into a clean top-to-bottom arrangement.
  • Test the validation by selecting the Start node, unchecking the Start Step checkbox in the sidebar, then clicking Save. You should see a red error panel appear at the bottom-left.

Nodes not rearranging on Auto Layout?

  • Check that import dagre from '@dagrejs/dagre' is at the very top of workflowUtils.js. A missing import causes a silent failure.
  • Verify your WorkflowDesigner.jsx imports getLayoutedElements from ./workflowUtils. If the name is misspelled the button handler does nothing.
  • Make sure the @dagrejs/dagre package is installed. Run npm list @dagrejs/dagre in your terminal to confirm.

Help me debug why Auto Layout is not working in my workflow designer.

✔️ Awesome, I've got everything!

Great. Double check you have saved both files before moving on.

ⓧ I'd like to double check the full code

Here is the complete workflowUtils.js:

import dagre from '@dagrejs/dagre';

const NODE_WIDTH = 180;
const NODE_HEIGHT = 80;
const GATEWAY_SIZE = 100;

export function getLayoutedElements(nodes, edges) {
  const g = new dagre.Graph();
  g.setDefaultEdgeLabel(() => ({}));
  g.setGraph({ rankdir: 'TB', nodesep: 60, ranksep: 80 });

  nodes.forEach((node) => {
    const width = node.type === 'gatewayNode' ? GATEWAY_SIZE : NODE_WIDTH;
    const height = node.type === 'gatewayNode' ? GATEWAY_SIZE : NODE_HEIGHT;
    g.setNode(node.id, { width, height });
  });

  edges.forEach((edge) => {
    g.setEdge(edge.source, edge.target);
  });

  dagre.layout(g);

  const layoutedNodes = nodes.map((node) => {
    const nodeWithPosition = g.node(node.id);
    const width = node.type === 'gatewayNode' ? GATEWAY_SIZE : NODE_WIDTH;
    const height = node.type === 'gatewayNode' ? GATEWAY_SIZE : NODE_HEIGHT;

    return {
      ...node,
      position: {
        x: nodeWithPosition.x - width / 2,
        y: nodeWithPosition.y - height / 2,
      },
    };
  });

  return { nodes: layoutedNodes, edges };
}

export function serializeWorkflow(nodes, edges, workflowName) {
  const steps = nodes
    .filter((n) => n.type === 'stepNode')
    .map((n) => ({
      id: n.id,
      name: n.data.name || 'Untitled',
      role: n.data.role || null,
      isStart: n.data.isStart || false,
      isEnd: n.data.isEnd || false,
    }));

  const transitions = edges.map((e) => ({
    fromStepId: e.source,
    toStepId: e.target,
    action: e.label || 'Next',
    sourceHandle: e.sourceHandle || null,
  }));

  return {
    name: workflowName,
    steps,
    transitions,
  };
}

export function deserializeWorkflow(workflowDef) {
  const nodes = workflowDef.steps.map((step, index) => ({
    id: step.id,
    type: 'stepNode',
    position: { x: 0, y: index * 120 },
    data: {
      name: step.name,
      role: step.role,
      isStart: step.isStart,
      isEnd: step.isEnd,
    },
  }));

  const edges = workflowDef.transitions.map((t, index) => ({
    id: `e-${index}`,
    source: t.fromStepId,
    target: t.toStepId,
    label: t.action,
    sourceHandle: t.sourceHandle || null,
    animated: true,
  }));

  const layouted = getLayoutedElements(nodes, edges);
  return layouted;
}

export function validateWorkflow(nodes, edges) {
  const errors = [];
  const stepNodes = nodes.filter((n) => n.type === 'stepNode');

  const startNodes = stepNodes.filter((n) => n.data.isStart);
  if (startNodes.length === 0) {
    errors.push('Workflow must have exactly one Start step.');
  } else if (startNodes.length > 1) {
    errors.push('Workflow must have only one Start step.');
  }

  const endNodes = stepNodes.filter((n) => n.data.isEnd);
  if (endNodes.length === 0) {
    errors.push('Workflow must have at least one End step.');
  }

  nodes.forEach((node) => {
    if (node.data.isEnd) return;
    const outgoing = edges.filter((e) => e.source === node.id);
    if (outgoing.length === 0) {
      errors.push(`"${node.data.name || node.id}" has no outgoing transitions.`);
    }
  });

  nodes.forEach((node) => {
    if (node.data.isStart) return;
    const incoming = edges.filter((e) => e.target === node.id);
    if (incoming.length === 0) {
      errors.push(`"${node.data.name || node.id}" has no incoming transitions.`);
    }
  });

  return errors;
}

And the complete WorkflowDesigner.jsx:

import { useCallback, useState } from 'react';
import {
  ReactFlow,
  Background,
  Controls,
  MiniMap,
  useNodesState,
  useEdgesState,
  addEdge,
} from '@xyflow/react';
import '@xyflow/react/dist/style.css';

import StepNode from './StepNode';
import GatewayNode from './GatewayNode';
import NodeConfigSidebar from './NodeConfigSidebar';
import {
  getLayoutedElements,
  serializeWorkflow,
  deserializeWorkflow,
  validateWorkflow,
} from './workflowUtils';
import './designer.css';

const nodeTypes = {
  stepNode: StepNode,
  gatewayNode: GatewayNode,
};

const initialNodes = [
  {
    id: 'step-1',
    type: 'stepNode',
    position: { x: 250, y: 0 },
    data: { name: 'Submit Request', role: 'Requester', isStart: true, isEnd: false },
  },
  {
    id: 'step-2',
    type: 'stepNode',
    position: { x: 250, y: 150 },
    data: { name: 'Manager Review', role: 'Manager', isStart: false, isEnd: false },
  },
  {
    id: 'step-3',
    type: 'stepNode',
    position: { x: 250, y: 300 },
    data: { name: 'Complete', role: '', isStart: false, isEnd: true },
  },
];

const initialEdges = [
  { id: 'e-1', source: 'step-1', target: 'step-2', label: 'Submit', animated: true },
  { id: 'e-2', source: 'step-2', target: 'step-3', label: 'Approve', animated: true },
];

let nodeIdCounter = 4;

function WorkflowDesigner() {
  const [nodes, setNodes, onNodesChange] = useNodesState(initialNodes);
  const [edges, setEdges, onEdgesChange] = useEdgesState(initialEdges);
  const [selectedElement, setSelectedElement] = useState(null);
  const [elementType, setElementType] = useState(null);
  const [validationErrors, setValidationErrors] = useState([]);
  const [workflowName, setWorkflowName] = useState('New Workflow');

  const onConnect = useCallback(
    (connection) => {
      setEdges((eds) => addEdge({ ...connection, label: 'Action', animated: true }, eds));
    },
    [setEdges]
  );

  const onNodeClick = useCallback((event, node) => {
    setSelectedElement(node);
    setElementType(node.type);
  }, []);

  const onEdgeClick = useCallback((event, edge) => {
    setSelectedElement(edge);
    setElementType('edge');
  }, []);

  const onPaneClick = useCallback(() => {
    setSelectedElement(null);
    setElementType(null);
  }, []);

  const handleUpdateNode = useCallback(
    (newData) => {
      setNodes((nds) =>
        nds.map((n) => {
          if (n.id === selectedElement.id) {
            return { ...n, data: newData };
          }
          return n;
        })
      );
      setSelectedElement((prev) => ({ ...prev, data: newData }));
    },
    [selectedElement, setNodes]
  );

  const handleUpdateEdge = useCallback(
    (newData) => {
      setEdges((eds) =>
        eds.map((e) => {
          if (e.id === selectedElement.id) {
            return { ...e, ...newData };
          }
          return e;
        })
      );
      setSelectedElement((prev) => ({ ...prev, ...newData }));
    },
    [selectedElement, setEdges]
  );

  const handleUpdate = useCallback(
    (newData) => {
      if (elementType === 'edge') {
        handleUpdateEdge(newData);
      } else {
        handleUpdateNode(newData);
      }
    },
    [elementType, handleUpdateEdge, handleUpdateNode]
  );

  const addStepNode = useCallback(() => {
    const id = `step-${nodeIdCounter++}`;
    const newNode = {
      id,
      type: 'stepNode',
      position: { x: 100 + Math.random() * 200, y: 100 + Math.random() * 200 },
      data: { name: 'New Step', role: '', isStart: false, isEnd: false },
    };
    setNodes((nds) => [...nds, newNode]);
  }, [setNodes]);

  const addGatewayNode = useCallback(() => {
    const id = `gateway-${nodeIdCounter++}`;
    const newNode = {
      id,
      type: 'gatewayNode',
      position: { x: 100 + Math.random() * 200, y: 100 + Math.random() * 200 },
      data: { condition: 'Condition?' },
    };
    setNodes((nds) => [...nds, newNode]);
  }, [setNodes]);

  const deleteSelected = useCallback(() => {
    if (!selectedElement) return;
    if (elementType === 'edge') {
      setEdges((eds) => eds.filter((e) => e.id !== selectedElement.id));
    } else {
      setNodes((nds) => nds.filter((n) => n.id !== selectedElement.id));
      setEdges((eds) =>
        eds.filter((e) => e.source !== selectedElement.id && e.target !== selectedElement.id)
      );
    }
    setSelectedElement(null);
    setElementType(null);
  }, [selectedElement, elementType, setNodes, setEdges]);

  const handleAutoLayout = useCallback(() => {
    const { nodes: layoutedNodes, edges: layoutedEdges } = getLayoutedElements(nodes, edges);
    setNodes(layoutedNodes);
    setEdges(layoutedEdges);
  }, [nodes, edges, setNodes, setEdges]);

  const handleSave = useCallback(async () => {
    const errors = validateWorkflow(nodes, edges);
    if (errors.length > 0) {
      setValidationErrors(errors);
      return;
    }
    setValidationErrors([]);
    const workflowDef = serializeWorkflow(nodes, edges, workflowName);
    try {
      const response = await fetch('/api/workflows', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(workflowDef),
      });
      if (response.ok) {
        alert('Workflow saved successfully!');
      } else {
        alert('Failed to save workflow.');
      }
    } catch (error) {
      alert('Error saving workflow: ' + error.message);
    }
  }, [nodes, edges, workflowName]);

  const handleLoad = useCallback(async () => {
    try {
      const response = await fetch('/api/workflows');
      const workflows = await response.json();
      if (workflows.length === 0) {
        alert('No saved workflows found.');
        return;
      }
      const latest = workflows[workflows.length - 1];
      const { nodes: loadedNodes, edges: loadedEdges } = deserializeWorkflow(latest);
      setNodes(loadedNodes);
      setEdges(loadedEdges);
      setWorkflowName(latest.name || 'Loaded Workflow');
    } catch (error) {
      alert('Error loading workflow: ' + error.message);
    }
  }, [setNodes, setEdges]);

  return (
    <div className="workflow-designer">
      <div className="designer-canvas">
        <div className="designer-toolbar">
          <input
            type="text"
            value={workflowName}
            onChange={(e) => setWorkflowName(e.target.value)}
            style={{ width: '150px', padding: '6px 8px', border: '1px solid #ddd', borderRadius: '4px' }}
          />
          <button onClick={addStepNode}>+ Step</button>
          <button onClick={addGatewayNode}>+ Gateway</button>
          {selectedElement && (
            <button className="delete-btn" onClick={deleteSelected}>Delete</button>
          )}
          <button onClick={handleAutoLayout}>Auto Layout</button>
          <button onClick={handleLoad}>Load</button>
          <button className="save-btn" onClick={handleSave}>Save</button>
        </div>

        <ReactFlow
          nodes={nodes}
          edges={edges}
          onNodesChange={onNodesChange}
          onEdgesChange={onEdgesChange}
          onConnect={onConnect}
          onNodeClick={onNodeClick}
          onEdgeClick={onEdgeClick}
          onPaneClick={onPaneClick}
          nodeTypes={nodeTypes}
          snapToGrid={true}
          snapGrid={[20, 20]}
          fitView
        >
          <Background variant="dots" gap={20} size={1} />
          <Controls />
          <MiniMap />
        </ReactFlow>

        {validationErrors.length > 0 && (
          <div className="validation-errors">
            {validationErrors.map((err, i) => (
              <p key={i}>{err}</p>
            ))}
          </div>
        )}
      </div>

      <NodeConfigSidebar
        selectedElement={selectedElement}
        elementType={elementType}
        onUpdate={handleUpdate}
      />
    </div>
  );
}

export default WorkflowDesigner;

Your workflow designer is now production-quality. It arranges nodes automatically, validates structure before saving, and snaps manual positioning to a grid. Next up, try the Secret Mission to add a drag-and-drop node palette for an even more intuitive design experience.

Secret mission

Add a Drag-and-Drop Node Palette

Your workflow designer uses toolbar buttons to add nodes. Real visual editors let users drag node types from a palette directly onto the canvas. Can you replace the buttons with a professional drag-and-drop experience?

Clean Up Your Resources

Clean Up Your Resources

This project runs entirely locally using your existing React dev server and SQL Server database from Project 1. There are no cloud costs or paid services to worry about.

Decide whether to keep your resources running, pause them to come back later, or delete them entirely.

Resources you used:

  • The src/designer/ folder and its files (WorkflowDesigner.jsx, StepNode.jsx, GatewayNode.jsx, NodeConfigSidebar.jsx, NodePalette.jsx, workflowUtils.js, designer.css).
  • npm packages: @xyflow/react and @dagrejs/dagre.
  • The Node.js dev server process running your React app.
  • The navigation link or route you added to reach the designer page.

Keep everything running

No action needed. Choose this if you are still actively building on your workflow designer or plan to extend it further.

  • Your designer is part of your existing React app. Leave everything as-is.
  • The dev server continues running and serving the designer page.
  • Your saved workflow definitions remain in your SQL Server database from Project 1.

Pause - I'll come back to this later

Shut down running processes to free up memory. Your code and database are saved locally and will be ready when you return.

  • Stop the React dev server by pressing Ctrl+C in the terminal where it is running.
  • Stop your ASP.NET Core API if it is running by pressing Ctrl+C in its terminal.
  • Your code files and node_modules stay on disk. When you return, run npm start to pick up where you left off.

Delete - I don't want to use this again

Remove all project resources added in this project. This only removes the designer feature you built in this project, not your entire Project 1 codebase.

  • Stop the React dev server by pressing Ctrl+C in its terminal.
  • Delete the designer folder by running this command from your React project root:

macOS

rm -rf src/designer/

Windows

rmdir /s /q src\designer
  • Uninstall the npm packages you added by running:
npm uninstall @xyflow/react @dagrejs/dagre
  • Remove the navigation link or route you added to your existing app that points to the designer page.
  • If you saved any workflow definitions through the designer, they remain in your SQL Server database. Delete them manually through your database management tool if you no longer need them.

Nice Work!

Nice Work!

You just built a visual workflow designer that produces the same JSON your BPM engine consumes. That is the "design-time" half of a real iBPMS platform.

You've learned how to:

  • Build a React Flow visual canvas with custom StepNode and GatewayNode components that render interactive workflow diagrams.
  • Create a real-time configuration sidebar that edits node and edge properties with changes reflected instantly on the canvas.
  • Serialize visual designs into your engine's JSON format, validate workflows before save, and auto-layout nodes using dagre.
  • Secret Mission: Implemented a drag-and-drop node palette so users can drag Step and Gateway nodes directly onto the canvas.

Ready to quiz yourself?