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": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
"preview": "vite preview",
|
||||
"test": "vitest",
|
||||
"test:run": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"zustand": "^5.0.2",
|
||||
"@radix-ui/react-avatar": "^1.1.11",
|
||||
"@radix-ui/react-collapsible": "^1.1.12",
|
||||
"@radix-ui/react-context-menu": "^2.2.16",
|
||||
@@ -44,6 +47,7 @@
|
||||
"tailwindcss-animate": "^1.0.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@testing-library/react": "^16.0.0",
|
||||
"@types/node": "^25.3.3",
|
||||
"@types/react": "^18.0.0",
|
||||
"@types/react-dom": "^18.0.0",
|
||||
@@ -53,6 +57,7 @@
|
||||
"shadcn": "^4.0.0",
|
||||
"tailwindcss": "^3.4.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 { 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
|
||||
)
|
||||
}
|
||||
@@ -1,15 +1,15 @@
|
||||
import React, { useContext, useMemo, memo } from 'react'
|
||||
import React, { memo } from 'react'
|
||||
import {
|
||||
BaseEdge,
|
||||
getBezierPath,
|
||||
type EdgeProps,
|
||||
} from '@xyflow/react'
|
||||
import { ConnectionPathContext } from '@/lib/graph/flowContext'
|
||||
import { getConnectionStatus, CONNECTION_STATUS_CLASS } from '@/lib/graph/connectionStatus'
|
||||
import { useCanvasStore } from '@/app/canvas/canvasStore'
|
||||
import { selectConnectionStatusForEdge } from '@/app/canvas/canvasStore.selectors'
|
||||
import { CONNECTION_STATUS_CLASS } from '@/lib/graph/connectionStatus'
|
||||
|
||||
const EDGE_STROKE_WIDTH = 2
|
||||
const DOT_MARKER_R = 1.5
|
||||
const EMPTY_PATH_NODE_IDS = new Set<string>()
|
||||
|
||||
function AnimatedEdgeInner({
|
||||
id,
|
||||
@@ -26,38 +26,13 @@ function AnimatedEdgeInner({
|
||||
target,
|
||||
data,
|
||||
}: EdgeProps) {
|
||||
const ctx = useContext(ConnectionPathContext)
|
||||
const pathNodeIds = ctx?.connectionPathNodeIds ?? EMPTY_PATH_NODE_IDS
|
||||
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 connectionStatus = useCanvasStore((s) =>
|
||||
selectConnectionStatusForEdge(s, source, target)
|
||||
)
|
||||
const statusClass = CONNECTION_STATUS_CLASS[connectionStatus]
|
||||
|
||||
const label = labelProp ?? (data as { connectionLabel?: string } | undefined)?.connectionLabel
|
||||
|
||||
const [edgePath, edgeLabelX, edgeLabelY] = getBezierPath({
|
||||
sourceX,
|
||||
sourceY,
|
||||
|
||||
@@ -2,7 +2,8 @@ import type { ComponentProps, ReactNode } from "react";
|
||||
import { NodeResizer } from "@xyflow/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";
|
||||
|
||||
/** Default min size for resizable nodes (used by NodeResizer). */
|
||||
@@ -38,7 +39,7 @@ export function BaseNode({
|
||||
}: BaseNodeProps) {
|
||||
const flowUIContext = useContext(FlowUIContext);
|
||||
const isFullscreenInstance = Boolean(nodeId && flowUIContext?.fullscreenNodeId === nodeId);
|
||||
const connectionPathRole = useConnectionPathRole(nodeId);
|
||||
const connectionPathRole = useConnectionPathRoleFromStore(nodeId);
|
||||
const hasSize =
|
||||
dimensions &&
|
||||
dimensions.width > 0 &&
|
||||
|
||||
@@ -4,10 +4,10 @@
|
||||
* 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 { ConnectionPathContext } from '@/lib/graph/flowContext'
|
||||
import { useSyncConnectionStatus } from '@/lib/graph/nodeLifecycle'
|
||||
import { useCanvasStore, dispatchCanvasCommand } from '@/app/canvas/canvasStore'
|
||||
import { usePlatform } from '@/app/kosmos/KosmosContext'
|
||||
import { getDefaultDataForType, getNextNodeId } from '@/lib/graph/flowUtils'
|
||||
import { getDefaultStyle } from '@/lib/graph/nodeRegistry'
|
||||
@@ -189,10 +189,7 @@ export function useRenderingNodeState(
|
||||
const lastManualRunTriggerRef = useRef(0)
|
||||
const manualRunTriggerSyncedRef = useRef(false)
|
||||
|
||||
const pathCtx = useContext(ConnectionPathContext)
|
||||
const pathCtxRef = useRef(pathCtx)
|
||||
pathCtxRef.current = pathCtx
|
||||
const triggerNodeIds = pathCtx?.connectionPathTriggerNodeIds ?? []
|
||||
const triggerNodeIds = useCanvasStore((s) => s.path.triggerNodeIds)
|
||||
|
||||
const updateDataRef = useRef(updateData)
|
||||
updateDataRef.current = updateData
|
||||
@@ -265,7 +262,7 @@ export function useRenderingNodeState(
|
||||
let cancelled = false
|
||||
|
||||
const run = async () => {
|
||||
pathCtxRef.current?.addConnectionPathTrigger?.(id)
|
||||
dispatchCanvasCommand({ type: 'path/addTrigger', payload: id })
|
||||
loadingStartedAtRef.current = Date.now()
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
|
||||
@@ -15,8 +15,9 @@
|
||||
* component, then export const MyNode = createAbstractNodeComponent('MyNode', MyNodeComponent).
|
||||
*/
|
||||
|
||||
import React, { useCallback, useContext, useMemo, useRef } from 'react'
|
||||
import { GraphContext, ConnectionPathContext } from './flowContext'
|
||||
import React, { useCallback, useContext, useMemo } from 'react'
|
||||
import { GraphContext } from './flowContext'
|
||||
import { dispatchCanvasCommand } from '@/app/canvas/canvasStore'
|
||||
import { nodePropsAreEqual } from './flowUtils'
|
||||
import type { AppNode } from './nodeTypes'
|
||||
|
||||
@@ -81,9 +82,6 @@ export function useAbstractNode<TData = Record<string, unknown>>(
|
||||
data: TData
|
||||
): AbstractNodeContext<TData> {
|
||||
const graphCtx = useContext(GraphContext)
|
||||
const pathCtx = useContext(ConnectionPathContext)
|
||||
const addTriggerRef = useRef(pathCtx?.addConnectionPathTrigger)
|
||||
addTriggerRef.current = pathCtx?.addConnectionPathTrigger
|
||||
const nodes = graphCtx?.graphRef?.current?.nodes ?? []
|
||||
const edges = graphCtx?.edges ?? []
|
||||
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
|
||||
) as AppNode[]
|
||||
)
|
||||
addTriggerRef.current?.(id)
|
||||
dispatchCanvasCommand({ type: 'path/addTrigger', payload: id })
|
||||
},
|
||||
[id, setNodes]
|
||||
)
|
||||
|
||||
@@ -5,8 +5,7 @@
|
||||
* ## State flow
|
||||
*
|
||||
* Nodes report lifecycle (updating / error / paused) via useSyncConnectionStatus(id, state).
|
||||
* This hook updates FlowContext sets; edges read them via getConnectionStatus() in connectionStatus.ts.
|
||||
* See lib/graph/state.ts for the overall state flow (graph state, connection path state).
|
||||
* This hook dispatches to the canvas store; edges read path state via selectors.
|
||||
*
|
||||
* ## Lifecycle phases (conceptual)
|
||||
*
|
||||
@@ -19,14 +18,14 @@
|
||||
* Priority for edge status: error > paused > updating > default.
|
||||
*/
|
||||
|
||||
import { useContext, useEffect, useRef } from 'react'
|
||||
import { ConnectionPathContext } from './flowContext'
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { dispatchCanvasCommand } from '@/app/canvas/canvasStore'
|
||||
|
||||
export type NodeLifecyclePhase = 'idle' | 'trigger' | 'updating' | 'paused' | 'error'
|
||||
|
||||
/**
|
||||
* 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 = {
|
||||
/** 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
|
||||
* 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:
|
||||
*
|
||||
@@ -53,39 +52,33 @@ export function useSyncConnectionStatus(
|
||||
nodeId: string,
|
||||
state: NodeConnectionStatusState
|
||||
): void {
|
||||
const ctx = useContext(ConnectionPathContext)
|
||||
const ctxRef = useRef(ctx)
|
||||
ctxRef.current = ctx
|
||||
const { updating, error, paused } = state
|
||||
const prevRef = useRef({ updating: false, error: false, paused: false })
|
||||
|
||||
useEffect(() => {
|
||||
const pathCtx = ctxRef.current
|
||||
const prev = prevRef.current
|
||||
const nowUpdating = Boolean(updating)
|
||||
const nowError = Boolean(error)
|
||||
const nowPaused = Boolean(paused)
|
||||
|
||||
if (prev.updating !== nowUpdating) {
|
||||
if (nowUpdating) pathCtx?.startConnectionPathUpdate?.(nodeId)
|
||||
else pathCtx?.endConnectionPathUpdate?.(nodeId)
|
||||
if (nowUpdating) dispatchCanvasCommand({ type: 'path/startUpdate', payload: nodeId })
|
||||
else dispatchCanvasCommand({ type: 'path/endUpdate', payload: nodeId })
|
||||
prev.updating = nowUpdating
|
||||
}
|
||||
if (prev.error !== nowError) {
|
||||
if (nowError) pathCtx?.addConnectionPathError?.(nodeId)
|
||||
else pathCtx?.removeConnectionPathError?.(nodeId)
|
||||
dispatchCanvasCommand({ type: 'path/setError', payload: { nodeId, error: nowError } })
|
||||
prev.error = nowError
|
||||
}
|
||||
if (prev.paused !== nowPaused) {
|
||||
if (nowPaused) pathCtx?.addConnectionPathPausedNode?.(nodeId)
|
||||
else pathCtx?.removeConnectionPathPausedNode?.(nodeId)
|
||||
dispatchCanvasCommand({ type: 'path/setPaused', payload: { nodeId, paused: nowPaused } })
|
||||
prev.paused = nowPaused
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (prevRef.current.updating) pathCtx?.endConnectionPathUpdate?.(nodeId)
|
||||
if (prevRef.current.error) pathCtx?.removeConnectionPathError?.(nodeId)
|
||||
if (prevRef.current.paused) pathCtx?.removeConnectionPathPausedNode?.(nodeId)
|
||||
if (prevRef.current.updating) dispatchCanvasCommand({ type: 'path/endUpdate', payload: nodeId })
|
||||
if (prevRef.current.error) dispatchCanvasCommand({ type: 'path/setError', payload: { nodeId, error: false } })
|
||||
if (prevRef.current.paused) dispatchCanvasCommand({ type: 'path/setPaused', payload: { nodeId, paused: false } })
|
||||
prevRef.current = { updating: false, error: false, paused: false }
|
||||
}
|
||||
}, [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