feat: introdce store
This commit is contained in:
@@ -34,7 +34,8 @@ import { useCanvasGraph } from '@/app/canvas/useCanvasGraph'
|
||||
import { getExampleGraph, backfillEdgeTargetTypes } from '@/app/canvas/canvasGraphUtils'
|
||||
import { ContextMenu, ContextMenuTrigger } from '@/components/ui/context-menu'
|
||||
import { CanvasContextMenuContent } from '@/app/canvas/CanvasContextMenuContent'
|
||||
import { useCanvasConnectionPath } from '@/app/canvas/useCanvasConnectionPath'
|
||||
import { useCanvasConnectionPathFromStore } from '@/app/canvas/useCanvasConnectionPathFromStore'
|
||||
import { dispatchCanvasCommand } from '@/app/canvas/canvasStore'
|
||||
import { CanvasMenubar } from '@/app/canvas/CanvasMenubar'
|
||||
import { createContextualNode } from '@/app/canvas/ContextualZoomNode'
|
||||
import { ViewportDisplayProvider } from '@/app/canvas/ViewportDisplayContext'
|
||||
@@ -186,7 +187,11 @@ export function CanvasPage({ projectId }: CanvasPageProps) {
|
||||
const [isSelecting, setIsSelecting] = React.useState(false)
|
||||
const [ariaAnnouncement, setAriaAnnouncement] = React.useState<string | null>(null)
|
||||
const [fullscreenNodeId, setFullscreenNodeId] = React.useState<string | null>(null)
|
||||
const connectionPath = useCanvasConnectionPath(edges)
|
||||
useEffect(() => {
|
||||
dispatchCanvasCommand({ type: 'graph/apply', payload: { nodes, edges } })
|
||||
}, [nodes, edges])
|
||||
|
||||
const connectionPath = useCanvasConnectionPathFromStore()
|
||||
|
||||
const nodesRef = useRef(nodes)
|
||||
nodesRef.current = nodes
|
||||
|
||||
31
frontend/src/app/canvas/canvasStore.index.ts
Normal file
31
frontend/src/app/canvas/canvasStore.index.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* Canvas store: centralized state + commands + selectors.
|
||||
* Entry point for store usage.
|
||||
*/
|
||||
|
||||
export {
|
||||
getCanvasStore,
|
||||
dispatchCanvasCommand,
|
||||
useCanvasStore,
|
||||
useCanvasStoreDispatch,
|
||||
canvasStore,
|
||||
} from './canvasStore'
|
||||
export type { CanvasStore, CanvasCommand } from './canvasStore'
|
||||
export { initialCanvasStore, canvasStoreReducer } from './canvasStore.reducer'
|
||||
export type { CanvasStore as CanvasStoreState, PathSlice, UISlice, GraphSlice, ConnectionFrom } from './canvasStore.types'
|
||||
export {
|
||||
selectGraph,
|
||||
selectPath,
|
||||
selectUI,
|
||||
selectNodes,
|
||||
selectEdges,
|
||||
selectPathNodeIds,
|
||||
selectPathPausedSegmentNodeIds,
|
||||
selectPathActiveSegmentNodeIds,
|
||||
selectConnectionStatusForEdge,
|
||||
selectPathRoleForNode,
|
||||
selectRenamingNodeId,
|
||||
selectFullscreenNodeId,
|
||||
selectConnectionFrom,
|
||||
} from './canvasStore.selectors'
|
||||
export type { ConnectionPathRole } from './canvasStore.selectors'
|
||||
143
frontend/src/app/canvas/canvasStore.reducer.ts
Normal file
143
frontend/src/app/canvas/canvasStore.reducer.ts
Normal file
@@ -0,0 +1,143 @@
|
||||
/**
|
||||
* Pure reducer for the canvas store. One command → one state transition.
|
||||
*/
|
||||
|
||||
import type { AppNode, AppEdge } from '@/lib/graph/nodeTypes'
|
||||
import type { CanvasStore, CanvasCommand, GraphSlice, PathSlice, UISlice } from './canvasStore.types'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Initial state
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const initialGraph: GraphSlice = {
|
||||
nodes: [],
|
||||
edges: [],
|
||||
}
|
||||
|
||||
const initialPath: PathSlice = {
|
||||
updatingNodeIds: [],
|
||||
triggerNodeIds: [],
|
||||
pausedNodeIds: [],
|
||||
errorNodeIds: [],
|
||||
}
|
||||
|
||||
const initialUI: UISlice = {
|
||||
renamingNodeId: null,
|
||||
fullscreenNodeId: null,
|
||||
connectionFrom: null,
|
||||
}
|
||||
|
||||
export const initialCanvasStore: CanvasStore = {
|
||||
graph: initialGraph,
|
||||
path: initialPath,
|
||||
ui: initialUI,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Reducer
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function reduceGraph(prev: GraphSlice, cmd: CanvasCommand): GraphSlice {
|
||||
switch (cmd.type) {
|
||||
case 'graph/setNodes': {
|
||||
const next =
|
||||
typeof cmd.payload === 'function' ? cmd.payload(prev.nodes) : cmd.payload
|
||||
return { ...prev, nodes: next }
|
||||
}
|
||||
case 'graph/setEdges': {
|
||||
const next =
|
||||
typeof cmd.payload === 'function' ? cmd.payload(prev.edges) : cmd.payload
|
||||
return { ...prev, edges: next }
|
||||
}
|
||||
case 'graph/apply': {
|
||||
return {
|
||||
nodes: cmd.payload.nodes ?? prev.nodes,
|
||||
edges: cmd.payload.edges ?? prev.edges,
|
||||
}
|
||||
}
|
||||
default:
|
||||
return prev
|
||||
}
|
||||
}
|
||||
|
||||
function reducePath(prev: PathSlice, cmd: CanvasCommand): PathSlice {
|
||||
switch (cmd.type) {
|
||||
case 'path/addTrigger': {
|
||||
const id = cmd.payload
|
||||
if (prev.triggerNodeIds.includes(id)) return prev
|
||||
return { ...prev, triggerNodeIds: [...prev.triggerNodeIds, id] }
|
||||
}
|
||||
case 'path/clearTriggers':
|
||||
return { ...prev, triggerNodeIds: [] }
|
||||
case 'path/startUpdate': {
|
||||
const id = cmd.payload
|
||||
if (prev.updatingNodeIds.includes(id)) return prev
|
||||
return { ...prev, updatingNodeIds: [...prev.updatingNodeIds, id] }
|
||||
}
|
||||
case 'path/endUpdate': {
|
||||
const id = cmd.payload
|
||||
return {
|
||||
...prev,
|
||||
updatingNodeIds: prev.updatingNodeIds.filter((x) => x !== id),
|
||||
}
|
||||
}
|
||||
case 'path/setPaused': {
|
||||
const { nodeId, paused } = cmd.payload
|
||||
const has = prev.pausedNodeIds.includes(nodeId)
|
||||
if (paused === has) return prev
|
||||
return {
|
||||
...prev,
|
||||
pausedNodeIds: paused
|
||||
? [...prev.pausedNodeIds, nodeId]
|
||||
: prev.pausedNodeIds.filter((x) => x !== nodeId),
|
||||
}
|
||||
}
|
||||
case 'path/setError': {
|
||||
const { nodeId, error } = cmd.payload
|
||||
const has = prev.errorNodeIds.includes(nodeId)
|
||||
if (error === has) return prev
|
||||
return {
|
||||
...prev,
|
||||
errorNodeIds: error
|
||||
? [...prev.errorNodeIds, nodeId]
|
||||
: prev.errorNodeIds.filter((x) => x !== nodeId),
|
||||
}
|
||||
}
|
||||
case 'path/clearPathSession':
|
||||
return {
|
||||
...initialPath,
|
||||
errorNodeIds: prev.errorNodeIds,
|
||||
}
|
||||
case 'path/clearErrors':
|
||||
return { ...prev, errorNodeIds: [] }
|
||||
default:
|
||||
return prev
|
||||
}
|
||||
}
|
||||
|
||||
function reduceUI(prev: UISlice, cmd: CanvasCommand): UISlice {
|
||||
switch (cmd.type) {
|
||||
case 'ui/setRenaming':
|
||||
return { ...prev, renamingNodeId: cmd.payload }
|
||||
case 'ui/setFullscreen':
|
||||
return { ...prev, fullscreenNodeId: cmd.payload }
|
||||
case 'ui/setConnectionFrom':
|
||||
return { ...prev, connectionFrom: cmd.payload }
|
||||
default:
|
||||
return prev
|
||||
}
|
||||
}
|
||||
|
||||
export function canvasStoreReducer(state: CanvasStore, command: CanvasCommand): CanvasStore {
|
||||
const graph = reduceGraph(state.graph, command)
|
||||
const path = reducePath(state.path, command)
|
||||
const ui = reduceUI(state.ui, command)
|
||||
if (
|
||||
graph === state.graph &&
|
||||
path === state.path &&
|
||||
ui === state.ui
|
||||
) {
|
||||
return state
|
||||
}
|
||||
return { graph, path, ui }
|
||||
}
|
||||
129
frontend/src/app/canvas/canvasStore.selectors.ts
Normal file
129
frontend/src/app/canvas/canvasStore.selectors.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
/**
|
||||
* Selectors for the canvas store. Pure (state) => value.
|
||||
* Derived path sets (pathNodeIds, pausedSegmentNodeIds, activeSegmentNodeIds) are computed here.
|
||||
*/
|
||||
|
||||
import { getPathNodeIds, getPausedSegmentNodeIds } from '@/lib/graph/graphPath'
|
||||
import { getConnectionStatus, type ConnectionStatus } from '@/lib/graph/connectionStatus'
|
||||
import type { CanvasStore } from './canvasStore.types'
|
||||
|
||||
export type ConnectionPathRole = 'trigger' | 'updating' | 'on-path' | null
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Raw slices
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function selectGraph(state: CanvasStore) {
|
||||
return state.graph
|
||||
}
|
||||
|
||||
export function selectPath(state: CanvasStore) {
|
||||
return state.path
|
||||
}
|
||||
|
||||
export function selectUI(state: CanvasStore) {
|
||||
return state.ui
|
||||
}
|
||||
|
||||
export function selectNodes(state: CanvasStore) {
|
||||
return state.graph.nodes
|
||||
}
|
||||
|
||||
export function selectEdges(state: CanvasStore) {
|
||||
return state.graph.edges
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Derived path (Sets) – depend on graph.edges + path primitive arrays
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const emptySet = new Set<string>()
|
||||
|
||||
function edgesAsGraphEdges(edges: CanvasStore['graph']['edges']) {
|
||||
return edges.map((e) => ({ source: e.source, target: e.target }))
|
||||
}
|
||||
|
||||
export function selectPathNodeIds(state: CanvasStore): Set<string> {
|
||||
const { edges } = state.graph
|
||||
const { updatingNodeIds, triggerNodeIds, pausedNodeIds } = state.path
|
||||
return getPathNodeIds(
|
||||
edgesAsGraphEdges(edges),
|
||||
updatingNodeIds,
|
||||
triggerNodeIds,
|
||||
pausedNodeIds
|
||||
)
|
||||
}
|
||||
|
||||
export function selectPathPausedSegmentNodeIds(state: CanvasStore): Set<string> {
|
||||
const pathNodeIds = selectPathNodeIds(state)
|
||||
const { edges } = state.graph
|
||||
const { triggerNodeIds, pausedNodeIds } = state.path
|
||||
return getPausedSegmentNodeIds(
|
||||
edgesAsGraphEdges(edges),
|
||||
pathNodeIds,
|
||||
triggerNodeIds,
|
||||
pausedNodeIds
|
||||
)
|
||||
}
|
||||
|
||||
export function selectPathActiveSegmentNodeIds(state: CanvasStore): Set<string> {
|
||||
const pathNodeIds = selectPathNodeIds(state)
|
||||
const pausedSegment = selectPathPausedSegmentNodeIds(state)
|
||||
const active = new Set(pathNodeIds)
|
||||
pausedSegment.forEach((id) => active.delete(id))
|
||||
return active
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Per-edge connection status (for AnimatedEdge)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function selectConnectionStatusForEdge(
|
||||
state: CanvasStore,
|
||||
source: string,
|
||||
target: string
|
||||
): ConnectionStatus {
|
||||
const pathNodeIds = selectPathNodeIds(state)
|
||||
const pausedSegmentNodeIds = selectPathPausedSegmentNodeIds(state)
|
||||
const activeSegmentNodeIds = selectPathActiveSegmentNodeIds(state)
|
||||
const errorTargetNodeIds = new Set(state.path.errorNodeIds)
|
||||
return getConnectionStatus({
|
||||
source,
|
||||
target,
|
||||
pathNodeIds,
|
||||
pausedSegmentNodeIds,
|
||||
activeSegmentNodeIds,
|
||||
errorTargetNodeIds,
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Per-node path role (for BaseNode styling)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function selectPathRoleForNode(
|
||||
state: CanvasStore,
|
||||
nodeId: string
|
||||
): ConnectionPathRole {
|
||||
const pathNodeIds = selectPathNodeIds(state)
|
||||
if (!pathNodeIds.has(nodeId)) return null
|
||||
if (state.path.triggerNodeIds.includes(nodeId)) return 'trigger'
|
||||
if (state.path.updatingNodeIds.includes(nodeId)) return 'updating'
|
||||
return 'on-path'
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// UI
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function selectRenamingNodeId(state: CanvasStore): string | null {
|
||||
return state.ui.renamingNodeId
|
||||
}
|
||||
|
||||
export function selectFullscreenNodeId(state: CanvasStore): string | null {
|
||||
return state.ui.fullscreenNodeId
|
||||
}
|
||||
|
||||
export function selectConnectionFrom(state: CanvasStore) {
|
||||
return state.ui.connectionFrom
|
||||
}
|
||||
399
frontend/src/app/canvas/canvasStore.test.ts
Normal file
399
frontend/src/app/canvas/canvasStore.test.ts
Normal file
@@ -0,0 +1,399 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest'
|
||||
import {
|
||||
initialCanvasStore,
|
||||
canvasStoreReducer,
|
||||
getCanvasStore,
|
||||
dispatchCanvasCommand,
|
||||
} from './canvasStore'
|
||||
import {
|
||||
selectPathNodeIds,
|
||||
selectPathPausedSegmentNodeIds,
|
||||
selectPathActiveSegmentNodeIds,
|
||||
selectConnectionStatusForEdge,
|
||||
selectPathRoleForNode,
|
||||
selectNodes,
|
||||
selectEdges,
|
||||
selectRenamingNodeId,
|
||||
selectFullscreenNodeId,
|
||||
selectConnectionFrom,
|
||||
} from './canvasStore.selectors'
|
||||
import type { CanvasStore, CanvasCommand } from './canvasStore.types'
|
||||
import type { AppNode, AppEdge } from '@/lib/graph/nodeTypes'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fixtures
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function makeNode(id: string, type = 'config'): AppNode {
|
||||
return {
|
||||
id,
|
||||
type,
|
||||
position: { x: 0, y: 0 },
|
||||
data: {},
|
||||
}
|
||||
}
|
||||
|
||||
function makeEdge(id: string, source: string, target: string): AppEdge {
|
||||
return { id, source, target }
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Reducer: graph commands
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('canvasStoreReducer', () => {
|
||||
describe('graph commands', () => {
|
||||
it('graph/setNodes replaces nodes', () => {
|
||||
const state: CanvasStore = {
|
||||
...initialCanvasStore,
|
||||
graph: {
|
||||
nodes: [makeNode('a')],
|
||||
edges: [],
|
||||
},
|
||||
}
|
||||
const next = canvasStoreReducer(state, {
|
||||
type: 'graph/setNodes',
|
||||
payload: [makeNode('b'), makeNode('c')],
|
||||
})
|
||||
expect(next.graph.nodes).toHaveLength(2)
|
||||
expect(next.graph.nodes.map((n) => n.id)).toEqual(['b', 'c'])
|
||||
expect(next.graph.edges).toEqual(state.graph.edges)
|
||||
})
|
||||
|
||||
it('graph/setNodes with updater function', () => {
|
||||
const state: CanvasStore = {
|
||||
...initialCanvasStore,
|
||||
graph: {
|
||||
nodes: [makeNode('a'), makeNode('b')],
|
||||
edges: [],
|
||||
},
|
||||
}
|
||||
const next = canvasStoreReducer(state, {
|
||||
type: 'graph/setNodes',
|
||||
payload: (prev) => prev.filter((n) => n.id !== 'a'),
|
||||
})
|
||||
expect(next.graph.nodes).toHaveLength(1)
|
||||
expect(next.graph.nodes[0].id).toBe('b')
|
||||
})
|
||||
|
||||
it('graph/setEdges replaces edges', () => {
|
||||
const state: CanvasStore = {
|
||||
...initialCanvasStore,
|
||||
graph: {
|
||||
nodes: [],
|
||||
edges: [makeEdge('e1', 'a', 'b')],
|
||||
},
|
||||
}
|
||||
const next = canvasStoreReducer(state, {
|
||||
type: 'graph/setEdges',
|
||||
payload: [makeEdge('e2', 'b', 'c')],
|
||||
})
|
||||
expect(next.graph.edges).toHaveLength(1)
|
||||
expect(next.graph.edges[0].id).toBe('e2')
|
||||
})
|
||||
|
||||
it('graph/apply updates nodes and edges', () => {
|
||||
const state: CanvasStore = {
|
||||
...initialCanvasStore,
|
||||
graph: {
|
||||
nodes: [makeNode('a')],
|
||||
edges: [makeEdge('e1', 'a', 'b')],
|
||||
},
|
||||
}
|
||||
const next = canvasStoreReducer(state, {
|
||||
type: 'graph/apply',
|
||||
payload: { nodes: [makeNode('x')] },
|
||||
})
|
||||
expect(next.graph.nodes).toHaveLength(1)
|
||||
expect(next.graph.nodes[0].id).toBe('x')
|
||||
expect(next.graph.edges).toEqual(state.graph.edges)
|
||||
})
|
||||
})
|
||||
|
||||
describe('path commands', () => {
|
||||
it('path/addTrigger adds node id', () => {
|
||||
const state: CanvasStore = { ...initialCanvasStore }
|
||||
const next = canvasStoreReducer(state, {
|
||||
type: 'path/addTrigger',
|
||||
payload: 'n1',
|
||||
})
|
||||
expect(next.path.triggerNodeIds).toEqual(['n1'])
|
||||
})
|
||||
|
||||
it('path/addTrigger is idempotent', () => {
|
||||
const state: CanvasStore = {
|
||||
...initialCanvasStore,
|
||||
path: { ...initialCanvasStore.path, triggerNodeIds: ['n1'] },
|
||||
}
|
||||
const next = canvasStoreReducer(state, {
|
||||
type: 'path/addTrigger',
|
||||
payload: 'n1',
|
||||
})
|
||||
expect(next).toBe(state)
|
||||
})
|
||||
|
||||
it('path/clearTriggers empties triggerNodeIds', () => {
|
||||
const state: CanvasStore = {
|
||||
...initialCanvasStore,
|
||||
path: { ...initialCanvasStore.path, triggerNodeIds: ['a', 'b'] },
|
||||
}
|
||||
const next = canvasStoreReducer(state, { type: 'path/clearTriggers' })
|
||||
expect(next.path.triggerNodeIds).toEqual([])
|
||||
})
|
||||
|
||||
it('path/startUpdate adds to updatingNodeIds', () => {
|
||||
const state: CanvasStore = { ...initialCanvasStore }
|
||||
const next = canvasStoreReducer(state, {
|
||||
type: 'path/startUpdate',
|
||||
payload: 'n1',
|
||||
})
|
||||
expect(next.path.updatingNodeIds).toEqual(['n1'])
|
||||
})
|
||||
|
||||
it('path/endUpdate removes from updatingNodeIds', () => {
|
||||
const state: CanvasStore = {
|
||||
...initialCanvasStore,
|
||||
path: {
|
||||
...initialCanvasStore.path,
|
||||
updatingNodeIds: ['n1', 'n2'],
|
||||
},
|
||||
}
|
||||
const next = canvasStoreReducer(state, {
|
||||
type: 'path/endUpdate',
|
||||
payload: 'n1',
|
||||
})
|
||||
expect(next.path.updatingNodeIds).toEqual(['n2'])
|
||||
})
|
||||
|
||||
it('path/setPaused adds and removes paused node', () => {
|
||||
const state: CanvasStore = { ...initialCanvasStore }
|
||||
let next = canvasStoreReducer(state, {
|
||||
type: 'path/setPaused',
|
||||
payload: { nodeId: 'n1', paused: true },
|
||||
})
|
||||
expect(next.path.pausedNodeIds).toEqual(['n1'])
|
||||
next = canvasStoreReducer(next, {
|
||||
type: 'path/setPaused',
|
||||
payload: { nodeId: 'n1', paused: false },
|
||||
})
|
||||
expect(next.path.pausedNodeIds).toEqual([])
|
||||
})
|
||||
|
||||
it('path/setError adds and removes error node', () => {
|
||||
const state: CanvasStore = { ...initialCanvasStore }
|
||||
let next = canvasStoreReducer(state, {
|
||||
type: 'path/setError',
|
||||
payload: { nodeId: 'n1', error: true },
|
||||
})
|
||||
expect(next.path.errorNodeIds).toEqual(['n1'])
|
||||
next = canvasStoreReducer(next, {
|
||||
type: 'path/setError',
|
||||
payload: { nodeId: 'n1', error: false },
|
||||
})
|
||||
expect(next.path.errorNodeIds).toEqual([])
|
||||
})
|
||||
|
||||
it('path/clearPathSession resets updating, trigger, paused; keeps error', () => {
|
||||
const state: CanvasStore = {
|
||||
...initialCanvasStore,
|
||||
path: {
|
||||
updatingNodeIds: ['u1'],
|
||||
triggerNodeIds: ['t1'],
|
||||
pausedNodeIds: ['p1'],
|
||||
errorNodeIds: ['e1'],
|
||||
},
|
||||
}
|
||||
const next = canvasStoreReducer(state, { type: 'path/clearPathSession' })
|
||||
expect(next.path.updatingNodeIds).toEqual([])
|
||||
expect(next.path.triggerNodeIds).toEqual([])
|
||||
expect(next.path.pausedNodeIds).toEqual([])
|
||||
expect(next.path.errorNodeIds).toEqual(['e1'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('ui commands', () => {
|
||||
it('ui/setRenaming updates renamingNodeId', () => {
|
||||
const state: CanvasStore = { ...initialCanvasStore }
|
||||
const next = canvasStoreReducer(state, {
|
||||
type: 'ui/setRenaming',
|
||||
payload: 'node-1',
|
||||
})
|
||||
expect(next.ui.renamingNodeId).toBe('node-1')
|
||||
})
|
||||
|
||||
it('ui/setFullscreen updates fullscreenNodeId', () => {
|
||||
const state: CanvasStore = { ...initialCanvasStore }
|
||||
const next = canvasStoreReducer(state, {
|
||||
type: 'ui/setFullscreen',
|
||||
payload: 'node-2',
|
||||
})
|
||||
expect(next.ui.fullscreenNodeId).toBe('node-2')
|
||||
})
|
||||
|
||||
it('ui/setConnectionFrom updates connectionFrom', () => {
|
||||
const state: CanvasStore = { ...initialCanvasStore }
|
||||
const next = canvasStoreReducer(state, {
|
||||
type: 'ui/setConnectionFrom',
|
||||
payload: { nodeId: 'n1', sourceHandle: 'out' },
|
||||
})
|
||||
expect(next.ui.connectionFrom).toEqual({ nodeId: 'n1', sourceHandle: 'out' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('no-op returns same reference', () => {
|
||||
it('path/addTrigger with existing id returns state', () => {
|
||||
const state: CanvasStore = {
|
||||
...initialCanvasStore,
|
||||
path: { ...initialCanvasStore.path, triggerNodeIds: ['a'] },
|
||||
}
|
||||
const next = canvasStoreReducer(state, { type: 'path/addTrigger', payload: 'a' })
|
||||
expect(next).toBe(state)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Selectors
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('canvasStore selectors', () => {
|
||||
it('selectNodes and selectEdges return graph slice', () => {
|
||||
const state: CanvasStore = {
|
||||
...initialCanvasStore,
|
||||
graph: {
|
||||
nodes: [makeNode('a')],
|
||||
edges: [makeEdge('e1', 'a', 'b')],
|
||||
},
|
||||
}
|
||||
expect(selectNodes(state)).toHaveLength(1)
|
||||
expect(selectEdges(state)).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('selectPathNodeIds derives path from edges and path arrays', () => {
|
||||
const state: CanvasStore = {
|
||||
...initialCanvasStore,
|
||||
graph: {
|
||||
nodes: [makeNode('a'), makeNode('b'), makeNode('c')],
|
||||
edges: [
|
||||
makeEdge('e1', 'a', 'b'),
|
||||
makeEdge('e2', 'b', 'c'),
|
||||
],
|
||||
},
|
||||
path: {
|
||||
updatingNodeIds: ['c'],
|
||||
triggerNodeIds: ['a'],
|
||||
pausedNodeIds: [],
|
||||
errorNodeIds: [],
|
||||
},
|
||||
}
|
||||
const pathIds = selectPathNodeIds(state)
|
||||
expect(pathIds.has('a')).toBe(true)
|
||||
expect(pathIds.has('b')).toBe(true)
|
||||
expect(pathIds.has('c')).toBe(true)
|
||||
})
|
||||
|
||||
it('selectConnectionStatusForEdge returns default when edge not on path', () => {
|
||||
const state: CanvasStore = {
|
||||
...initialCanvasStore,
|
||||
graph: { nodes: [], edges: [makeEdge('e1', 'a', 'b')] },
|
||||
path: initialCanvasStore.path,
|
||||
}
|
||||
expect(selectConnectionStatusForEdge(state, 'a', 'b')).toBe('default')
|
||||
})
|
||||
|
||||
it('selectConnectionStatusForEdge returns error when target has error', () => {
|
||||
const state: CanvasStore = {
|
||||
...initialCanvasStore,
|
||||
graph: {
|
||||
nodes: [],
|
||||
edges: [makeEdge('e1', 'a', 'b')],
|
||||
},
|
||||
path: {
|
||||
...initialCanvasStore.path,
|
||||
triggerNodeIds: ['a'],
|
||||
updatingNodeIds: ['b'],
|
||||
pausedNodeIds: [],
|
||||
errorNodeIds: ['b'],
|
||||
},
|
||||
}
|
||||
expect(selectConnectionStatusForEdge(state, 'a', 'b')).toBe('error')
|
||||
})
|
||||
|
||||
it('selectPathRoleForNode returns trigger when node in triggerNodeIds', () => {
|
||||
const state: CanvasStore = {
|
||||
...initialCanvasStore,
|
||||
graph: {
|
||||
nodes: [],
|
||||
edges: [makeEdge('e1', 'a', 'b')],
|
||||
},
|
||||
path: {
|
||||
...initialCanvasStore.path,
|
||||
triggerNodeIds: ['a'],
|
||||
updatingNodeIds: ['b'],
|
||||
pausedNodeIds: [],
|
||||
errorNodeIds: [],
|
||||
},
|
||||
}
|
||||
expect(selectPathRoleForNode(state, 'a')).toBe('trigger')
|
||||
expect(selectPathRoleForNode(state, 'b')).toBe('updating')
|
||||
expect(selectPathRoleForNode(state, 'x')).toBe(null)
|
||||
})
|
||||
|
||||
it('selectRenamingNodeId, selectFullscreenNodeId, selectConnectionFrom return ui slice', () => {
|
||||
const state: CanvasStore = {
|
||||
...initialCanvasStore,
|
||||
ui: {
|
||||
renamingNodeId: 'r1',
|
||||
fullscreenNodeId: 'f1',
|
||||
connectionFrom: { nodeId: 'c1' },
|
||||
},
|
||||
}
|
||||
expect(selectRenamingNodeId(state)).toBe('r1')
|
||||
expect(selectFullscreenNodeId(state)).toBe('f1')
|
||||
expect(selectConnectionFrom(state)).toEqual({ nodeId: 'c1' })
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Store integration (dispatch + getState)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('canvas store integration', () => {
|
||||
beforeEach(() => {
|
||||
dispatchCanvasCommand({ type: 'graph/setNodes', payload: [] })
|
||||
dispatchCanvasCommand({ type: 'graph/setEdges', payload: [] })
|
||||
dispatchCanvasCommand({ type: 'path/clearPathSession' })
|
||||
dispatchCanvasCommand({ type: 'path/clearTriggers' })
|
||||
dispatchCanvasCommand({ type: 'path/clearErrors' })
|
||||
dispatchCanvasCommand({ type: 'ui/setRenaming', payload: null })
|
||||
dispatchCanvasCommand({ type: 'ui/setFullscreen', payload: null })
|
||||
dispatchCanvasCommand({ type: 'ui/setConnectionFrom', payload: null })
|
||||
})
|
||||
|
||||
it('dispatch graph/setNodes updates getCanvasStore().graph.nodes', () => {
|
||||
const node = makeNode('test-1')
|
||||
dispatchCanvasCommand({ type: 'graph/setNodes', payload: [node] })
|
||||
const state = getCanvasStore()
|
||||
expect(state.graph.nodes).toHaveLength(1)
|
||||
expect(state.graph.nodes[0].id).toBe('test-1')
|
||||
})
|
||||
|
||||
it('dispatch path/addTrigger updates path and selectPathNodeIds', () => {
|
||||
dispatchCanvasCommand({ type: 'graph/setNodes', payload: [makeNode('a'), makeNode('b')] })
|
||||
dispatchCanvasCommand({ type: 'graph/setEdges', payload: [makeEdge('e1', 'a', 'b')] })
|
||||
dispatchCanvasCommand({ type: 'path/addTrigger', payload: 'a' })
|
||||
dispatchCanvasCommand({ type: 'path/startUpdate', payload: 'b' })
|
||||
const state = getCanvasStore()
|
||||
expect(state.path.triggerNodeIds).toContain('a')
|
||||
expect(state.path.updatingNodeIds).toContain('b')
|
||||
const pathIds = selectPathNodeIds(state)
|
||||
expect(pathIds.has('a')).toBe(true)
|
||||
expect(pathIds.has('b')).toBe(true)
|
||||
})
|
||||
|
||||
it('dispatch ui/setFullscreen updates getCanvasStore().ui', () => {
|
||||
dispatchCanvasCommand({ type: 'ui/setFullscreen', payload: 'full-node' })
|
||||
const state = getCanvasStore()
|
||||
expect(state.ui.fullscreenNodeId).toBe('full-node')
|
||||
})
|
||||
})
|
||||
74
frontend/src/app/canvas/canvasStore.ts
Normal file
74
frontend/src/app/canvas/canvasStore.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
/**
|
||||
* Centralized canvas store (Zustand). Single source of truth for graph, path, and UI.
|
||||
* Mutate only via dispatch(command); read via useCanvasStore(selector) or getCanvasStore().
|
||||
*/
|
||||
|
||||
import { createStore, useStore } from 'zustand'
|
||||
import type { CanvasStore as CanvasStoreState, CanvasCommand } from './canvasStore.types'
|
||||
import { initialCanvasStore, canvasStoreReducer } from './canvasStore.reducer'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Store type (state + dispatch)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type CanvasStoreWithDispatch = CanvasStoreState & {
|
||||
dispatch: (command: CanvasCommand) => void
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Create store
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function createCanvasStore() {
|
||||
return createStore<CanvasStoreWithDispatch>((set, get) => {
|
||||
const applyCommand = (command: CanvasCommand) => {
|
||||
const prev = get()
|
||||
const next = canvasStoreReducer(prev, command)
|
||||
if (next === prev) return
|
||||
set({ ...next, dispatch: prev.dispatch })
|
||||
}
|
||||
const dispatch: (command: CanvasCommand) => void =
|
||||
typeof import.meta !== 'undefined' && import.meta.env?.DEV
|
||||
? (command) => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log('[canvas]', command.type, command.payload)
|
||||
applyCommand(command)
|
||||
}
|
||||
: applyCommand
|
||||
return { ...initialCanvasStore, dispatch }
|
||||
})
|
||||
}
|
||||
|
||||
const canvasStore = createCanvasStore()
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public API
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Get current state (for use outside React or in selectors). Excludes dispatch. */
|
||||
export function getCanvasStore(): CanvasStoreState {
|
||||
const s = canvasStore.getState()
|
||||
return { graph: s.graph, path: s.path, ui: s.ui }
|
||||
}
|
||||
|
||||
/** Dispatch a command. Use for all mutations. */
|
||||
export function dispatchCanvasCommand(command: CanvasCommand): void {
|
||||
canvasStore.getState().dispatch(command)
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to the store. Pass a selector to re-render only when the selected value changes.
|
||||
* For object/array selectors consider useShallow from 'zustand/react/shallow' to avoid unnecessary re-renders.
|
||||
*/
|
||||
export function useCanvasStore<T>(selector: (state: CanvasStoreState) => T): T {
|
||||
return useStore(canvasStore, selector)
|
||||
}
|
||||
|
||||
/** Hook that returns dispatch (stable reference). */
|
||||
export function useCanvasStoreDispatch(): (command: CanvasCommand) => void {
|
||||
return useStore(canvasStore, (s) => s.dispatch, Object.is)
|
||||
}
|
||||
|
||||
export { canvasStore, initialCanvasStore, canvasStoreReducer }
|
||||
export type { CanvasStoreState as CanvasStore }
|
||||
export type { CanvasCommand }
|
||||
68
frontend/src/app/canvas/canvasStore.types.ts
Normal file
68
frontend/src/app/canvas/canvasStore.types.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* Canvas store: types for the centralized store (graph + path + ui).
|
||||
* All mutations go through commands; reads go through selectors.
|
||||
*/
|
||||
|
||||
import type { AppNode, AppEdge } from '@/lib/graph/nodeTypes'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Graph slice
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type GraphSlice = {
|
||||
nodes: AppNode[]
|
||||
edges: AppEdge[]
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Path slice (primitive arrays; derived Sets are in selectors)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type PathSlice = {
|
||||
updatingNodeIds: string[]
|
||||
triggerNodeIds: string[]
|
||||
pausedNodeIds: string[]
|
||||
errorNodeIds: string[]
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// UI slice
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type ConnectionFrom = { nodeId: string; sourceHandle?: string } | null
|
||||
|
||||
export type UISlice = {
|
||||
renamingNodeId: string | null
|
||||
fullscreenNodeId: string | null
|
||||
connectionFrom: ConnectionFrom
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Full store
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type CanvasStore = {
|
||||
graph: GraphSlice
|
||||
path: PathSlice
|
||||
ui: UISlice
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Commands (discriminated union)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type CanvasCommand =
|
||||
| { type: 'graph/setNodes'; payload: AppNode[] | ((prev: AppNode[]) => AppNode[]) }
|
||||
| { type: 'graph/setEdges'; payload: AppEdge[] | ((prev: AppEdge[]) => AppEdge[]) }
|
||||
| { type: 'graph/apply'; payload: { nodes?: AppNode[]; edges?: AppEdge[] } }
|
||||
| { type: 'path/addTrigger'; payload: string }
|
||||
| { type: 'path/clearTriggers' }
|
||||
| { type: 'path/startUpdate'; payload: string }
|
||||
| { type: 'path/endUpdate'; payload: string }
|
||||
| { type: 'path/setPaused'; payload: { nodeId: string; paused: boolean } }
|
||||
| { type: 'path/setError'; payload: { nodeId: string; error: boolean } }
|
||||
| { type: 'path/clearPathSession' }
|
||||
| { type: 'path/clearErrors' }
|
||||
| { type: 'ui/setRenaming'; payload: string | null }
|
||||
| { type: 'ui/setFullscreen'; payload: string | null }
|
||||
| { type: 'ui/setConnectionFrom'; payload: ConnectionFrom }
|
||||
154
frontend/src/app/canvas/useCanvasConnectionPathFromStore.ts
Normal file
154
frontend/src/app/canvas/useCanvasConnectionPathFromStore.ts
Normal file
@@ -0,0 +1,154 @@
|
||||
/**
|
||||
* Connection-path state and callbacks backed by the canvas store.
|
||||
* Replaces useCanvasConnectionPath when the store is the source of truth for path.
|
||||
* Sync graph to store (nodes, edges) from CanvasPage so path selectors have current edges.
|
||||
*
|
||||
* Subscribes only to path and edges (stable refs); derived Sets are computed in useMemo
|
||||
* so getSnapshot stays stable and we avoid "Maximum update depth" / getSnapshot loops.
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef } from 'react'
|
||||
import {
|
||||
dispatchCanvasCommand,
|
||||
useCanvasStore,
|
||||
} from '@/app/canvas/canvasStore'
|
||||
import {
|
||||
selectPathRoleForNode,
|
||||
type ConnectionPathRole,
|
||||
} from '@/app/canvas/canvasStore.selectors'
|
||||
import { getPathNodeIds, getPausedSegmentNodeIds } from '@/lib/graph/graphPath'
|
||||
import type { UseCanvasConnectionPathResult } from './useCanvasConnectionPath'
|
||||
|
||||
const CONNECTION_PATH_UPDATE_TAIL_MS = 200
|
||||
|
||||
function edgesAsGraphEdges(
|
||||
edges: Array<{ source: string; target: string }>
|
||||
): Array<{ source: string; target: string }> {
|
||||
return edges.map((e) => ({ source: e.source, target: e.target }))
|
||||
}
|
||||
|
||||
export function useCanvasConnectionPathFromStore(): UseCanvasConnectionPathResult {
|
||||
const path = useCanvasStore((s) => s.path)
|
||||
const edges = useCanvasStore((s) => s.graph.edges)
|
||||
|
||||
const pathNodeIds = useMemo(
|
||||
() =>
|
||||
getPathNodeIds(
|
||||
edgesAsGraphEdges(edges),
|
||||
path.updatingNodeIds,
|
||||
path.triggerNodeIds,
|
||||
path.pausedNodeIds
|
||||
),
|
||||
[
|
||||
edges,
|
||||
path.updatingNodeIds,
|
||||
path.triggerNodeIds,
|
||||
path.pausedNodeIds,
|
||||
]
|
||||
)
|
||||
const connectionPathPausedSegmentNodeIds = useMemo(
|
||||
() =>
|
||||
getPausedSegmentNodeIds(
|
||||
edgesAsGraphEdges(edges),
|
||||
pathNodeIds,
|
||||
path.triggerNodeIds,
|
||||
path.pausedNodeIds
|
||||
),
|
||||
[edges, pathNodeIds, path.triggerNodeIds, path.pausedNodeIds]
|
||||
)
|
||||
const connectionPathActiveSegmentNodeIds = useMemo(() => {
|
||||
const active = new Set(pathNodeIds)
|
||||
connectionPathPausedSegmentNodeIds.forEach((id) => active.delete(id))
|
||||
return active
|
||||
}, [pathNodeIds, connectionPathPausedSegmentNodeIds])
|
||||
|
||||
const pathUpdateEndTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const prevUpdatingLengthRef = useRef(path.updatingNodeIds.length)
|
||||
|
||||
const startConnectionPathUpdate = useCallback((nodeId: string) => {
|
||||
dispatchCanvasCommand({ type: 'path/startUpdate', payload: nodeId })
|
||||
}, [])
|
||||
|
||||
const endConnectionPathUpdate = useCallback((nodeId: string) => {
|
||||
dispatchCanvasCommand({ type: 'path/endUpdate', payload: nodeId })
|
||||
}, [])
|
||||
|
||||
const addConnectionPathTrigger = useCallback((nodeId: string) => {
|
||||
dispatchCanvasCommand({ type: 'path/addTrigger', payload: nodeId })
|
||||
}, [])
|
||||
|
||||
const addConnectionPathPausedNode = useCallback((nodeId: string) => {
|
||||
dispatchCanvasCommand({
|
||||
type: 'path/setPaused',
|
||||
payload: { nodeId, paused: true },
|
||||
})
|
||||
}, [])
|
||||
|
||||
const removeConnectionPathPausedNode = useCallback((nodeId: string) => {
|
||||
dispatchCanvasCommand({
|
||||
type: 'path/setPaused',
|
||||
payload: { nodeId, paused: false },
|
||||
})
|
||||
}, [])
|
||||
|
||||
const addConnectionPathError = useCallback((nodeId: string) => {
|
||||
dispatchCanvasCommand({
|
||||
type: 'path/setError',
|
||||
payload: { nodeId, error: true },
|
||||
})
|
||||
}, [])
|
||||
|
||||
const removeConnectionPathError = useCallback((nodeId: string) => {
|
||||
dispatchCanvasCommand({
|
||||
type: 'path/setError',
|
||||
payload: { nodeId, error: false },
|
||||
})
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const prev = prevUpdatingLengthRef.current
|
||||
const now = path.updatingNodeIds.length
|
||||
prevUpdatingLengthRef.current = now
|
||||
if (prev > 0 && now === 0) {
|
||||
if (pathUpdateEndTimeoutRef.current != null) {
|
||||
clearTimeout(pathUpdateEndTimeoutRef.current)
|
||||
}
|
||||
pathUpdateEndTimeoutRef.current = setTimeout(() => {
|
||||
pathUpdateEndTimeoutRef.current = null
|
||||
dispatchCanvasCommand({ type: 'path/clearPathSession' })
|
||||
}, CONNECTION_PATH_UPDATE_TAIL_MS)
|
||||
}
|
||||
return () => {
|
||||
if (pathUpdateEndTimeoutRef.current != null) {
|
||||
clearTimeout(pathUpdateEndTimeoutRef.current)
|
||||
}
|
||||
}
|
||||
}, [path.updatingNodeIds.length])
|
||||
|
||||
return {
|
||||
connectionPathUpdatingNodeIds: path.updatingNodeIds,
|
||||
connectionPathTriggerNodeIds: path.triggerNodeIds,
|
||||
connectionPathPausedNodeIds: path.pausedNodeIds,
|
||||
connectionPathErrorNodeIds: path.errorNodeIds,
|
||||
connectionPathNodeIds: pathNodeIds,
|
||||
connectionPathPausedSegmentNodeIds: connectionPathPausedSegmentNodeIds,
|
||||
connectionPathActiveSegmentNodeIds: connectionPathActiveSegmentNodeIds,
|
||||
startConnectionPathUpdate,
|
||||
endConnectionPathUpdate,
|
||||
addConnectionPathTrigger,
|
||||
addConnectionPathPausedNode,
|
||||
removeConnectionPathPausedNode,
|
||||
addConnectionPathError,
|
||||
removeConnectionPathError,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Path role for a node (trigger / updating / on-path) from the store.
|
||||
* Use in node components so they only re-render when their path role changes.
|
||||
*/
|
||||
export function useConnectionPathRoleFromStore(nodeId: string | undefined): ConnectionPathRole {
|
||||
return useCanvasStore((s) =>
|
||||
nodeId != null ? selectPathRoleForNode(s, nodeId) : null
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user