feat: introdce store
This commit is contained in:
218
frontend/docs/CANVAS_STATE_DESIGN.md
Normal file
218
frontend/docs/CANVAS_STATE_DESIGN.md
Normal file
@@ -0,0 +1,218 @@
|
|||||||
|
# Canvas state: design pattern for controlled, centralized, debuggable flow
|
||||||
|
|
||||||
|
This doc proposes a **store + commands + selectors** pattern so canvas state is:
|
||||||
|
|
||||||
|
- **Controlled** – every change goes through one place
|
||||||
|
- **Centralized** – one store holds graph, path, and UI slices
|
||||||
|
- **Predictable** – same action → same state transition; easy to reason about
|
||||||
|
- **Easier to debug** – log commands, inspect store, optional time-travel
|
||||||
|
|
||||||
|
It complements [CANVAS_PERFORMANCE_OPTIONS.md](./CANVAS_PERFORMANCE_OPTIONS.md) and [state.ts](../src/lib/graph/state.ts).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Core idea: single store + commands + selectors
|
||||||
|
|
||||||
|
### 1.1 Single store (one source of truth)
|
||||||
|
|
||||||
|
Keep all canvas-related state in **one store** with **slices**:
|
||||||
|
|
||||||
|
```
|
||||||
|
Store
|
||||||
|
├── graph: { nodes, edges } // current graph (with history if needed)
|
||||||
|
├── path: ConnectionPathState // trigger/updating/paused/error node IDs
|
||||||
|
├── ui: FlowUIState // renaming, fullscreen, connectionFrom, etc.
|
||||||
|
└── (optional) history: HistoryState // undo/redo stack
|
||||||
|
```
|
||||||
|
|
||||||
|
- **No duplicate sources**: nodes/edges live only in the store, not in context + refs.
|
||||||
|
- **Reads**: components get data via **selectors** (e.g. `useStore(s => s.graph.nodes)` or `useStore(selectPathForEdge, edgeId)`).
|
||||||
|
- **Writes**: only via **commands** (e.g. `dispatch({ type: 'graph/setNodes', payload: updater })`).
|
||||||
|
|
||||||
|
### 1.2 Commands (controlled mutations)
|
||||||
|
|
||||||
|
Every mutation is a **command** (action):
|
||||||
|
|
||||||
|
- **Graph**: `graph/setNodes`, `graph/setEdges`, `graph/applySilent` (position), `graph/undo`, `graph/redo`
|
||||||
|
- **Path**: `path/addTrigger`, `path/startUpdate`, `path/endUpdate`, `path/setPaused`, `path/setError`
|
||||||
|
- **UI**: `ui/setRenaming`, `ui/setFullscreen`, `ui/setConnectionFrom`
|
||||||
|
|
||||||
|
Benefits:
|
||||||
|
|
||||||
|
- **Predictable**: one command → one reducer → one new state; no scattered `setState` in hooks.
|
||||||
|
- **Traceable**: log every command (and payload) in dev; replay or inspect.
|
||||||
|
- **Testable**: test reducers with command + prev state → next state.
|
||||||
|
- **Time-travel (optional)**: store past states or inverse deltas per command for debug UI.
|
||||||
|
|
||||||
|
### 1.3 Selectors (derived state and subscriptions)
|
||||||
|
|
||||||
|
**Selectors** are pure functions `(state) => value`. They:
|
||||||
|
|
||||||
|
- **Derive** values (e.g. path node IDs from trigger/updating/paused).
|
||||||
|
- **Scope** data (e.g. “incoming edges for node X”, “connection status for edge Y”).
|
||||||
|
- **Stabilize** references when the logical value hasn’t changed (e.g. same path IDs → same Set reference).
|
||||||
|
|
||||||
|
Components **subscribe via selectors**:
|
||||||
|
|
||||||
|
- `useStore(selectGraph)` → re-render when `graph` slice changes.
|
||||||
|
- `useStore(selectPathNodeIds)` → re-render only when path node IDs change.
|
||||||
|
- `useStore(selectConnectionStatusForEdge, edgeId)` → re-render only when that edge’s status changes.
|
||||||
|
|
||||||
|
So:
|
||||||
|
|
||||||
|
- **Centralized**: all reads go through the store.
|
||||||
|
- **Predictable**: same state in → same selector out.
|
||||||
|
- **Performance**: only components whose selected value changed re-render (with a store that supports shallow equality, e.g. Zustand).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Data structures
|
||||||
|
|
||||||
|
### 2.1 Store shape (TypeScript)
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// Slices match current concepts; easy to migrate from existing state.ts + useCanvasConnectionPath.
|
||||||
|
|
||||||
|
interface CanvasStore {
|
||||||
|
graph: {
|
||||||
|
nodes: AppNode[]
|
||||||
|
edges: AppEdge[]
|
||||||
|
}
|
||||||
|
path: ConnectionPathState // from state.ts
|
||||||
|
ui: FlowUIState
|
||||||
|
// optional, for undo/redo
|
||||||
|
_history?: {
|
||||||
|
past: HistoryDelta[]
|
||||||
|
future: HistoryDelta[]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.2 Commands (discriminated union)
|
||||||
|
|
||||||
|
```ts
|
||||||
|
type CanvasCommand =
|
||||||
|
| { type: 'graph/setNodes'; payload: AppNode[] | ((prev: AppNode[]) => AppNode[]) }
|
||||||
|
| { type: 'graph/setEdges'; payload: AppEdge[] | ((prev: AppEdge[]) => AppEdge[]) }
|
||||||
|
| { type: 'graph/applySilent'; payload: (prev: GraphState) => GraphState }
|
||||||
|
| { type: 'path/addTrigger'; payload: string }
|
||||||
|
| { 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: 'ui/setRenaming'; payload: string | null }
|
||||||
|
| { type: 'ui/setFullscreen'; payload: string | null }
|
||||||
|
// ...
|
||||||
|
```
|
||||||
|
|
||||||
|
Single dispatcher:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
function dispatch(cmd: CanvasCommand): void
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.3 Selectors (examples)
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// Raw slices
|
||||||
|
const selectGraph = (s: CanvasStore) => s.graph
|
||||||
|
const selectPath = (s: CanvasStore) => s.path
|
||||||
|
|
||||||
|
// Stable derived path sets (same ref if same IDs)
|
||||||
|
const selectPathNodeIds = (s: CanvasStore) => getPathNodeIds(s.graph.edges, s.path...)
|
||||||
|
|
||||||
|
// Per-edge status (for AnimatedEdge) – only changes when this edge’s status changes
|
||||||
|
const selectConnectionStatusForEdge = (s: CanvasStore, source: string, target: string) =>
|
||||||
|
getConnectionStatus({ source, target, pathNodeIds: s.path.connectionPathNodeIds, ... })
|
||||||
|
|
||||||
|
// Per-node: “am I on path?” (for BaseNode)
|
||||||
|
const selectPathRoleForNode = (s: CanvasStore, nodeId: string) =>
|
||||||
|
getConnectionPathRole(nodeId, s.path)
|
||||||
|
```
|
||||||
|
|
||||||
|
Use with a store that supports **selector + equality** so components only re-render when the selected value actually changes (e.g. Zustand’s `useStore(selector, shallowEqual)` or custom `useSelector`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Why this helps
|
||||||
|
|
||||||
|
| Goal | How the pattern helps |
|
||||||
|
|------|------------------------|
|
||||||
|
| **Controlled** | All writes go through `dispatch(cmd)`. No ad-hoc `setState` in hooks or context. |
|
||||||
|
| **Centralized** | One store; no split between context, refs, and local state for the same concept. |
|
||||||
|
| **Predictable** | One command → one reducer → one new state. Order of updates is explicit. |
|
||||||
|
| **Easier to debug** | Log commands; inspect store (e.g. Redux DevTools or a simple `store.getState()` logger); optional time-travel by replaying or reverting commands. |
|
||||||
|
| **Fewer redraws** | Selectors + equality checks mean components only re-render when their slice or derived value changes. |
|
||||||
|
| **Clear data flow** | Data flow is “store → selectors → components” and “events → commands → store”; no implicit propagation. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Implementation options
|
||||||
|
|
||||||
|
### Option A: Zustand (recommended for React)
|
||||||
|
|
||||||
|
- **Store**: `create<CanvasStore>()` with a `dispatch` that applies commands and updates the store.
|
||||||
|
- **Selectors**: `useCanvasStore(selectPathNodeIds)` etc.; Zustand re-renders only when the selected value changes (with shallow or custom equality).
|
||||||
|
- **Commands**: either one `setState` that takes a reducer, or a separate `dispatch` that maps commands to `setState` calls.
|
||||||
|
- **Debug**: middleware that logs commands and state (or use Redux DevTools with a small adapter).
|
||||||
|
|
||||||
|
### Option B: Redux Toolkit
|
||||||
|
|
||||||
|
- **Store**: one RTK store; slices: `graph`, `path`, `ui`.
|
||||||
|
- **Commands**: RTK actions; reducers are pure and easy to test.
|
||||||
|
- **Selectors**: `createSelector` for derived state; `useSelector` for subscriptions.
|
||||||
|
- **Debug**: Redux DevTools out of the box (time-travel, action log, state diff).
|
||||||
|
|
||||||
|
### Option C: Minimal custom store (no new deps)
|
||||||
|
|
||||||
|
- **Store**: a single `useReducer` (or `useState` + reducer) at the top (e.g. CanvasPage or a provider).
|
||||||
|
- **Commands**: dispatch to the reducer; reducer returns new state by slice.
|
||||||
|
- **Selectors**: pass store (or state) to a `useSelector(store, selector, equality)` hook that subscribes and only re-renders when the selected value changes (e.g. by comparing with `Object.is` or shallow compare).
|
||||||
|
- **Debug**: log `dispatch` and state in dev; optional snapshot history in the reducer.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Migration path from current setup
|
||||||
|
|
||||||
|
1. **Introduce the store** (e.g. Zustand or RTK) next to existing context; keep feeding React Flow and current consumers from the store so behavior stays the same.
|
||||||
|
2. **Move graph state** from `useGraphStateWithHistory` into the store (graph slice + history if needed); keep `setNodes`/`setEdges` as commands that update the store.
|
||||||
|
3. **Move path state** from `useCanvasConnectionPath` into the store (path slice); replace path context with `useStore(selectPath...)` or per-edge/per-node selectors.
|
||||||
|
4. **Move UI state** from CanvasPage `useState` into the store (ui slice); replace FlowUIContext with store selectors.
|
||||||
|
5. **Remove redundant context** (GraphContext, ConnectionPathContext, FlowUIContext) once all reads go through selectors and all writes through commands.
|
||||||
|
6. **Add logging / DevTools** for commands and state; add optional time-travel if desired.
|
||||||
|
|
||||||
|
This can be done slice-by-slice (e.g. path first, then graph, then UI) to keep changes small and testable.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Implementation (Zustand)
|
||||||
|
|
||||||
|
The store is implemented under `frontend/src/app/canvas/`:
|
||||||
|
|
||||||
|
| File | Purpose |
|
||||||
|
|------|---------|
|
||||||
|
| `canvasStore.types.ts` | `CanvasStore`, `CanvasCommand`, slice types |
|
||||||
|
| `canvasStore.reducer.ts` | Pure reducer + `initialCanvasStore` |
|
||||||
|
| `canvasStore.selectors.ts` | Selectors (graph, path derived sets, per-edge status, per-node role) |
|
||||||
|
| `canvasStore.ts` | Zustand store, `getCanvasStore()`, `dispatchCanvasCommand()`, `useCanvasStore()`, `useCanvasStoreDispatch()`; dev logging of commands |
|
||||||
|
| `canvasStore.index.ts` | Re-exports for consumers |
|
||||||
|
| `canvasStore.test.ts` | Test suite (reducer, selectors, store integration) |
|
||||||
|
|
||||||
|
**Run tests:** `npm run test:run` (or `npm run test` for watch) in `frontend/`.
|
||||||
|
|
||||||
|
**Usage:** Import from `@/app/canvas/canvasStore` or `@/app/canvas/canvasStore.index`:
|
||||||
|
|
||||||
|
- `dispatchCanvasCommand({ type: 'graph/setNodes', payload: nodes })`
|
||||||
|
- `useCanvasStore(selectPathNodeIds)` or `useCanvasStore(selectConnectionStatusForEdge, ...)` (selectors take state; for per-edge/per-node use a factory selector in the component)
|
||||||
|
- `useCanvasStoreDispatch()` for stable dispatch in components
|
||||||
|
|
||||||
|
Migration from existing context: feed the store from CanvasPage (or sync store ↔ existing hooks) and gradually replace context consumers with `useCanvasStore(selector)` and `dispatchCanvasCommand`. See §5 migration path.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Summary
|
||||||
|
|
||||||
|
- **Pattern**: one **store** (graph + path + ui), **commands** for all mutations, **selectors** for reads and derived state.
|
||||||
|
- **Data structures**: flat slices in the store; commands as a discriminated union; selectors as pure functions (state [, args]) → value.
|
||||||
|
- **Benefits**: controlled, centralized, predictable, easier to debug, and fewer unnecessary redraws via selector-based subscriptions.
|
||||||
|
- **Concrete next step**: migrate one consumer (e.g. AnimatedEdge) to `useCanvasStore(selectConnectionStatusForEdge)` with a per-edge selector and `dispatchCanvasCommand` for path updates; then remove its ConnectionPathContext dependency.
|
||||||
1593
frontend/package-lock.json
generated
1593
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -5,9 +5,12 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"preview": "vite preview"
|
"preview": "vite preview",
|
||||||
|
"test": "vitest",
|
||||||
|
"test:run": "vitest run"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"zustand": "^5.0.2",
|
||||||
"@radix-ui/react-avatar": "^1.1.11",
|
"@radix-ui/react-avatar": "^1.1.11",
|
||||||
"@radix-ui/react-collapsible": "^1.1.12",
|
"@radix-ui/react-collapsible": "^1.1.12",
|
||||||
"@radix-ui/react-context-menu": "^2.2.16",
|
"@radix-ui/react-context-menu": "^2.2.16",
|
||||||
@@ -44,6 +47,7 @@
|
|||||||
"tailwindcss-animate": "^1.0.7"
|
"tailwindcss-animate": "^1.0.7"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@testing-library/react": "^16.0.0",
|
||||||
"@types/node": "^25.3.3",
|
"@types/node": "^25.3.3",
|
||||||
"@types/react": "^18.0.0",
|
"@types/react": "^18.0.0",
|
||||||
"@types/react-dom": "^18.0.0",
|
"@types/react-dom": "^18.0.0",
|
||||||
@@ -53,6 +57,7 @@
|
|||||||
"shadcn": "^4.0.0",
|
"shadcn": "^4.0.0",
|
||||||
"tailwindcss": "^3.4.0",
|
"tailwindcss": "^3.4.0",
|
||||||
"typescript": "^5.0.0",
|
"typescript": "^5.0.0",
|
||||||
"vite": "^7.3.1"
|
"vite": "^7.3.1",
|
||||||
|
"vitest": "^2.1.6"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,7 +34,8 @@ import { useCanvasGraph } from '@/app/canvas/useCanvasGraph'
|
|||||||
import { getExampleGraph, backfillEdgeTargetTypes } from '@/app/canvas/canvasGraphUtils'
|
import { getExampleGraph, backfillEdgeTargetTypes } from '@/app/canvas/canvasGraphUtils'
|
||||||
import { ContextMenu, ContextMenuTrigger } from '@/components/ui/context-menu'
|
import { ContextMenu, ContextMenuTrigger } from '@/components/ui/context-menu'
|
||||||
import { CanvasContextMenuContent } from '@/app/canvas/CanvasContextMenuContent'
|
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 { CanvasMenubar } from '@/app/canvas/CanvasMenubar'
|
||||||
import { createContextualNode } from '@/app/canvas/ContextualZoomNode'
|
import { createContextualNode } from '@/app/canvas/ContextualZoomNode'
|
||||||
import { ViewportDisplayProvider } from '@/app/canvas/ViewportDisplayContext'
|
import { ViewportDisplayProvider } from '@/app/canvas/ViewportDisplayContext'
|
||||||
@@ -186,7 +187,11 @@ export function CanvasPage({ projectId }: CanvasPageProps) {
|
|||||||
const [isSelecting, setIsSelecting] = React.useState(false)
|
const [isSelecting, setIsSelecting] = React.useState(false)
|
||||||
const [ariaAnnouncement, setAriaAnnouncement] = React.useState<string | null>(null)
|
const [ariaAnnouncement, setAriaAnnouncement] = React.useState<string | null>(null)
|
||||||
const [fullscreenNodeId, setFullscreenNodeId] = 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)
|
const nodesRef = useRef(nodes)
|
||||||
nodesRef.current = 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
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,15 +1,15 @@
|
|||||||
import React, { useContext, useMemo, memo } from 'react'
|
import React, { memo } from 'react'
|
||||||
import {
|
import {
|
||||||
BaseEdge,
|
BaseEdge,
|
||||||
getBezierPath,
|
getBezierPath,
|
||||||
type EdgeProps,
|
type EdgeProps,
|
||||||
} from '@xyflow/react'
|
} from '@xyflow/react'
|
||||||
import { ConnectionPathContext } from '@/lib/graph/flowContext'
|
import { useCanvasStore } from '@/app/canvas/canvasStore'
|
||||||
import { getConnectionStatus, CONNECTION_STATUS_CLASS } from '@/lib/graph/connectionStatus'
|
import { selectConnectionStatusForEdge } from '@/app/canvas/canvasStore.selectors'
|
||||||
|
import { CONNECTION_STATUS_CLASS } from '@/lib/graph/connectionStatus'
|
||||||
|
|
||||||
const EDGE_STROKE_WIDTH = 2
|
const EDGE_STROKE_WIDTH = 2
|
||||||
const DOT_MARKER_R = 1.5
|
const DOT_MARKER_R = 1.5
|
||||||
const EMPTY_PATH_NODE_IDS = new Set<string>()
|
|
||||||
|
|
||||||
function AnimatedEdgeInner({
|
function AnimatedEdgeInner({
|
||||||
id,
|
id,
|
||||||
@@ -26,38 +26,13 @@ function AnimatedEdgeInner({
|
|||||||
target,
|
target,
|
||||||
data,
|
data,
|
||||||
}: EdgeProps) {
|
}: EdgeProps) {
|
||||||
const ctx = useContext(ConnectionPathContext)
|
const connectionStatus = useCanvasStore((s) =>
|
||||||
const pathNodeIds = ctx?.connectionPathNodeIds ?? EMPTY_PATH_NODE_IDS
|
selectConnectionStatusForEdge(s, source, target)
|
||||||
const pausedSegmentNodeIds = ctx?.connectionPathPausedSegmentNodeIds ?? EMPTY_PATH_NODE_IDS
|
|
||||||
const activeSegmentNodeIds = ctx?.connectionPathActiveSegmentNodeIds ?? EMPTY_PATH_NODE_IDS
|
|
||||||
const errorTargetNodeIds = useMemo(
|
|
||||||
() => new Set(ctx?.connectionPathErrorNodeIds ?? []),
|
|
||||||
[ctx?.connectionPathErrorNodeIds]
|
|
||||||
)
|
|
||||||
|
|
||||||
const label = labelProp ?? (data as { connectionLabel?: string } | undefined)?.connectionLabel
|
|
||||||
|
|
||||||
const connectionStatus = useMemo(
|
|
||||||
() =>
|
|
||||||
getConnectionStatus({
|
|
||||||
source,
|
|
||||||
target,
|
|
||||||
pathNodeIds,
|
|
||||||
pausedSegmentNodeIds,
|
|
||||||
activeSegmentNodeIds,
|
|
||||||
errorTargetNodeIds,
|
|
||||||
}),
|
|
||||||
[
|
|
||||||
source,
|
|
||||||
target,
|
|
||||||
pathNodeIds,
|
|
||||||
pausedSegmentNodeIds,
|
|
||||||
activeSegmentNodeIds,
|
|
||||||
errorTargetNodeIds,
|
|
||||||
]
|
|
||||||
)
|
)
|
||||||
const statusClass = CONNECTION_STATUS_CLASS[connectionStatus]
|
const statusClass = CONNECTION_STATUS_CLASS[connectionStatus]
|
||||||
|
|
||||||
|
const label = labelProp ?? (data as { connectionLabel?: string } | undefined)?.connectionLabel
|
||||||
|
|
||||||
const [edgePath, edgeLabelX, edgeLabelY] = getBezierPath({
|
const [edgePath, edgeLabelX, edgeLabelY] = getBezierPath({
|
||||||
sourceX,
|
sourceX,
|
||||||
sourceY,
|
sourceY,
|
||||||
|
|||||||
@@ -2,7 +2,8 @@ import type { ComponentProps, ReactNode } from "react";
|
|||||||
import { NodeResizer } from "@xyflow/react";
|
import { NodeResizer } from "@xyflow/react";
|
||||||
import { useContext } from "react";
|
import { useContext } from "react";
|
||||||
|
|
||||||
import { FlowUIContext, useConnectionPathRole } from "@/lib/graph/flowContext";
|
import { FlowUIContext } from "@/lib/graph/flowContext";
|
||||||
|
import { useConnectionPathRoleFromStore } from "@/app/canvas/useCanvasConnectionPathFromStore";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
/** Default min size for resizable nodes (used by NodeResizer). */
|
/** Default min size for resizable nodes (used by NodeResizer). */
|
||||||
@@ -38,7 +39,7 @@ export function BaseNode({
|
|||||||
}: BaseNodeProps) {
|
}: BaseNodeProps) {
|
||||||
const flowUIContext = useContext(FlowUIContext);
|
const flowUIContext = useContext(FlowUIContext);
|
||||||
const isFullscreenInstance = Boolean(nodeId && flowUIContext?.fullscreenNodeId === nodeId);
|
const isFullscreenInstance = Boolean(nodeId && flowUIContext?.fullscreenNodeId === nodeId);
|
||||||
const connectionPathRole = useConnectionPathRole(nodeId);
|
const connectionPathRole = useConnectionPathRoleFromStore(nodeId);
|
||||||
const hasSize =
|
const hasSize =
|
||||||
dimensions &&
|
dimensions &&
|
||||||
dimensions.width > 0 &&
|
dimensions.width > 0 &&
|
||||||
|
|||||||
@@ -4,10 +4,10 @@
|
|||||||
* pipeline interface.
|
* pipeline interface.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useCallback, useContext, useDeferredValue, useEffect, useMemo, useRef, useState } from 'react'
|
import { useCallback, useDeferredValue, useEffect, useMemo, useRef, useState } from 'react'
|
||||||
import { useAbstractNode } from '@/lib/graph/abstractNode'
|
import { useAbstractNode } from '@/lib/graph/abstractNode'
|
||||||
import { ConnectionPathContext } from '@/lib/graph/flowContext'
|
|
||||||
import { useSyncConnectionStatus } from '@/lib/graph/nodeLifecycle'
|
import { useSyncConnectionStatus } from '@/lib/graph/nodeLifecycle'
|
||||||
|
import { useCanvasStore, dispatchCanvasCommand } from '@/app/canvas/canvasStore'
|
||||||
import { usePlatform } from '@/app/kosmos/KosmosContext'
|
import { usePlatform } from '@/app/kosmos/KosmosContext'
|
||||||
import { getDefaultDataForType, getNextNodeId } from '@/lib/graph/flowUtils'
|
import { getDefaultDataForType, getNextNodeId } from '@/lib/graph/flowUtils'
|
||||||
import { getDefaultStyle } from '@/lib/graph/nodeRegistry'
|
import { getDefaultStyle } from '@/lib/graph/nodeRegistry'
|
||||||
@@ -189,10 +189,7 @@ export function useRenderingNodeState(
|
|||||||
const lastManualRunTriggerRef = useRef(0)
|
const lastManualRunTriggerRef = useRef(0)
|
||||||
const manualRunTriggerSyncedRef = useRef(false)
|
const manualRunTriggerSyncedRef = useRef(false)
|
||||||
|
|
||||||
const pathCtx = useContext(ConnectionPathContext)
|
const triggerNodeIds = useCanvasStore((s) => s.path.triggerNodeIds)
|
||||||
const pathCtxRef = useRef(pathCtx)
|
|
||||||
pathCtxRef.current = pathCtx
|
|
||||||
const triggerNodeIds = pathCtx?.connectionPathTriggerNodeIds ?? []
|
|
||||||
|
|
||||||
const updateDataRef = useRef(updateData)
|
const updateDataRef = useRef(updateData)
|
||||||
updateDataRef.current = updateData
|
updateDataRef.current = updateData
|
||||||
@@ -265,7 +262,7 @@ export function useRenderingNodeState(
|
|||||||
let cancelled = false
|
let cancelled = false
|
||||||
|
|
||||||
const run = async () => {
|
const run = async () => {
|
||||||
pathCtxRef.current?.addConnectionPathTrigger?.(id)
|
dispatchCanvasCommand({ type: 'path/addTrigger', payload: id })
|
||||||
loadingStartedAtRef.current = Date.now()
|
loadingStartedAtRef.current = Date.now()
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
setError(null)
|
setError(null)
|
||||||
|
|||||||
@@ -15,8 +15,9 @@
|
|||||||
* component, then export const MyNode = createAbstractNodeComponent('MyNode', MyNodeComponent).
|
* component, then export const MyNode = createAbstractNodeComponent('MyNode', MyNodeComponent).
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { useCallback, useContext, useMemo, useRef } from 'react'
|
import React, { useCallback, useContext, useMemo } from 'react'
|
||||||
import { GraphContext, ConnectionPathContext } from './flowContext'
|
import { GraphContext } from './flowContext'
|
||||||
|
import { dispatchCanvasCommand } from '@/app/canvas/canvasStore'
|
||||||
import { nodePropsAreEqual } from './flowUtils'
|
import { nodePropsAreEqual } from './flowUtils'
|
||||||
import type { AppNode } from './nodeTypes'
|
import type { AppNode } from './nodeTypes'
|
||||||
|
|
||||||
@@ -81,9 +82,6 @@ export function useAbstractNode<TData = Record<string, unknown>>(
|
|||||||
data: TData
|
data: TData
|
||||||
): AbstractNodeContext<TData> {
|
): AbstractNodeContext<TData> {
|
||||||
const graphCtx = useContext(GraphContext)
|
const graphCtx = useContext(GraphContext)
|
||||||
const pathCtx = useContext(ConnectionPathContext)
|
|
||||||
const addTriggerRef = useRef(pathCtx?.addConnectionPathTrigger)
|
|
||||||
addTriggerRef.current = pathCtx?.addConnectionPathTrigger
|
|
||||||
const nodes = graphCtx?.graphRef?.current?.nodes ?? []
|
const nodes = graphCtx?.graphRef?.current?.nodes ?? []
|
||||||
const edges = graphCtx?.edges ?? []
|
const edges = graphCtx?.edges ?? []
|
||||||
const setNodes = graphCtx?.setNodes
|
const setNodes = graphCtx?.setNodes
|
||||||
@@ -97,7 +95,7 @@ export function useAbstractNode<TData = Record<string, unknown>>(
|
|||||||
n.id === id ? { ...n, data: { ...(n.data as object), ...partial } } : n
|
n.id === id ? { ...n, data: { ...(n.data as object), ...partial } } : n
|
||||||
) as AppNode[]
|
) as AppNode[]
|
||||||
)
|
)
|
||||||
addTriggerRef.current?.(id)
|
dispatchCanvasCommand({ type: 'path/addTrigger', payload: id })
|
||||||
},
|
},
|
||||||
[id, setNodes]
|
[id, setNodes]
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -5,8 +5,7 @@
|
|||||||
* ## State flow
|
* ## State flow
|
||||||
*
|
*
|
||||||
* Nodes report lifecycle (updating / error / paused) via useSyncConnectionStatus(id, state).
|
* Nodes report lifecycle (updating / error / paused) via useSyncConnectionStatus(id, state).
|
||||||
* This hook updates FlowContext sets; edges read them via getConnectionStatus() in connectionStatus.ts.
|
* This hook dispatches to the canvas store; edges read path state via selectors.
|
||||||
* See lib/graph/state.ts for the overall state flow (graph state, connection path state).
|
|
||||||
*
|
*
|
||||||
* ## Lifecycle phases (conceptual)
|
* ## Lifecycle phases (conceptual)
|
||||||
*
|
*
|
||||||
@@ -19,14 +18,14 @@
|
|||||||
* Priority for edge status: error > paused > updating > default.
|
* Priority for edge status: error > paused > updating > default.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useContext, useEffect, useRef } from 'react'
|
import { useEffect, useRef } from 'react'
|
||||||
import { ConnectionPathContext } from './flowContext'
|
import { dispatchCanvasCommand } from '@/app/canvas/canvasStore'
|
||||||
|
|
||||||
export type NodeLifecyclePhase = 'idle' | 'trigger' | 'updating' | 'paused' | 'error'
|
export type NodeLifecyclePhase = 'idle' | 'trigger' | 'updating' | 'paused' | 'error'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* State that drives connection status for this node.
|
* State that drives connection status for this node.
|
||||||
* Pass the current values from your node; the hook syncs them to FlowContext.
|
* Pass the current values from your node; the hook syncs them to the canvas store.
|
||||||
*/
|
*/
|
||||||
export type NodeConnectionStatusState = {
|
export type NodeConnectionStatusState = {
|
||||||
/** Node is doing async work (e.g. loading, running). Incoming/outgoing path edges show blue. */
|
/** Node is doing async work (e.g. loading, running). Incoming/outgoing path edges show blue. */
|
||||||
@@ -38,9 +37,9 @@ export type NodeConnectionStatusState = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Syncs this node's lifecycle state to FlowContext so connection status (edge colors)
|
* Syncs this node's lifecycle state to the canvas store so connection status (edge colors)
|
||||||
* and path animation are correct. Call once per node with the current updating/error/paused
|
* and path animation are correct. Call once per node with the current updating/error/paused
|
||||||
* state; the hook will add/remove this node from the appropriate sets.
|
* state; the hook will dispatch path commands to add/remove this node from the appropriate sets.
|
||||||
*
|
*
|
||||||
* Use in any node that can be updating, in error, or paused:
|
* Use in any node that can be updating, in error, or paused:
|
||||||
*
|
*
|
||||||
@@ -53,39 +52,33 @@ export function useSyncConnectionStatus(
|
|||||||
nodeId: string,
|
nodeId: string,
|
||||||
state: NodeConnectionStatusState
|
state: NodeConnectionStatusState
|
||||||
): void {
|
): void {
|
||||||
const ctx = useContext(ConnectionPathContext)
|
|
||||||
const ctxRef = useRef(ctx)
|
|
||||||
ctxRef.current = ctx
|
|
||||||
const { updating, error, paused } = state
|
const { updating, error, paused } = state
|
||||||
const prevRef = useRef({ updating: false, error: false, paused: false })
|
const prevRef = useRef({ updating: false, error: false, paused: false })
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const pathCtx = ctxRef.current
|
|
||||||
const prev = prevRef.current
|
const prev = prevRef.current
|
||||||
const nowUpdating = Boolean(updating)
|
const nowUpdating = Boolean(updating)
|
||||||
const nowError = Boolean(error)
|
const nowError = Boolean(error)
|
||||||
const nowPaused = Boolean(paused)
|
const nowPaused = Boolean(paused)
|
||||||
|
|
||||||
if (prev.updating !== nowUpdating) {
|
if (prev.updating !== nowUpdating) {
|
||||||
if (nowUpdating) pathCtx?.startConnectionPathUpdate?.(nodeId)
|
if (nowUpdating) dispatchCanvasCommand({ type: 'path/startUpdate', payload: nodeId })
|
||||||
else pathCtx?.endConnectionPathUpdate?.(nodeId)
|
else dispatchCanvasCommand({ type: 'path/endUpdate', payload: nodeId })
|
||||||
prev.updating = nowUpdating
|
prev.updating = nowUpdating
|
||||||
}
|
}
|
||||||
if (prev.error !== nowError) {
|
if (prev.error !== nowError) {
|
||||||
if (nowError) pathCtx?.addConnectionPathError?.(nodeId)
|
dispatchCanvasCommand({ type: 'path/setError', payload: { nodeId, error: nowError } })
|
||||||
else pathCtx?.removeConnectionPathError?.(nodeId)
|
|
||||||
prev.error = nowError
|
prev.error = nowError
|
||||||
}
|
}
|
||||||
if (prev.paused !== nowPaused) {
|
if (prev.paused !== nowPaused) {
|
||||||
if (nowPaused) pathCtx?.addConnectionPathPausedNode?.(nodeId)
|
dispatchCanvasCommand({ type: 'path/setPaused', payload: { nodeId, paused: nowPaused } })
|
||||||
else pathCtx?.removeConnectionPathPausedNode?.(nodeId)
|
|
||||||
prev.paused = nowPaused
|
prev.paused = nowPaused
|
||||||
}
|
}
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
if (prevRef.current.updating) pathCtx?.endConnectionPathUpdate?.(nodeId)
|
if (prevRef.current.updating) dispatchCanvasCommand({ type: 'path/endUpdate', payload: nodeId })
|
||||||
if (prevRef.current.error) pathCtx?.removeConnectionPathError?.(nodeId)
|
if (prevRef.current.error) dispatchCanvasCommand({ type: 'path/setError', payload: { nodeId, error: false } })
|
||||||
if (prevRef.current.paused) pathCtx?.removeConnectionPathPausedNode?.(nodeId)
|
if (prevRef.current.paused) dispatchCanvasCommand({ type: 'path/setPaused', payload: { nodeId, paused: false } })
|
||||||
prevRef.current = { updating: false, error: false, paused: false }
|
prevRef.current = { updating: false, error: false, paused: false }
|
||||||
}
|
}
|
||||||
}, [nodeId, updating, error, paused])
|
}, [nodeId, updating, error, paused])
|
||||||
|
|||||||
15
frontend/vitest.config.ts
Normal file
15
frontend/vitest.config.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import { defineConfig } from 'vitest/config'
|
||||||
|
import path from 'path'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
test: {
|
||||||
|
environment: 'node',
|
||||||
|
globals: true,
|
||||||
|
include: ['src/**/*.test.ts', 'src/**/*.test.tsx'],
|
||||||
|
},
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@': path.resolve(__dirname, './src'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user