diff --git a/src/frontend/src/builder/NodePropertiesPanel.tsx b/src/frontend/src/builder/NodePropertiesPanel.tsx index 8ba28f1..886641a 100644 --- a/src/frontend/src/builder/NodePropertiesPanel.tsx +++ b/src/frontend/src/builder/NodePropertiesPanel.tsx @@ -1,6 +1,6 @@ import type { NodeType } from '../onboarding/types/flow' import type { FlowDraft, FlowDraftConnection, FlowDraftNode } from './flowAuthoring' -import { connectionToEdgeId } from './VisualJourneyCanvas' +import { connectionToEdgeId } from './visualJourneyCanvasUtils' const NODE_TYPES: NodeType[] = ['Form', 'DocumentUpload', 'Redirect', 'Information', 'Logic'] diff --git a/src/frontend/src/builder/VisualJourneyCanvas.test.tsx b/src/frontend/src/builder/VisualJourneyCanvas.test.tsx index 3bd350e..ff022bd 100644 --- a/src/frontend/src/builder/VisualJourneyCanvas.test.tsx +++ b/src/frontend/src/builder/VisualJourneyCanvas.test.tsx @@ -6,7 +6,7 @@ import { getNodeStyle, connectionToEdgeId, draftConnectionsToEdges, -} from './VisualJourneyCanvas' +} from './visualJourneyCanvasUtils' describe('NODE_TYPE_STYLES', () => { const types: NodeType[] = ['Form', 'DocumentUpload', 'Redirect', 'Information', 'Logic'] diff --git a/src/frontend/src/builder/VisualJourneyCanvas.tsx b/src/frontend/src/builder/VisualJourneyCanvas.tsx index a0d64bb..8ea801f 100644 --- a/src/frontend/src/builder/VisualJourneyCanvas.tsx +++ b/src/frontend/src/builder/VisualJourneyCanvas.tsx @@ -1,11 +1,8 @@ -import { useState, useCallback, useEffect, useRef, useMemo } from 'react' +import { useCallback, useMemo, useState } from 'react' import ReactFlow, { Background, Controls, MiniMap, - applyNodeChanges, - applyEdgeChanges, - addEdge, type Node, type Edge, type NodeChange, @@ -14,52 +11,11 @@ import ReactFlow, { } from 'reactflow' import 'reactflow/dist/style.css' import type { FlowDraft, FlowDraftNode, FlowDraftConnection } from './flowAuthoring' -import type { NodeType } from '../onboarding/types/flow' - -export const NODE_TYPE_STYLES: Record< - NodeType, - { background: string; borderColor: string; color: string } -> = { - Form: { background: '#dbeafe', borderColor: '#3b82f6', color: '#1e3a8a' }, - DocumentUpload: { background: '#ede9fe', borderColor: '#8b5cf6', color: '#4c1d95' }, - Redirect: { background: '#fef3c7', borderColor: '#f59e0b', color: '#78350f' }, - Information: { background: '#d1fae5', borderColor: '#10b981', color: '#064e3b' }, - Logic: { background: '#ffedd5', borderColor: '#f97316', color: '#7c2d12' }, -} - -export function getNodeStyle( - node: Pick, -): React.CSSProperties { - const colors = NODE_TYPE_STYLES[node.type] - return { - background: colors.background, - borderColor: colors.borderColor, - color: colors.color, - fontWeight: node.isStartNode ? 700 : 400, - border: `2px solid ${colors.borderColor}`, - borderRadius: 8, - padding: '6px 10px', - fontSize: 12, - outline: node.isStartNode ? `3px solid ${colors.borderColor}` : undefined, - outlineOffset: node.isStartNode ? 3 : undefined, - } -} - -export function connectionToEdgeId(conn: FlowDraftConnection): string { - return `${conn.sourceNodeId}__${conn.targetNodeId}__${conn.priority}` -} - -export function draftConnectionsToEdges(connections: FlowDraftConnection[]): Edge[] { - return connections.map((conn) => { - const parts = [conn.conditionField, conn.conditionOperator, conn.conditionValue].filter(Boolean) - return { - id: connectionToEdgeId(conn), - source: conn.sourceNodeId, - target: conn.targetNodeId, - label: parts.length > 0 ? (parts.join(' ') as string) : undefined, - } - }) -} +import { + connectionToEdgeId, + draftConnectionsToEdges, + getNodeStyle, +} from './visualJourneyCanvasUtils' function computeInitialPositions( nodes: FlowDraftNode[], @@ -106,27 +62,6 @@ function buildNodeLabel(node: FlowDraftNode): string { return `${node.isStartNode ? '⭐ ' : ''}${node.title} [${node.type}]` } -function draftNodesToRfNodes( - nodes: FlowDraftNode[], - positions: Map, - existingById: Map, -): Node[] { - return nodes.map((node, index) => { - const existing = existingById.get(node.id) - const position = - existing?.position ?? - positions.get(node.id) ?? - { x: (index % 4) * 240, y: 50 + Math.floor(index / 4) * 120 } - return { - id: node.id, - data: { label: buildNodeLabel(node) }, - position, - style: getNodeStyle(node), - selected: existing?.selected ?? false, - } - }) -} - type VisualJourneyCanvasProps = { draft: FlowDraft onChange: (draft: FlowDraft) => void @@ -144,62 +79,68 @@ export function VisualJourneyCanvas({ onSelectNode, onSelectEdge, }: VisualJourneyCanvasProps) { - const positionsRef = useRef>( - computeInitialPositions(draft.nodes, draft.connections), + // Persisted user-driven positions only (updated via drag in onNodesChange). + const [positions, setPositions] = useState>( + () => new Map(), ) - const [rfNodes, setRfNodes] = useState(() => - draftNodesToRfNodes(draft.nodes, positionsRef.current, new Map()), + // Computed layout positions for any node that doesn't have a persisted position. + const layoutPositions = useMemo( + () => computeInitialPositions(draft.nodes, draft.connections), + [draft.nodes, draft.connections], ) - const [rfEdges, setRfEdges] = useState(() => - draftConnectionsToEdges(draft.connections), - ) - - // Sync when draft changes (e.g., from properties panel updates or node additions) - const prevDraftRef = useRef(draft) - useEffect(() => { - if (draft === prevDraftRef.current) return - prevDraftRef.current = draft - setRfNodes((prev) => { - const existingById = new Map(prev.map((n) => [n.id, n])) - return draftNodesToRfNodes(draft.nodes, positionsRef.current, existingById) + const rfNodes = useMemo(() => { + return draft.nodes.map((node, index) => { + const persisted = positions.get(node.id) + const layout = layoutPositions.get(node.id) + const position = + persisted ?? + layout ?? { x: (index % 4) * 240, y: 50 + Math.floor(index / 4) * 120 } + return { + id: node.id, + data: { label: buildNodeLabel(node) }, + position, + style: getNodeStyle(node), + selected: node.id === selectedNodeId, + } }) - setRfEdges( + }, [draft.nodes, layoutPositions, positions, selectedNodeId]) + + const rfEdges = useMemo( + () => draftConnectionsToEdges(draft.connections).map((edge) => ({ ...edge, selected: edge.id === selectedEdgeId, })), - ) - }, [draft, selectedEdgeId]) - - // Keep selected state in sync with external selection - useEffect(() => { - setRfNodes((prev) => - prev.map((n) => ({ ...n, selected: n.id === selectedNodeId })), - ) - }, [selectedNodeId]) - - useEffect(() => { - setRfEdges((prev) => - prev.map((e) => ({ ...e, selected: e.id === selectedEdgeId })), - ) - }, [selectedEdgeId]) + [draft.connections, selectedEdgeId], + ) const onNodesChange = useCallback( (changes: NodeChange[]) => { - // Track position updates persistently - for (const change of changes) { - if (change.type === 'position' && change.position) { - positionsRef.current.set(change.id, change.position) - } - } - - setRfNodes((nds) => applyNodeChanges(changes, nds)) + // Track position updates persistently so they survive re-renders. + const positionUpdates = changes.filter( + (c): c is NodeChange & { type: 'position'; id: string; position: { x: number; y: number } } => + c.type === 'position' && !!c.position, + ) const removedIds = new Set( changes.filter((c) => c.type === 'remove').map((c) => c.id), ) + + if (positionUpdates.length > 0 || removedIds.size > 0) { + setPositions((prev) => { + const next = new Map(prev) + for (const change of positionUpdates) { + next.set(change.id, change.position) + } + for (const id of removedIds) { + next.delete(id) + } + return next + }) + } + if (removedIds.size > 0) { onChange({ ...draft, @@ -215,8 +156,6 @@ export function VisualJourneyCanvas({ const onEdgesChange = useCallback( (changes: EdgeChange[]) => { - setRfEdges((eds) => applyEdgeChanges(changes, eds)) - const removedIds = new Set( changes.filter((c) => c.type === 'remove').map((c) => c.id), ) @@ -242,25 +181,16 @@ export function VisualJourneyCanvas({ (c) => c.sourceNodeId === connection.source && c.targetNodeId === connection.target, ).length, } - const newEdge = { - id: connectionToEdgeId(newConn), - source: newConn.sourceNodeId, - target: newConn.targetNodeId, - } - setRfEdges((eds) => addEdge(newEdge, eds)) onChange({ ...draft, connections: [...draft.connections, newConn] }) }, [draft, onChange], ) - const stableNodes = useMemo(() => rfNodes, [rfNodes]) - const stableEdges = useMemo(() => rfEdges, [rfEdges]) - return (
= { + Form: { background: '#dbeafe', borderColor: '#3b82f6', color: '#1e3a8a' }, + DocumentUpload: { background: '#ede9fe', borderColor: '#8b5cf6', color: '#4c1d95' }, + Redirect: { background: '#fef3c7', borderColor: '#f59e0b', color: '#78350f' }, + Information: { background: '#d1fae5', borderColor: '#10b981', color: '#064e3b' }, + Logic: { background: '#ffedd5', borderColor: '#f97316', color: '#7c2d12' }, +} + +export function getNodeStyle( + node: Pick, +): CSSProperties { + const colors = NODE_TYPE_STYLES[node.type] + return { + background: colors.background, + borderColor: colors.borderColor, + color: colors.color, + fontWeight: node.isStartNode ? 700 : 400, + border: `2px solid ${colors.borderColor}`, + borderRadius: 8, + padding: '6px 10px', + fontSize: 12, + outline: node.isStartNode ? `3px solid ${colors.borderColor}` : undefined, + outlineOffset: node.isStartNode ? 3 : undefined, + } +} + +export function connectionToEdgeId(conn: FlowDraftConnection): string { + return `${conn.sourceNodeId}__${conn.targetNodeId}__${conn.priority}` +} + +export function draftConnectionsToEdges(connections: FlowDraftConnection[]): Edge[] { + return connections.map((conn) => { + const parts = [conn.conditionField, conn.conditionOperator, conn.conditionValue].filter(Boolean) + return { + id: connectionToEdgeId(conn), + source: conn.sourceNodeId, + target: conn.targetNodeId, + label: parts.length > 0 ? (parts.join(' ') as string) : undefined, + } + }) +} diff --git a/src/frontend/src/onboarding/hooks/useFlow.ts b/src/frontend/src/onboarding/hooks/useFlow.ts index 3d1ea79..4860dce 100644 --- a/src/frontend/src/onboarding/hooks/useFlow.ts +++ b/src/frontend/src/onboarding/hooks/useFlow.ts @@ -1,4 +1,4 @@ -import { useEffect, useState } from 'react' +import { useEffect, useReducer } from 'react' import type { FlowDefinition } from '../types/flow' const API_KEY = import.meta.env.VITE_API_KEY as string | undefined @@ -13,31 +13,61 @@ async function fetchFlowDefinition(flowId: string): Promise { return response.json() as Promise } +type FlowState = { + flow: FlowDefinition | null + isLoading: boolean + error: string | null +} + +type FlowAction = + | { type: 'reset'; isLoading: boolean } + | { type: 'success'; flow: FlowDefinition } + | { type: 'failure'; error: string } + +function flowReducer(_state: FlowState, action: FlowAction): FlowState { + switch (action.type) { + case 'reset': + return { flow: null, isLoading: action.isLoading, error: null } + case 'success': + return { flow: action.flow, isLoading: false, error: null } + case 'failure': + return { flow: null, isLoading: false, error: action.error } + } +} + +function initFlowState(flowId: string | null): FlowState { + return { flow: null, isLoading: flowId !== null, error: null } +} + export function useFlow(flowId: string | null) { - const [flow, setFlow] = useState(null) - const [isLoading, setIsLoading] = useState(flowId !== null) - const [error, setError] = useState(null) + const [state, dispatch] = useReducer(flowReducer, flowId, initFlowState) useEffect(() => { - if (!flowId) return - setIsLoading(true) - setError(null) - const controller = new AbortController() + if (!flowId) { + dispatch({ type: 'reset', isLoading: false }) + return + } + + let cancelled = false + dispatch({ type: 'reset', isLoading: true }) + fetchFlowDefinition(flowId) .then((data) => { - if (!controller.signal.aborted) { - setFlow(data) - setIsLoading(false) - } + if (!cancelled) dispatch({ type: 'success', flow: data }) }) .catch((err: unknown) => { - if (!controller.signal.aborted) { - setError(err instanceof Error ? err.message : 'Failed to load flow') - setIsLoading(false) + if (!cancelled) { + dispatch({ + type: 'failure', + error: err instanceof Error ? err.message : 'Failed to load flow', + }) } }) - return () => controller.abort() + + return () => { + cancelled = true + } }, [flowId]) - return { flow, isLoading, error } + return state }