/**
* Global state store for the workflow editor. Holds all editor state (nodes,
* connections, selection, zoom/pan, and undo/redo history) via a reducer and
* exposes it to the component tree through a React Context provider and the
* useWorkflow hook.
*
* @module context/WorkflowContext
*/
import React, { createContext, useContext, useReducer, useEffect } from 'react'
import { v4 as uuidv4 } from 'uuid'
import { loadComponents } from '../utils/componentLoader'
const WorkflowContext = createContext(null)
/**
* Action types that should be recorded in the undo/redo history.
*/
// Actions that should be tracked in undo/redo history
const UNDOABLE_ACTIONS = [
'ADD_NODE',
'DELETE_NODE',
'UPDATE_NODE',
'ADD_CONNECTION',
'DELETE_CONNECTION',
'LOAD_WORKFLOW',
'CLEAR_CANVAS',
'END_DRAG' // Commits drag as single undo step
]
/**
* Maximum number of history entries retained for undo/redo.
*/
const MAX_HISTORY_SIZE = 50
/**
* Initial reducer state for a fresh, empty workflow editor session.
*/
export const initialState = {
components: {},
nodes: [],
connections: [],
selectedNodeId: null,
selectedConnectionId: null,
connectingFrom: null,
previewedComponent: null,
zoom: 1,
pan: { x: 0, y: 0 },
// Undo/Redo history
past: [],
future: [],
// Drag state for batching position updates
dragStartState: null
}
/**
* Core reducer that applies a single action to the workflow state. Handles node
* lifecycle (ADD_NODE, UPDATE_NODE, UPDATE_NODE_POSITION, DELETE_NODE),
* connection lifecycle (ADD_CONNECTION, DELETE_CONNECTION, UPDATE_CONNECTION_PORTS),
* selection (SELECT_NODE, SELECT_CONNECTION, DESELECT_ALL), connecting/drag
* gestures, viewport (SET_ZOOM, SET_PAN), workflow load/clear, and undo/redo.
*
* @param {Object} state - The current workflow state.
* @param {Object} action - The dispatched action with a `type` and optional `payload`.
* @returns {Object} The next workflow state.
*/
export function workflowReducer(state, action) {
switch (action.type) {
case 'SET_COMPONENTS':
return { ...state, components: action.payload }
case 'ADD_COMPONENT': {
// Add (or replace by uuid) a single component in its category, keeping the
// list sorted by name so it matches the backend-loaded ordering.
const { category, component } = action.payload
const existing = state.components[category] || []
const merged = [
...existing.filter(c => c.uuid !== component.uuid),
component
].sort((a, b) => (a.name || '').localeCompare(b.name || ''))
return { ...state, components: { ...state.components, [category]: merged } }
}
case 'ADD_NODE': {
const { componentData, x, y } = action.payload
const isFilter = componentData.protocolType === 'filter_point'
const newNode = {
id: uuidv4(),
protocolUuid: componentData.uuid,
type: componentData.protocolType,
name: componentData.name,
description: componentData.description,
x,
y,
componentData,
parameters: Object.values(componentData.parameters || {}).flat().reduce((acc, param) => {
acc[param.name] = param.default !== undefined ? param.default : null
return acc
}, {}),
...(isFilter && { filterParams: { maxCounter: 1, altTarget: null } })
}
return { ...state, nodes: [...state.nodes, newNode], selectedNodeId: newNode.id }
}
case 'UPDATE_NODE': {
const { id, ...updates } = action.payload
return {
...state,
nodes: state.nodes.map(node =>
node.id === id ? { ...node, ...updates } : node
)
}
}
case 'UPDATE_NODE_POSITION': {
const { id, x, y } = action.payload
const oldNode = state.nodes.find(n => n.id === id)
if (!oldNode) return state
const dx = x - oldNode.x
const dy = y - oldNode.y
// Update node position
const newNodes = state.nodes.map(node =>
node.id === id ? { ...node, x, y } : node
)
// Update connection points attached to this node
const newConnections = state.connections.map(conn => {
let updated = { ...conn }
if (conn.sourceId === id && conn.sourcePoint) {
updated.sourcePoint = {
x: conn.sourcePoint.x + dx,
y: conn.sourcePoint.y + dy
}
}
if (conn.targetId === id && conn.targetPoint) {
updated.targetPoint = {
x: conn.targetPoint.x + dx,
y: conn.targetPoint.y + dy
}
}
return updated
})
return {
...state,
nodes: newNodes,
connections: newConnections
}
}
case 'DELETE_NODE': {
const nodeId = action.payload
return {
...state,
nodes: state.nodes.filter(n => n.id !== nodeId),
connections: state.connections.filter(
c => c.sourceId !== nodeId && c.targetId !== nodeId
),
selectedNodeId: state.selectedNodeId === nodeId ? null : state.selectedNodeId
}
}
case 'SELECT_NODE':
return {
...state,
selectedNodeId: action.payload,
selectedConnectionId: null,
previewedComponent: null
}
case 'SET_PREVIEWED_COMPONENT':
return {
...state,
previewedComponent: action.payload,
selectedNodeId: null,
selectedConnectionId: null
}
case 'ADD_CONNECTION': {
const { sourceId, targetId, sourcePoint, targetPoint } = action.payload
const exists = state.connections.some(
c => c.sourceId === sourceId && c.targetId === targetId
)
if (exists || sourceId === targetId) return state
const newConnection = {
id: uuidv4(),
sourceId,
targetId,
sourcePoint,
targetPoint
}
return {
...state,
connections: [...state.connections, newConnection],
connectingFrom: null
}
}
case 'DELETE_CONNECTION':
return {
...state,
connections: state.connections.filter(c => c.id !== action.payload),
selectedConnectionId: state.selectedConnectionId === action.payload ? null : state.selectedConnectionId
}
case 'UPDATE_CONNECTION_PORTS': {
const { id, sourcePoint, targetPoint } = action.payload
return {
...state,
connections: state.connections.map(conn =>
conn.id === id ? { ...conn, sourcePoint, targetPoint } : conn
)
}
}
case 'SELECT_CONNECTION':
return {
...state,
selectedConnectionId: action.payload,
selectedNodeId: null
}
case 'START_CONNECTING':
return { ...state, connectingFrom: action.payload }
case 'CANCEL_CONNECTING':
return { ...state, connectingFrom: null }
case 'SET_ZOOM':
return { ...state, zoom: Math.max(0.25, Math.min(2, action.payload)) }
case 'SET_PAN':
return { ...state, pan: action.payload }
case 'LOAD_WORKFLOW': {
const { nodes, connections } = action.payload
return {
...state,
nodes,
connections,
selectedNodeId: null,
selectedConnectionId: null
}
}
case 'CLEAR_CANVAS':
return {
...state,
nodes: [],
connections: [],
selectedNodeId: null,
selectedConnectionId: null
}
case 'DESELECT_ALL':
return {
...state,
selectedNodeId: null,
selectedConnectionId: null,
connectingFrom: null,
previewedComponent: null
}
case 'START_DRAG':
// Save current state before drag begins (for undo)
return {
...state,
dragStartState: { nodes: state.nodes, connections: state.connections }
}
case 'END_DRAG':
// Clear drag state - history entry is added by undoableReducer
return { ...state, dragStartState: null }
case 'UNDO': {
if (state.past.length === 0) return state
const previous = state.past[state.past.length - 1]
const newPast = state.past.slice(0, -1)
return {
...state,
nodes: previous.nodes,
connections: previous.connections,
past: newPast,
future: [{ nodes: state.nodes, connections: state.connections }, ...state.future],
selectedNodeId: null,
selectedConnectionId: null
}
}
case 'REDO': {
if (state.future.length === 0) return state
const next = state.future[0]
const newFuture = state.future.slice(1)
return {
...state,
nodes: next.nodes,
connections: next.connections,
past: [...state.past, { nodes: state.nodes, connections: state.connections }],
future: newFuture,
selectedNodeId: null,
selectedConnectionId: null
}
}
default:
return state
}
}
/**
* Reducer wrapper that layers undo/redo history management over
* workflowReducer. UNDO/REDO are delegated directly; END_DRAG commits the
* pre-drag snapshot as a single history entry; other undoable actions push the
* prior state onto the history stack (capped at MAX_HISTORY_SIZE) and clear the
* redo future when nodes or connections actually change. Non-undoable actions
* pass through unchanged.
*
* @param {Object} state - The current workflow state.
* @param {Object} action - The dispatched action with a `type` and optional `payload`.
* @returns {Object} The next workflow state, including updated history.
*/
// Wrapper reducer that handles history for undoable actions
function undoableReducer(state, action) {
// Handle undo/redo directly
if (action.type === 'UNDO' || action.type === 'REDO') {
return workflowReducer(state, action)
}
// Special handling for END_DRAG - use saved dragStartState for history
if (action.type === 'END_DRAG') {
const newState = workflowReducer(state, action)
// Only add to history if we have a saved drag start state and something changed
if (state.dragStartState) {
const nodesChanged = state.nodes !== state.dragStartState.nodes
const connectionsChanged = state.connections !== state.dragStartState.connections
if (nodesChanged || connectionsChanged) {
const pastEntry = state.dragStartState
const newPast = [...state.past, pastEntry].slice(-MAX_HISTORY_SIZE)
return {
...newState,
past: newPast,
future: [] // Clear future on new action
}
}
}
return newState
}
// For other undoable actions, save current state to history before applying
if (UNDOABLE_ACTIONS.includes(action.type)) {
const newState = workflowReducer(state, action)
// Only add to history if nodes or connections actually changed
const nodesChanged = newState.nodes !== state.nodes
const connectionsChanged = newState.connections !== state.connections
if (nodesChanged || connectionsChanged) {
const pastEntry = { nodes: state.nodes, connections: state.connections }
const newPast = [...state.past, pastEntry].slice(-MAX_HISTORY_SIZE)
return {
...newState,
past: newPast,
future: [] // Clear future on new action
}
}
return newState
}
// Non-undoable actions pass through normally
return workflowReducer(state, action)
}
/**
* Context provider that owns the workflow reducer state and dispatch, making
* them available to descendant components. On mount it loads the component
* catalog via loadComponents (local backend, or bundled JSON for the web build).
*
* @param {Object} props - Component props.
* @param {JSX.Element} props.children - The subtree that consumes the context.
* @returns {JSX.Element} The provider wrapping its children.
*/
export function WorkflowProvider({ children }) {
const [state, dispatch] = useReducer(undoableReducer, initialState)
useEffect(() => {
loadComponents()
.then(data => dispatch({ type: 'SET_COMPONENTS', payload: data }))
.catch(err => console.error('Failed to load components:', err))
}, [])
return (
<WorkflowContext.Provider value={{ state, dispatch }}>
{children}
</WorkflowContext.Provider>
)
}
/**
* Custom hook for accessing the workflow context. Must be called within a
* WorkflowProvider, otherwise it throws.
*
* @returns {Object} The context value: `{ state, dispatch }`.
*/
export function useWorkflow() {
const context = useContext(WorkflowContext)
if (!context) {
throw new Error('useWorkflow must be used within a WorkflowProvider')
}
return context
}