feat: React Flow migration for flow editor canvas #82
235
docs/plans/2026-02-18-flow-editor-react-flow-design.md
Normal file
235
docs/plans/2026-02-18-flow-editor-react-flow-design.md
Normal file
@@ -0,0 +1,235 @@
|
|||||||
|
# Flow Editor — React Flow Migration Design
|
||||||
|
|
||||||
|
> **Date:** 2026-02-18
|
||||||
|
> **Scope:** Replace hand-built CSS flexbox canvas with @xyflow/react for zoom, pan, auto-layout, and improved collapse UX
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
The current flow editor canvas (`TreeCanvas.tsx`) uses pure CSS flexbox to position nodes. This works for small trees but breaks down with large flows — nodes overlap, there's no zoom/pan, and collapsing subtrees is hard to discover. This design replaces the canvas with React Flow (`@xyflow/react`), adds dagre-based auto-layout, and moves editing to a right-side panel.
|
||||||
|
|
||||||
|
## Problems Solved
|
||||||
|
|
||||||
|
1. **No zoom/pan** — users can only scroll; can't zoom out for a bird's-eye view or zoom into a section
|
||||||
|
2. **Node overlap** — wide trees with many branches cause flexbox lanes to overlap
|
||||||
|
3. **Collapse is hidden** — the subtree collapse toggle is a small icon in the node header, easy to miss
|
||||||
|
4. **Inline editing bloats cards** — expanded cards are huge, disrupting the visual tree layout
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
### Source of Truth
|
||||||
|
|
||||||
|
The Zustand store's `treeStructure` (recursive nested object) remains the single source of truth. No store changes required. The canvas maintains a **derived** flat representation (`Node[]` and `Edge[]`) computed from the tree structure.
|
||||||
|
|
||||||
|
### Data Flow
|
||||||
|
|
||||||
|
```
|
||||||
|
treeStructure (Zustand) → useTreeLayout hook → { nodes, edges } → ReactFlow → renders
|
||||||
|
↓
|
||||||
|
user clicks node → NodeEditorPanel opens
|
||||||
|
user saves edits → updateNode(id, data) → store updates → re-derive
|
||||||
|
```
|
||||||
|
|
||||||
|
### New Dependencies
|
||||||
|
|
||||||
|
- `@xyflow/react` — canvas framework (MIT, 20k+ GitHub stars)
|
||||||
|
- `@dagrejs/dagre` — directed graph layout algorithm
|
||||||
|
- `@types/dagre` — TypeScript types
|
||||||
|
|
||||||
|
## Interactions
|
||||||
|
|
||||||
|
### Zoom
|
||||||
|
|
||||||
|
Ctrl/Cmd + scroll wheel to zoom. Zoom range: 25%–200%. Plain scroll pans vertically (natural page scrolling feel).
|
||||||
|
|
||||||
|
### Pan
|
||||||
|
|
||||||
|
Click and drag on empty canvas space. Plain scroll pans vertically. Middle-click drag also pans.
|
||||||
|
|
||||||
|
### Node Selection
|
||||||
|
|
||||||
|
Single-click on a node body selects it and opens the side panel editor.
|
||||||
|
|
||||||
|
### Subtree Collapse
|
||||||
|
|
||||||
|
Single-click on a visible chevron icon at the bottom edge of any node that has children. Always visible (not behind hover). When collapsed:
|
||||||
|
- Children and their edges are removed from the React Flow graph entirely
|
||||||
|
- A pill below the node shows "N nodes hidden"
|
||||||
|
- Clicking the pill or chevron again expands
|
||||||
|
|
||||||
|
### Zoom Controls
|
||||||
|
|
||||||
|
Small floating toolbar in bottom-left corner: zoom in (+), zoom out (−), fit-to-view. Uses React Flow's built-in `<Controls>` component.
|
||||||
|
|
||||||
|
### Minimap
|
||||||
|
|
||||||
|
Bottom-right corner. Collapsible via a toggle button — user can minimize or close it. Pannable and zoomable (click on minimap to jump to that area). Uses React Flow's built-in `<MiniMap>` component. Node colors in minimap match type accent colors (blue/yellow/green).
|
||||||
|
|
||||||
|
### Fit View
|
||||||
|
|
||||||
|
Auto-fits on initial load and when clicking the fit button. Applies padding so nodes aren't pressed against viewport edges.
|
||||||
|
|
||||||
|
## Custom Node Types
|
||||||
|
|
||||||
|
Four React Flow custom node types, all **compact** (no inline editing):
|
||||||
|
|
||||||
|
| Type | Accent | Icon | Content |
|
||||||
|
|------|--------|------|---------|
|
||||||
|
| `decision` | Blue left border | `HelpCircle` | Question text (1-2 lines), "N options" badge, option labels |
|
||||||
|
| `action` | Yellow left border | `Zap` | Title, description preview (truncated) |
|
||||||
|
| `solution` | Green left border | `CheckCircle` | Title, description preview (truncated) |
|
||||||
|
| `answer` | Dashed border, muted | — | Label + "Choose Type" prompt |
|
||||||
|
|
||||||
|
### Card Specs
|
||||||
|
|
||||||
|
- **Width:** 280px (fixed — gives dagre consistent widths)
|
||||||
|
- **Height:** Variable based on content (~80–120px estimated)
|
||||||
|
- **Selected state:** `ring-1 ring-primary`
|
||||||
|
- **Validation errors:** Red dot badge on nodes with errors
|
||||||
|
- **Collapse chevron:** Visible at bottom of node when it has children
|
||||||
|
|
||||||
|
### Edges
|
||||||
|
|
||||||
|
Smoothstep edges (React Flow built-in) — route around nodes with rounded corners. Color: `border-border`.
|
||||||
|
|
||||||
|
**Edge labels:** Show the option text leading to each child. **Truncated to 35 characters + ellipsis** for long option text (e.g., "User reports intermittent VPN di…"). Full text visible on hover tooltip and in the side panel when the parent decision is selected.
|
||||||
|
|
||||||
|
## Side Panel Editor
|
||||||
|
|
||||||
|
When a node is selected, a right-side editor panel opens. The canvas container **resizes** (shrinks by panel width) rather than the panel overlaying the canvas — this prevents covering the selected node. React Flow handles container resize natively.
|
||||||
|
|
||||||
|
### Panel Specs
|
||||||
|
|
||||||
|
- **Width:** 400px
|
||||||
|
- **Position:** Right side, part of the layout (not floating)
|
||||||
|
- **Open triggers:** Single-click a node
|
||||||
|
- **Close triggers:** X button, Escape key, clicking empty canvas
|
||||||
|
- **Auto-center:** When panel opens, auto-pan so the selected node stays centered in the remaining canvas area (via React Flow's `setCenter` / `fitBounds`)
|
||||||
|
|
||||||
|
### Panel Structure
|
||||||
|
|
||||||
|
- **Header:** Node type icon + badge, node title (or "New Decision"), close button
|
||||||
|
- **Body:** Renders existing form components — `NodeFormDecision`, `NodeFormAction`, `NodeFormResolution`. For `answer` nodes: type picker buttons (Decision/Action/Solution)
|
||||||
|
- **Footer:** Save (`bg-gradient-brand`), Cancel, Delete (with confirmation), Duplicate
|
||||||
|
|
||||||
|
### Draft Model
|
||||||
|
|
||||||
|
Same local-draft-then-commit pattern as current inline editor:
|
||||||
|
- Panel opens with a clone of the node data
|
||||||
|
- Edits modify the draft only
|
||||||
|
- Save writes to Zustand store → triggers re-derive of React Flow nodes/edges
|
||||||
|
- Cancel discards draft and closes panel
|
||||||
|
- Switching nodes while editing prompts save/discard
|
||||||
|
|
||||||
|
### Panel Coexistence
|
||||||
|
|
||||||
|
Only one right panel open at a time. Opening node editor closes metadata panel and vice versa.
|
||||||
|
|
||||||
|
## Layout Engine (Dagre)
|
||||||
|
|
||||||
|
### Configuration
|
||||||
|
|
||||||
|
- Direction: `rankdir: 'TB'` (top-to-bottom)
|
||||||
|
- Node width: 280px
|
||||||
|
- Node height: estimated heuristic (~80px base + content)
|
||||||
|
- Rank separation (vertical gap): ~100px
|
||||||
|
- Node separation (horizontal gap): ~40px
|
||||||
|
|
||||||
|
### Height Measurement Correction
|
||||||
|
|
||||||
|
Dagre needs node heights before rendering, but content varies. Strategy:
|
||||||
|
1. **First pass:** Use heuristic height estimates based on node type and content length
|
||||||
|
2. **After first paint:** Measure actual rendered heights via refs
|
||||||
|
3. **If any height differs by >10px from estimate:** Re-run dagre with actual heights (single correction pass, no infinite loops)
|
||||||
|
|
||||||
|
This avoids visible layout jumps in most cases while catching edge cases like decision nodes with 8+ options.
|
||||||
|
|
||||||
|
### When Re-layout Runs
|
||||||
|
|
||||||
|
| Trigger | Re-layout? |
|
||||||
|
|---------|-----------|
|
||||||
|
| Node added/deleted | Yes |
|
||||||
|
| Node moved (reparented) | Yes |
|
||||||
|
| Options added/removed on a decision (structural change) | Yes |
|
||||||
|
| Content-only edits (title, description text) | No |
|
||||||
|
| Collapse/expand toggle | Yes (different nodes visible) |
|
||||||
|
| Panel open/close | No (React Flow handles container resize) |
|
||||||
|
|
||||||
|
After re-layout, `fitView` is called with padding.
|
||||||
|
|
||||||
|
## File Changes
|
||||||
|
|
||||||
|
### New Files
|
||||||
|
|
||||||
|
| File | Purpose |
|
||||||
|
|------|---------|
|
||||||
|
| `components/tree-editor/FlowCanvas.tsx` | React Flow canvas — replaces `TreeCanvas.tsx` |
|
||||||
|
| `components/tree-editor/FlowCanvasNode.tsx` | Custom compact node component (decision/action/solution) |
|
||||||
|
| `components/tree-editor/FlowCanvasAnswerNode.tsx` | Custom node for answer stubs |
|
||||||
|
| `components/tree-editor/NodeEditorPanel.tsx` | Right-side editor panel — replaces inline card editing |
|
||||||
|
| `components/tree-editor/useTreeLayout.ts` | Hook: treeStructure → nodes/edges + dagre + measure-correct |
|
||||||
|
| `lib/dagreLayout.ts` | Pure function: positioned nodes from dagre |
|
||||||
|
|
||||||
|
### Modified Files
|
||||||
|
|
||||||
|
| File | Changes |
|
||||||
|
|------|---------|
|
||||||
|
| `TreeEditorLayout.tsx` | Flow mode renders `FlowCanvas` + `NodeEditorPanel` instead of `TreeCanvas` |
|
||||||
|
| `TreeEditorPage.tsx` | Panel state: node editor vs metadata, single-panel-at-a-time |
|
||||||
|
|
||||||
|
### Unchanged
|
||||||
|
|
||||||
|
- `treeEditorStore.ts` — no store changes needed
|
||||||
|
- `NodeFormDecision.tsx`, `NodeFormAction.tsx`, `NodeFormResolution.tsx` — reused inside panel
|
||||||
|
- `MetadataSidePanel.tsx` — already works, gets single-panel-at-a-time rule
|
||||||
|
- Code mode — completely untouched
|
||||||
|
|
||||||
|
### Removed from Active Flow Mode Path
|
||||||
|
|
||||||
|
- `TreeCanvas.tsx` → replaced by `FlowCanvas.tsx`
|
||||||
|
- `TreeCanvasNode.tsx` → replaced by `FlowCanvasNode.tsx`
|
||||||
|
- `AnswerStubCard.tsx` → logic moves to `FlowCanvasAnswerNode.tsx`
|
||||||
|
|
||||||
|
Old components stay in the repo but are no longer imported in Flow mode.
|
||||||
|
|
||||||
|
## React Flow Configuration
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<ReactFlow
|
||||||
|
nodes={nodes}
|
||||||
|
edges={edges}
|
||||||
|
onNodesChange={onNodesChange}
|
||||||
|
nodeTypes={nodeTypes}
|
||||||
|
edgeTypes={edgeTypes}
|
||||||
|
onNodeClick={handleNodeClick}
|
||||||
|
onPaneClick={handlePaneClick}
|
||||||
|
fitView
|
||||||
|
minZoom={0.25}
|
||||||
|
maxZoom={2}
|
||||||
|
zoomOnScroll={false} // plain scroll pans
|
||||||
|
zoomOnPinch={true} // trackpad pinch zooms
|
||||||
|
panOnScroll={true} // plain scroll pans vertically
|
||||||
|
panOnScrollMode="vertical"
|
||||||
|
selectionOnDrag={false} // no multi-select
|
||||||
|
nodesDraggable={false} // dagre controls layout
|
||||||
|
nodesConnectable={false} // no edge reconnection
|
||||||
|
proOptions={{ hideAttribution: true }}
|
||||||
|
>
|
||||||
|
<Background variant="dots" gap={24} size={1} color="var(--border)" />
|
||||||
|
<Controls showInteractive={false} />
|
||||||
|
<MiniMap
|
||||||
|
pannable
|
||||||
|
zoomable
|
||||||
|
nodeColor={getNodeColor}
|
||||||
|
style={{ display: minimapVisible ? 'block' : 'none' }}
|
||||||
|
/>
|
||||||
|
</ReactFlow>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Not Included (YAGNI)
|
||||||
|
|
||||||
|
- No drag-to-reparent nodes on canvas
|
||||||
|
- No visual edge reconnection (dragging edges)
|
||||||
|
- No multi-select nodes
|
||||||
|
- No undo/redo on canvas position changes (undo/redo stays on tree data only)
|
||||||
|
- No manual node drag repositioning (dagre controls layout)
|
||||||
|
- No light mode (dark-first design system)
|
||||||
1474
docs/plans/2026-02-18-flow-editor-react-flow-impl.md
Normal file
1474
docs/plans/2026-02-18-flow-editor-react-flow-impl.md
Normal file
File diff suppressed because it is too large
Load Diff
183
frontend/package-lock.json
generated
183
frontend/package-lock.json
generated
@@ -8,12 +8,14 @@
|
|||||||
"name": "frontend",
|
"name": "frontend",
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@dagrejs/dagre": "^2.0.4",
|
||||||
"@dnd-kit/core": "^6.3.1",
|
"@dnd-kit/core": "^6.3.1",
|
||||||
"@dnd-kit/sortable": "^10.0.0",
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
"@dnd-kit/utilities": "^3.2.2",
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
"@monaco-editor/react": "^4.7.0",
|
"@monaco-editor/react": "^4.7.0",
|
||||||
"@stripe/stripe-js": "^8.7.0",
|
"@stripe/stripe-js": "^8.7.0",
|
||||||
"@types/lodash": "^4.17.23",
|
"@types/lodash": "^4.17.23",
|
||||||
|
"@xyflow/react": "^12.10.0",
|
||||||
"axios": "^1.13.4",
|
"axios": "^1.13.4",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
@@ -563,6 +565,21 @@
|
|||||||
"node": ">=20.19.0"
|
"node": ">=20.19.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@dagrejs/dagre": {
|
||||||
|
"version": "2.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@dagrejs/dagre/-/dagre-2.0.4.tgz",
|
||||||
|
"integrity": "sha512-J6vCWTNpicHF4zFlZG1cS5DkGzMr9941gddYkakjrg3ZNev4bbqEgLHFTWiFrcJm7UCRu7olO3K6IRDd9gSGhA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@dagrejs/graphlib": "3.0.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@dagrejs/graphlib": {
|
||||||
|
"version": "3.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@dagrejs/graphlib/-/graphlib-3.0.4.tgz",
|
||||||
|
"integrity": "sha512-HxZ7fCvAwTLCWCO0WjDkzAFQze8LdC6iOpKbetDKHIuDfIgMlIzYzqZ4nxwLlclQX+3ZVeZ1K2OuaOE2WWcyOg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@date-fns/tz": {
|
"node_modules/@date-fns/tz": {
|
||||||
"version": "1.4.1",
|
"version": "1.4.1",
|
||||||
"resolved": "https://registry.npmjs.org/@date-fns/tz/-/tz-1.4.1.tgz",
|
"resolved": "https://registry.npmjs.org/@date-fns/tz/-/tz-1.4.1.tgz",
|
||||||
@@ -1972,6 +1989,15 @@
|
|||||||
"integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==",
|
"integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/d3-drag": {
|
||||||
|
"version": "3.0.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-drag/-/d3-drag-3.0.7.tgz",
|
||||||
|
"integrity": "sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/d3-selection": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@types/d3-ease": {
|
"node_modules/@types/d3-ease": {
|
||||||
"version": "3.0.2",
|
"version": "3.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz",
|
||||||
@@ -2002,6 +2028,12 @@
|
|||||||
"@types/d3-time": "*"
|
"@types/d3-time": "*"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/d3-selection": {
|
||||||
|
"version": "3.0.11",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-3.0.11.tgz",
|
||||||
|
"integrity": "sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@types/d3-shape": {
|
"node_modules/@types/d3-shape": {
|
||||||
"version": "3.1.8",
|
"version": "3.1.8",
|
||||||
"resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz",
|
"resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz",
|
||||||
@@ -2023,6 +2055,25 @@
|
|||||||
"integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==",
|
"integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/d3-transition": {
|
||||||
|
"version": "3.0.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-transition/-/d3-transition-3.0.9.tgz",
|
||||||
|
"integrity": "sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/d3-selection": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@types/d3-zoom": {
|
||||||
|
"version": "3.0.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/d3-zoom/-/d3-zoom-3.0.8.tgz",
|
||||||
|
"integrity": "sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/d3-interpolate": "*",
|
||||||
|
"@types/d3-selection": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@types/debug": {
|
"node_modules/@types/debug": {
|
||||||
"version": "4.1.12",
|
"version": "4.1.12",
|
||||||
"resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz",
|
"resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz",
|
||||||
@@ -2546,6 +2597,66 @@
|
|||||||
"url": "https://opencollective.com/vitest"
|
"url": "https://opencollective.com/vitest"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@xyflow/react": {
|
||||||
|
"version": "12.10.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@xyflow/react/-/react-12.10.0.tgz",
|
||||||
|
"integrity": "sha512-eOtz3whDMWrB4KWVatIBrKuxECHqip6PfA8fTpaS2RUGVpiEAe+nqDKsLqkViVWxDGreq0lWX71Xth/SPAzXiw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@xyflow/system": "0.0.74",
|
||||||
|
"classcat": "^5.0.3",
|
||||||
|
"zustand": "^4.4.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": ">=17",
|
||||||
|
"react-dom": ">=17"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@xyflow/react/node_modules/zustand": {
|
||||||
|
"version": "4.5.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.7.tgz",
|
||||||
|
"integrity": "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"use-sync-external-store": "^1.2.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12.7.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": ">=16.8",
|
||||||
|
"immer": ">=9.0.6",
|
||||||
|
"react": ">=16.8"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"immer": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"react": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@xyflow/system": {
|
||||||
|
"version": "0.0.74",
|
||||||
|
"resolved": "https://registry.npmjs.org/@xyflow/system/-/system-0.0.74.tgz",
|
||||||
|
"integrity": "sha512-7v7B/PkiVrkdZzSbL+inGAo6tkR/WQHHG0/jhSvLQToCsfa8YubOGmBYd1s08tpKpihdHDZFwzQZeR69QSBb4Q==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/d3-drag": "^3.0.7",
|
||||||
|
"@types/d3-interpolate": "^3.0.4",
|
||||||
|
"@types/d3-selection": "^3.0.10",
|
||||||
|
"@types/d3-transition": "^3.0.8",
|
||||||
|
"@types/d3-zoom": "^3.0.8",
|
||||||
|
"d3-drag": "^3.0.0",
|
||||||
|
"d3-interpolate": "^3.0.1",
|
||||||
|
"d3-selection": "^3.0.0",
|
||||||
|
"d3-zoom": "^3.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/acorn": {
|
"node_modules/acorn": {
|
||||||
"version": "8.15.0",
|
"version": "8.15.0",
|
||||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
|
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
|
||||||
@@ -3043,6 +3154,12 @@
|
|||||||
"url": "https://polar.sh/cva"
|
"url": "https://polar.sh/cva"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/classcat": {
|
||||||
|
"version": "5.0.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/classcat/-/classcat-5.0.5.tgz",
|
||||||
|
"integrity": "sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/cliui": {
|
"node_modules/cliui": {
|
||||||
"version": "8.0.1",
|
"version": "8.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz",
|
||||||
@@ -3248,6 +3365,28 @@
|
|||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/d3-dispatch": {
|
||||||
|
"version": "3.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz",
|
||||||
|
"integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==",
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-drag": {
|
||||||
|
"version": "3.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz",
|
||||||
|
"integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"d3-dispatch": "1 - 3",
|
||||||
|
"d3-selection": "3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/d3-ease": {
|
"node_modules/d3-ease": {
|
||||||
"version": "3.0.1",
|
"version": "3.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz",
|
||||||
@@ -3303,6 +3442,15 @@
|
|||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/d3-selection": {
|
||||||
|
"version": "3.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz",
|
||||||
|
"integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==",
|
||||||
|
"license": "ISC",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/d3-shape": {
|
"node_modules/d3-shape": {
|
||||||
"version": "3.2.0",
|
"version": "3.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz",
|
||||||
@@ -3348,6 +3496,41 @@
|
|||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/d3-transition": {
|
||||||
|
"version": "3.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-3.0.1.tgz",
|
||||||
|
"integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"d3-color": "1 - 3",
|
||||||
|
"d3-dispatch": "1 - 3",
|
||||||
|
"d3-ease": "1 - 3",
|
||||||
|
"d3-interpolate": "1 - 3",
|
||||||
|
"d3-timer": "1 - 3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"d3-selection": "2 - 3"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/d3-zoom": {
|
||||||
|
"version": "3.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-3.0.0.tgz",
|
||||||
|
"integrity": "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"d3-dispatch": "1 - 3",
|
||||||
|
"d3-drag": "2 - 3",
|
||||||
|
"d3-interpolate": "1 - 3",
|
||||||
|
"d3-selection": "2 - 3",
|
||||||
|
"d3-transition": "2 - 3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/data-urls": {
|
"node_modules/data-urls": {
|
||||||
"version": "7.0.0",
|
"version": "7.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz",
|
||||||
|
|||||||
@@ -13,12 +13,14 @@
|
|||||||
"analyze": "vite-bundle-visualizer"
|
"analyze": "vite-bundle-visualizer"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@dagrejs/dagre": "^2.0.4",
|
||||||
"@dnd-kit/core": "^6.3.1",
|
"@dnd-kit/core": "^6.3.1",
|
||||||
"@dnd-kit/sortable": "^10.0.0",
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
"@dnd-kit/utilities": "^3.2.2",
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
"@monaco-editor/react": "^4.7.0",
|
"@monaco-editor/react": "^4.7.0",
|
||||||
"@stripe/stripe-js": "^8.7.0",
|
"@stripe/stripe-js": "^8.7.0",
|
||||||
"@types/lodash": "^4.17.23",
|
"@types/lodash": "^4.17.23",
|
||||||
|
"@xyflow/react": "^12.10.0",
|
||||||
"axios": "^1.13.4",
|
"axios": "^1.13.4",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { ChevronDown, ChevronUp } from 'lucide-react'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
interface CategoryItem {
|
interface CategoryItem {
|
||||||
@@ -13,16 +15,23 @@ interface CategoryListProps {
|
|||||||
onSelect: (id: string | null) => void
|
onSelect: (id: string | null) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const VISIBLE_COUNT = 4
|
||||||
|
|
||||||
export function CategoryList({ categories, activeId, onSelect }: CategoryListProps) {
|
export function CategoryList({ categories, activeId, onSelect }: CategoryListProps) {
|
||||||
|
const [expanded, setExpanded] = useState(false)
|
||||||
|
|
||||||
if (categories.length === 0) return null
|
if (categories.length === 0) return null
|
||||||
|
|
||||||
|
const hasMore = categories.length > VISIBLE_COUNT
|
||||||
|
const visible = expanded ? categories : categories.slice(0, VISIBLE_COUNT)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="px-3 py-2">
|
<div className="px-3 py-2">
|
||||||
<p className="mb-2 px-3 font-heading text-[0.6875rem] font-bold uppercase tracking-[0.04em] text-muted-foreground">
|
<p className="mb-2 px-3 font-heading text-[0.6875rem] font-bold uppercase tracking-[0.04em] text-muted-foreground">
|
||||||
Categories
|
Categories
|
||||||
</p>
|
</p>
|
||||||
<div className="space-y-0.5">
|
<div className="space-y-0.5">
|
||||||
{categories.map(cat => (
|
{visible.map(cat => (
|
||||||
<button
|
<button
|
||||||
key={cat.id}
|
key={cat.id}
|
||||||
onClick={() => onSelect(activeId === cat.id ? null : cat.id)}
|
onClick={() => onSelect(activeId === cat.id ? null : cat.id)}
|
||||||
@@ -41,6 +50,24 @@ export function CategoryList({ categories, activeId, onSelect }: CategoryListPro
|
|||||||
<span className="font-label text-[0.6875rem] text-[hsl(var(--text-dimmed))]">{cat.count}</span>
|
<span className="font-label text-[0.6875rem] text-[hsl(var(--text-dimmed))]">{cat.count}</span>
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
|
{hasMore && (
|
||||||
|
<button
|
||||||
|
onClick={() => setExpanded(v => !v)}
|
||||||
|
className="flex w-full items-center gap-2.5 rounded-lg px-3 py-1.5 text-[0.8125rem] text-muted-foreground hover:bg-[hsl(var(--sidebar-hover))] hover:text-foreground transition-colors"
|
||||||
|
>
|
||||||
|
{expanded ? (
|
||||||
|
<>
|
||||||
|
<ChevronUp className="h-3.5 w-3.5" />
|
||||||
|
<span>Show less</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<ChevronDown className="h-3.5 w-3.5" />
|
||||||
|
<span>{categories.length - VISIBLE_COUNT} more</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
176
frontend/src/components/tree-editor/FlowCanvas.tsx
Normal file
176
frontend/src/components/tree-editor/FlowCanvas.tsx
Normal file
@@ -0,0 +1,176 @@
|
|||||||
|
import { useCallback, useMemo, useState, useEffect } from 'react'
|
||||||
|
import {
|
||||||
|
ReactFlow,
|
||||||
|
Background,
|
||||||
|
Controls,
|
||||||
|
MiniMap,
|
||||||
|
BackgroundVariant,
|
||||||
|
useReactFlow,
|
||||||
|
useNodesState,
|
||||||
|
useEdgesState,
|
||||||
|
ReactFlowProvider,
|
||||||
|
PanOnScrollMode,
|
||||||
|
type NodeMouseHandler,
|
||||||
|
} from '@xyflow/react'
|
||||||
|
import '@xyflow/react/dist/style.css'
|
||||||
|
|
||||||
|
import { FlowCanvasNode, NODE_TYPE_CONFIG } from './FlowCanvasNode'
|
||||||
|
import { FlowCanvasAnswerNode } from './FlowCanvasAnswerNode'
|
||||||
|
import { useTreeLayout } from './useTreeLayout'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
import { Map as MapIcon, MapPinOff } from 'lucide-react'
|
||||||
|
import type { FlowCanvasNodeData } from './FlowCanvasNode'
|
||||||
|
import type { FlowCanvasAnswerNodeData } from './FlowCanvasAnswerNode'
|
||||||
|
|
||||||
|
const nodeTypes = {
|
||||||
|
flowNode: FlowCanvasNode,
|
||||||
|
answerStub: FlowCanvasAnswerNode,
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FlowCanvasProps {
|
||||||
|
selectedNodeId: string | null
|
||||||
|
onNodeSelect: (nodeId: string | null) => void
|
||||||
|
onSelectAnswerType: (nodeId: string, type: 'decision' | 'action' | 'solution') => void
|
||||||
|
}
|
||||||
|
|
||||||
|
function FlowCanvasInner({ selectedNodeId, onNodeSelect, onSelectAnswerType }: FlowCanvasProps) {
|
||||||
|
const { fitView, setCenter } = useReactFlow()
|
||||||
|
const { nodes: layoutNodes, edges: layoutEdges, collapsedNodeIds, toggleCollapse, onNodesMeasured } = useTreeLayout()
|
||||||
|
const [minimapVisible, setMinimapVisible] = useState(true)
|
||||||
|
|
||||||
|
// Inject callbacks into node data (because useTreeLayout creates placeholder functions)
|
||||||
|
const nodesWithCallbacks = useMemo(() => {
|
||||||
|
return layoutNodes.map(n => {
|
||||||
|
if (n.type === 'flowNode') {
|
||||||
|
const data = n.data as unknown as FlowCanvasNodeData
|
||||||
|
return {
|
||||||
|
...n,
|
||||||
|
selected: n.id === selectedNodeId,
|
||||||
|
data: { ...data, onToggleCollapse: toggleCollapse },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (n.type === 'answerStub') {
|
||||||
|
const data = n.data as unknown as FlowCanvasAnswerNodeData
|
||||||
|
return {
|
||||||
|
...n,
|
||||||
|
selected: n.id === selectedNodeId,
|
||||||
|
data: { ...data, onSelectType: onSelectAnswerType },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
})
|
||||||
|
}, [layoutNodes, selectedNodeId, toggleCollapse, onSelectAnswerType])
|
||||||
|
|
||||||
|
const [nodes, setNodes, onNodesChange] = useNodesState(nodesWithCallbacks)
|
||||||
|
const [edges, setEdges, onEdgesChange] = useEdgesState(layoutEdges)
|
||||||
|
|
||||||
|
// Sync layout changes into React Flow state
|
||||||
|
useEffect(() => {
|
||||||
|
setNodes(nodesWithCallbacks)
|
||||||
|
setEdges(layoutEdges)
|
||||||
|
}, [nodesWithCallbacks, layoutEdges, setNodes, setEdges])
|
||||||
|
|
||||||
|
// Fit view after layout changes
|
||||||
|
useEffect(() => {
|
||||||
|
// Small delay to let React Flow process the node updates
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
fitView({ padding: 0.1, duration: 200 })
|
||||||
|
}, 50)
|
||||||
|
return () => clearTimeout(timer)
|
||||||
|
}, [layoutNodes.length, collapsedNodeIds.size]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
|
// Auto-center on selected node when panel opens
|
||||||
|
useEffect(() => {
|
||||||
|
if (!selectedNodeId) return
|
||||||
|
const node = nodes.find(n => n.id === selectedNodeId)
|
||||||
|
if (node) {
|
||||||
|
const x = node.position.x + 140 // center of 280px node
|
||||||
|
const y = node.position.y + 50
|
||||||
|
setCenter(x, y, { duration: 300, zoom: 1 })
|
||||||
|
}
|
||||||
|
}, [selectedNodeId]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
|
// Height measurement correction
|
||||||
|
useEffect(() => {
|
||||||
|
if (nodes.length > 0 && nodes.some(n => n.measured?.height)) {
|
||||||
|
onNodesMeasured(nodes)
|
||||||
|
}
|
||||||
|
}, [nodes]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
|
const handleNodeClick: NodeMouseHandler = useCallback((_event, node) => {
|
||||||
|
onNodeSelect(node.id)
|
||||||
|
}, [onNodeSelect])
|
||||||
|
|
||||||
|
const handlePaneClick = useCallback(() => {
|
||||||
|
onNodeSelect(null)
|
||||||
|
}, [onNodeSelect])
|
||||||
|
|
||||||
|
// Custom minimap node color based on actual tree node type
|
||||||
|
const minimapNodeColor = useCallback((rfNode: { data?: unknown }) => {
|
||||||
|
const data = rfNode.data as (FlowCanvasNodeData & FlowCanvasAnswerNodeData) | undefined
|
||||||
|
if (!data || !('node' in data)) return '#6b7280'
|
||||||
|
const treeNode = data.node
|
||||||
|
if (treeNode.type === 'answer') return '#6b7280'
|
||||||
|
const config = NODE_TYPE_CONFIG[treeNode.type as keyof typeof NODE_TYPE_CONFIG]
|
||||||
|
return config?.minimapColor ?? '#6b7280'
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative h-full w-full">
|
||||||
|
<ReactFlow
|
||||||
|
nodes={nodes}
|
||||||
|
edges={edges}
|
||||||
|
onNodesChange={onNodesChange}
|
||||||
|
onEdgesChange={onEdgesChange}
|
||||||
|
nodeTypes={nodeTypes}
|
||||||
|
onNodeClick={handleNodeClick}
|
||||||
|
onPaneClick={handlePaneClick}
|
||||||
|
fitView
|
||||||
|
minZoom={0.25}
|
||||||
|
maxZoom={2}
|
||||||
|
zoomOnScroll={false}
|
||||||
|
zoomOnPinch={true}
|
||||||
|
panOnScroll={true}
|
||||||
|
panOnScrollMode={PanOnScrollMode.Vertical}
|
||||||
|
selectionOnDrag={false}
|
||||||
|
nodesDraggable={false}
|
||||||
|
nodesConnectable={false}
|
||||||
|
proOptions={{ hideAttribution: true }}
|
||||||
|
className="bg-accent/30"
|
||||||
|
>
|
||||||
|
<Background variant={BackgroundVariant.Dots} gap={20} size={1.5} color="hsl(var(--muted-foreground) / 0.25)" />
|
||||||
|
<Controls showInteractive={false} className="!bg-card !border-border !shadow-lg" />
|
||||||
|
{minimapVisible && (
|
||||||
|
<MiniMap
|
||||||
|
pannable
|
||||||
|
zoomable
|
||||||
|
nodeColor={minimapNodeColor}
|
||||||
|
className="!bg-card !border-border"
|
||||||
|
nodeStrokeWidth={2}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</ReactFlow>
|
||||||
|
|
||||||
|
{/* Minimap toggle button */}
|
||||||
|
<button
|
||||||
|
onClick={() => setMinimapVisible(v => !v)}
|
||||||
|
className={cn(
|
||||||
|
'absolute bottom-2 right-2 z-10 rounded-lg border border-border bg-card p-2 text-muted-foreground shadow-lg hover:bg-accent hover:text-foreground transition-colors',
|
||||||
|
minimapVisible && 'bottom-[170px]'
|
||||||
|
)}
|
||||||
|
title={minimapVisible ? 'Hide minimap' : 'Show minimap'}
|
||||||
|
>
|
||||||
|
{minimapVisible ? <MapPinOff className="h-4 w-4" /> : <MapIcon className="h-4 w-4" />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wrap in ReactFlowProvider (required by useReactFlow hook)
|
||||||
|
export function FlowCanvas(props: FlowCanvasProps) {
|
||||||
|
return (
|
||||||
|
<ReactFlowProvider>
|
||||||
|
<FlowCanvasInner {...props} />
|
||||||
|
</ReactFlowProvider>
|
||||||
|
)
|
||||||
|
}
|
||||||
69
frontend/src/components/tree-editor/FlowCanvasAnswerNode.tsx
Normal file
69
frontend/src/components/tree-editor/FlowCanvasAnswerNode.tsx
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
import { memo, useState } from 'react'
|
||||||
|
import { Handle, Position, type NodeProps } from '@xyflow/react'
|
||||||
|
import { HelpCircle, Zap, CheckCircle } from 'lucide-react'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
import type { TreeStructure } from '@/types'
|
||||||
|
|
||||||
|
export interface FlowCanvasAnswerNodeData {
|
||||||
|
node: TreeStructure
|
||||||
|
onSelectType: (nodeId: string, type: 'decision' | 'action' | 'solution') => void
|
||||||
|
}
|
||||||
|
|
||||||
|
function FlowCanvasAnswerNodeComponent({ data, selected }: NodeProps) {
|
||||||
|
const { node, onSelectType } = data as unknown as FlowCanvasAnswerNodeData
|
||||||
|
const [picking, setPicking] = useState(false)
|
||||||
|
const label = node.title || 'Answer'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Handle type="target" position={Position.Top} className="!bg-border !w-2 !h-2 !border-0" />
|
||||||
|
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'w-[280px] rounded-xl border-2 border-dashed border-border bg-card/50 transition-all',
|
||||||
|
!picking && 'cursor-pointer hover:border-primary/40 hover:bg-accent/30',
|
||||||
|
selected && 'ring-1 ring-primary'
|
||||||
|
)}
|
||||||
|
onClick={() => !picking && setPicking(true)}
|
||||||
|
>
|
||||||
|
<div className="px-3 pt-2.5 pb-1 text-sm font-heading font-medium text-foreground text-center">
|
||||||
|
{label}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!picking ? (
|
||||||
|
<div className="pb-2.5 text-center text-[10px] text-muted-foreground font-label">
|
||||||
|
+ Choose Type
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center justify-center gap-1.5 pb-2.5 px-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => { e.stopPropagation(); onSelectType(node.id, 'decision') }}
|
||||||
|
className="flex items-center gap-1 rounded-md px-2 py-1 text-[10px] font-label border border-blue-500/30 bg-blue-500/10 text-blue-400 hover:bg-blue-500/20"
|
||||||
|
>
|
||||||
|
<HelpCircle className="h-2.5 w-2.5" /> Decision
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => { e.stopPropagation(); onSelectType(node.id, 'action') }}
|
||||||
|
className="flex items-center gap-1 rounded-md px-2 py-1 text-[10px] font-label border border-yellow-500/30 bg-yellow-500/10 text-yellow-400 hover:bg-yellow-500/20"
|
||||||
|
>
|
||||||
|
<Zap className="h-2.5 w-2.5" /> Action
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => { e.stopPropagation(); onSelectType(node.id, 'solution') }}
|
||||||
|
className="flex items-center gap-1 rounded-md px-2 py-1 text-[10px] font-label border border-green-500/30 bg-green-500/10 text-green-400 hover:bg-green-500/20"
|
||||||
|
>
|
||||||
|
<CheckCircle className="h-2.5 w-2.5" /> Solution
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Handle type="source" position={Position.Bottom} className="!bg-border !w-2 !h-2 !border-0" />
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const FlowCanvasAnswerNode = memo(FlowCanvasAnswerNodeComponent)
|
||||||
154
frontend/src/components/tree-editor/FlowCanvasNode.tsx
Normal file
154
frontend/src/components/tree-editor/FlowCanvasNode.tsx
Normal file
@@ -0,0 +1,154 @@
|
|||||||
|
import { memo } from 'react'
|
||||||
|
import { Handle, Position, type NodeProps } from '@xyflow/react'
|
||||||
|
import { HelpCircle, Zap, CheckCircle, ChevronDown, ChevronRight, AlertCircle } from 'lucide-react'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
import type { TreeStructure, NodeType } from '@/types'
|
||||||
|
|
||||||
|
const NODE_TYPE_CONFIG: Record<Exclude<NodeType, 'answer'>, {
|
||||||
|
icon: typeof HelpCircle
|
||||||
|
label: string
|
||||||
|
borderClass: string
|
||||||
|
badgeClass: string
|
||||||
|
minimapColor: string
|
||||||
|
}> = {
|
||||||
|
decision: {
|
||||||
|
icon: HelpCircle,
|
||||||
|
label: 'Decision',
|
||||||
|
borderClass: 'border-l-4 border-l-blue-500',
|
||||||
|
badgeClass: 'bg-blue-500/20 text-blue-400',
|
||||||
|
minimapColor: '#3b82f6',
|
||||||
|
},
|
||||||
|
action: {
|
||||||
|
icon: Zap,
|
||||||
|
label: 'Action',
|
||||||
|
borderClass: 'border-l-4 border-l-yellow-500',
|
||||||
|
badgeClass: 'bg-yellow-500/20 text-yellow-400',
|
||||||
|
minimapColor: '#eab308',
|
||||||
|
},
|
||||||
|
solution: {
|
||||||
|
icon: CheckCircle,
|
||||||
|
label: 'Solution',
|
||||||
|
borderClass: 'border-l-4 border-l-green-500',
|
||||||
|
badgeClass: 'bg-green-500/20 text-green-400',
|
||||||
|
minimapColor: '#22c55e',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FlowCanvasNodeData {
|
||||||
|
node: TreeStructure
|
||||||
|
hasChildren: boolean
|
||||||
|
isCollapsed: boolean
|
||||||
|
hasValidationErrors: boolean
|
||||||
|
isNew: boolean
|
||||||
|
onToggleCollapse: (nodeId: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
function FlowCanvasNodeComponent({ data, selected }: NodeProps) {
|
||||||
|
const { node, hasChildren, isCollapsed, hasValidationErrors, isNew, onToggleCollapse } = data as unknown as FlowCanvasNodeData
|
||||||
|
const nodeType = node.type as Exclude<NodeType, 'answer'>
|
||||||
|
const config = NODE_TYPE_CONFIG[nodeType] ?? NODE_TYPE_CONFIG.decision
|
||||||
|
const Icon = config.icon
|
||||||
|
|
||||||
|
const title = node.type === 'decision'
|
||||||
|
? (node.question || 'Untitled Decision')
|
||||||
|
: (node.title || `Untitled ${config.label}`)
|
||||||
|
|
||||||
|
const optionCount = node.options?.length ?? 0
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* Target handle at top */}
|
||||||
|
<Handle type="target" position={Position.Top} className="!bg-border !w-2 !h-2 !border-0" />
|
||||||
|
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'w-[280px] rounded-xl border border-border bg-card shadow-sm cursor-pointer transition-all',
|
||||||
|
config.borderClass,
|
||||||
|
selected && 'ring-1 ring-primary shadow-md'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center gap-2 px-3 py-2.5">
|
||||||
|
{/* Type badge */}
|
||||||
|
<span className={cn('flex h-5 w-5 shrink-0 items-center justify-center rounded', config.badgeClass)}>
|
||||||
|
<Icon className="h-3 w-3" />
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{/* Title */}
|
||||||
|
<span className="flex-1 truncate text-sm font-heading font-medium text-foreground">
|
||||||
|
{title}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{/* Badges */}
|
||||||
|
{isNew && (
|
||||||
|
<span className="rounded-full bg-yellow-500/20 px-1.5 py-0.5 text-[10px] font-label text-yellow-400">
|
||||||
|
New
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{hasValidationErrors && (
|
||||||
|
<AlertCircle className="h-3.5 w-3.5 shrink-0 text-red-400" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Decision options preview */}
|
||||||
|
{node.type === 'decision' && optionCount > 0 && (
|
||||||
|
<div className="border-t border-border px-3 py-1.5">
|
||||||
|
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||||
|
<span className="font-label">{optionCount} option{optionCount !== 1 ? 's' : ''}</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 space-y-0.5">
|
||||||
|
{node.options!.slice(0, 3).map((opt, i) => (
|
||||||
|
<div key={opt.id} className="truncate text-xs text-muted-foreground">
|
||||||
|
<span className="font-label text-foreground/60">{String.fromCharCode(65 + i)}</span>{' '}
|
||||||
|
{opt.label || '(empty)'}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{optionCount > 3 && (
|
||||||
|
<div className="text-xs text-muted-foreground">+{optionCount - 3} more</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Description preview for action/solution */}
|
||||||
|
{(node.type === 'action' || node.type === 'solution') && node.description && (
|
||||||
|
<div className="border-t border-border px-3 py-1.5">
|
||||||
|
<div className="line-clamp-2 text-xs text-muted-foreground">{node.description}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Collapse chevron */}
|
||||||
|
{hasChildren && (
|
||||||
|
<div className="flex justify-center border-t border-border py-1">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
onToggleCollapse(node.id)
|
||||||
|
}}
|
||||||
|
className="flex items-center gap-1 rounded px-2 py-0.5 text-xs text-muted-foreground hover:bg-accent hover:text-foreground transition-colors"
|
||||||
|
>
|
||||||
|
{isCollapsed ? (
|
||||||
|
<>
|
||||||
|
<ChevronRight className="h-3 w-3" />
|
||||||
|
<span className="font-label">Expand</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<ChevronDown className="h-3 w-3" />
|
||||||
|
<span className="font-label">Collapse</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Source handle at bottom */}
|
||||||
|
<Handle type="source" position={Position.Bottom} className="!bg-border !w-2 !h-2 !border-0" />
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const FlowCanvasNode = memo(FlowCanvasNodeComponent)
|
||||||
|
export { NODE_TYPE_CONFIG }
|
||||||
264
frontend/src/components/tree-editor/NodeEditorPanel.tsx
Normal file
264
frontend/src/components/tree-editor/NodeEditorPanel.tsx
Normal file
@@ -0,0 +1,264 @@
|
|||||||
|
import { useState, useCallback, useEffect, useRef } from 'react'
|
||||||
|
import { HelpCircle, Zap, CheckCircle, X, Trash2, Copy, Save } from 'lucide-react'
|
||||||
|
import { useTreeEditorStore, findNodeInTree } from '@/store/treeEditorStore'
|
||||||
|
import { NodeFormDecision } from './NodeFormDecision'
|
||||||
|
import { NodeFormAction } from './NodeFormAction'
|
||||||
|
import { NodeFormResolution } from './NodeFormResolution'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
import type { TreeStructure, NodeType } from '@/types'
|
||||||
|
|
||||||
|
interface NodeEditorPanelProps {
|
||||||
|
nodeId: string
|
||||||
|
onClose: () => void
|
||||||
|
onSelectType?: (nodeId: string, type: 'decision' | 'action' | 'solution') => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const TYPE_CONFIG: Record<Exclude<NodeType, 'answer'>, { icon: typeof HelpCircle; label: string; badgeClass: string }> = {
|
||||||
|
decision: { icon: HelpCircle, label: 'Decision', badgeClass: 'bg-blue-500/20 text-blue-400' },
|
||||||
|
action: { icon: Zap, label: 'Action', badgeClass: 'bg-yellow-500/20 text-yellow-400' },
|
||||||
|
solution: { icon: CheckCircle, label: 'Solution', badgeClass: 'bg-green-500/20 text-green-400' },
|
||||||
|
}
|
||||||
|
|
||||||
|
function cloneWithoutChildren(node: TreeStructure): TreeStructure {
|
||||||
|
const { children: _children, ...rest } = node
|
||||||
|
return structuredClone(rest) as TreeStructure
|
||||||
|
}
|
||||||
|
|
||||||
|
export function NodeEditorPanel({ nodeId, onClose, onSelectType }: NodeEditorPanelProps) {
|
||||||
|
const treeStructure = useTreeEditorStore(s => s.treeStructure)
|
||||||
|
const updateNode = useTreeEditorStore(s => s.updateNode)
|
||||||
|
const deleteNode = useTreeEditorStore(s => s.deleteNode)
|
||||||
|
const duplicateNode = useTreeEditorStore(s => s.duplicateNode)
|
||||||
|
const addNode = useTreeEditorStore(s => s.addNode)
|
||||||
|
const selectNode = useTreeEditorStore(s => s.selectNode)
|
||||||
|
|
||||||
|
const node = treeStructure ? findNodeInTree(nodeId, treeStructure) : null
|
||||||
|
const [draft, setDraft] = useState<TreeStructure | null>(null)
|
||||||
|
const [isDirty, setIsDirty] = useState(false)
|
||||||
|
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
|
||||||
|
const panelRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
|
// Initialize/reset draft when nodeId changes
|
||||||
|
useEffect(() => {
|
||||||
|
if (node) {
|
||||||
|
setDraft(cloneWithoutChildren(node))
|
||||||
|
setIsDirty(false)
|
||||||
|
setShowDeleteConfirm(false)
|
||||||
|
}
|
||||||
|
}, [nodeId]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
|
const handleDraftUpdate = useCallback((updates: Partial<TreeStructure>) => {
|
||||||
|
setDraft(prev => prev ? { ...prev, ...updates } : prev)
|
||||||
|
setIsDirty(true)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleSave = useCallback(() => {
|
||||||
|
if (!draft || !node) return
|
||||||
|
const { children: _children, ...draftWithoutChildren } = draft
|
||||||
|
updateNode(nodeId, draftWithoutChildren)
|
||||||
|
|
||||||
|
// Auto-create answer stubs for new decision options without next_node_id
|
||||||
|
if (draft.options) {
|
||||||
|
const options = draft.options.filter(o => o.label.trim())
|
||||||
|
const stubsCreated: Array<{ optId: string; stubId: string }> = []
|
||||||
|
|
||||||
|
options.forEach(opt => {
|
||||||
|
if (!opt.next_node_id) {
|
||||||
|
const stubId = addNode(nodeId, 'answer')
|
||||||
|
updateNode(stubId, { title: opt.label })
|
||||||
|
stubsCreated.push({ optId: opt.id, stubId })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if (stubsCreated.length > 0) {
|
||||||
|
const updatedOptions = options.map(o => {
|
||||||
|
const stub = stubsCreated.find(s => s.optId === o.id)
|
||||||
|
return stub ? { ...o, next_node_id: stub.stubId } : o
|
||||||
|
})
|
||||||
|
updateNode(nodeId, { options: updatedOptions })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsDirty(false)
|
||||||
|
}, [draft, node, nodeId, updateNode, addNode])
|
||||||
|
|
||||||
|
const handleClose = useCallback(() => {
|
||||||
|
if (isDirty) {
|
||||||
|
if (!window.confirm('You have unsaved changes. Discard them?')) return
|
||||||
|
}
|
||||||
|
onClose()
|
||||||
|
}, [isDirty, onClose])
|
||||||
|
|
||||||
|
// Escape to close
|
||||||
|
useEffect(() => {
|
||||||
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
handleClose()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
document.addEventListener('keydown', handleKeyDown)
|
||||||
|
return () => document.removeEventListener('keydown', handleKeyDown)
|
||||||
|
}, [handleClose])
|
||||||
|
|
||||||
|
const handleDelete = useCallback(() => {
|
||||||
|
if (!treeStructure) return
|
||||||
|
clearInboundReferences(nodeId, treeStructure, updateNode)
|
||||||
|
deleteNode(nodeId)
|
||||||
|
onClose()
|
||||||
|
}, [nodeId, treeStructure, updateNode, deleteNode, onClose])
|
||||||
|
|
||||||
|
const handleDuplicate = useCallback(() => {
|
||||||
|
const newId = duplicateNode(nodeId)
|
||||||
|
if (newId) {
|
||||||
|
selectNode(newId)
|
||||||
|
}
|
||||||
|
}, [nodeId, duplicateNode, selectNode])
|
||||||
|
|
||||||
|
if (!node || !draft) return null
|
||||||
|
|
||||||
|
// Answer stub: show type picker instead of form
|
||||||
|
if (node.type === 'answer') {
|
||||||
|
return (
|
||||||
|
<div ref={panelRef} className="flex h-full w-[400px] shrink-0 flex-col border-l border-border bg-card">
|
||||||
|
<div className="flex items-center justify-between border-b border-border px-4 py-3">
|
||||||
|
<span className="text-sm font-heading font-medium text-foreground">
|
||||||
|
{node.title || 'Answer Placeholder'}
|
||||||
|
</span>
|
||||||
|
<button onClick={handleClose} className="rounded-md p-1 text-muted-foreground hover:bg-accent hover:text-foreground">
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-1 flex-col items-center justify-center gap-4 px-4">
|
||||||
|
<p className="text-sm text-muted-foreground text-center">Choose a type for this node:</p>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{(['decision', 'action', 'solution'] as const).map(type => {
|
||||||
|
const cfg = TYPE_CONFIG[type]
|
||||||
|
const TypeIcon = cfg.icon
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={type}
|
||||||
|
type="button"
|
||||||
|
onClick={() => onSelectType?.(nodeId, type)}
|
||||||
|
className={cn(
|
||||||
|
'flex items-center gap-1.5 rounded-lg px-3 py-2 text-sm font-label border transition-colors',
|
||||||
|
type === 'decision' && 'border-blue-500/30 bg-blue-500/10 text-blue-400 hover:bg-blue-500/20',
|
||||||
|
type === 'action' && 'border-yellow-500/30 bg-yellow-500/10 text-yellow-400 hover:bg-yellow-500/20',
|
||||||
|
type === 'solution' && 'border-green-500/30 bg-green-500/10 text-green-400 hover:bg-green-500/20',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<TypeIcon className="h-4 w-4" /> {cfg.label}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const config = TYPE_CONFIG[node.type as Exclude<NodeType, 'answer'>] ?? TYPE_CONFIG.decision
|
||||||
|
const TypeIcon = config.icon
|
||||||
|
const title = node.type === 'decision' ? (node.question || 'Untitled Decision') : (node.title || `Untitled ${config.label}`)
|
||||||
|
const isRoot = treeStructure?.id === nodeId
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={panelRef} className="flex h-[calc(100vh-105px)] w-[400px] shrink-0 flex-col border-l border-border bg-card">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center gap-2 border-b border-border px-4 py-3 shrink-0">
|
||||||
|
<span className={cn('flex h-5 w-5 shrink-0 items-center justify-center rounded', config.badgeClass)}>
|
||||||
|
<TypeIcon className="h-3 w-3" />
|
||||||
|
</span>
|
||||||
|
<span className="flex-1 truncate text-sm font-heading font-medium text-foreground">{title}</span>
|
||||||
|
<button onClick={handleClose} className="rounded-md p-1 text-muted-foreground hover:bg-accent hover:text-foreground">
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Body — scrollable form area */}
|
||||||
|
<div className="min-h-0 flex-1 overflow-y-auto px-4 py-3">
|
||||||
|
{draft.type === 'decision' && <NodeFormDecision node={draft} onUpdate={handleDraftUpdate} />}
|
||||||
|
{draft.type === 'action' && <NodeFormAction node={draft} onUpdate={handleDraftUpdate} />}
|
||||||
|
{draft.type === 'solution' && <NodeFormResolution node={draft} onUpdate={handleDraftUpdate} />}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<div className="flex items-center gap-2 border-t border-border px-4 py-3 shrink-0">
|
||||||
|
<button
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={!isDirty}
|
||||||
|
className={cn(
|
||||||
|
'flex items-center gap-1.5 rounded-lg px-4 py-2 text-sm font-medium text-white shadow-lg shadow-primary/20 transition-opacity',
|
||||||
|
isDirty ? 'bg-gradient-brand hover:opacity-90' : 'bg-gradient-brand opacity-50 cursor-not-allowed'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Save className="h-3.5 w-3.5" /> Save
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleClose}
|
||||||
|
className="rounded-lg border border-border px-4 py-2 text-sm text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<div className="flex-1" />
|
||||||
|
{!isRoot && (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
onClick={handleDuplicate}
|
||||||
|
className="rounded-md p-2 text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||||
|
title="Duplicate"
|
||||||
|
>
|
||||||
|
<Copy className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
{showDeleteConfirm ? (
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<button
|
||||||
|
onClick={handleDelete}
|
||||||
|
className="rounded-md bg-red-500/20 px-2 py-1 text-xs text-red-400 hover:bg-red-500/30"
|
||||||
|
>
|
||||||
|
Confirm
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowDeleteConfirm(false)}
|
||||||
|
className="rounded-md px-2 py-1 text-xs text-muted-foreground hover:bg-accent"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
onClick={() => setShowDeleteConfirm(true)}
|
||||||
|
className="rounded-md p-2 text-muted-foreground hover:bg-accent hover:text-red-400"
|
||||||
|
title="Delete"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear all next_node_id references to a node before deleting
|
||||||
|
function clearInboundReferences(
|
||||||
|
nodeId: string,
|
||||||
|
treeStructure: TreeStructure,
|
||||||
|
updateNode: (id: string, updates: Partial<TreeStructure>) => void
|
||||||
|
) {
|
||||||
|
function walk(node: TreeStructure) {
|
||||||
|
if (node.type === 'decision' && node.options) {
|
||||||
|
const needsUpdate = node.options.some(o => o.next_node_id === nodeId)
|
||||||
|
if (needsUpdate) {
|
||||||
|
updateNode(node.id, {
|
||||||
|
options: node.options.map(o => o.next_node_id === nodeId ? { ...o, next_node_id: '' } : o),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (node.type === 'action' && node.next_node_id === nodeId) {
|
||||||
|
updateNode(node.id, { next_node_id: '' })
|
||||||
|
}
|
||||||
|
node.children?.forEach(walk)
|
||||||
|
}
|
||||||
|
walk(treeStructure)
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { lazy, Suspense } from 'react'
|
import { lazy, Suspense } from 'react'
|
||||||
import { TreePreviewPanel } from '@/components/tree-preview/TreePreviewPanel'
|
import { TreePreviewPanel } from '@/components/tree-preview/TreePreviewPanel'
|
||||||
import { TreeCanvas } from './TreeCanvas'
|
import { FlowCanvas } from './FlowCanvas'
|
||||||
|
import { NodeEditorPanel } from './NodeEditorPanel'
|
||||||
import { MetadataSidePanel } from './MetadataSidePanel'
|
import { MetadataSidePanel } from './MetadataSidePanel'
|
||||||
import { useTreeEditorStore } from '@/store/treeEditorStore'
|
import { useTreeEditorStore } from '@/store/treeEditorStore'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
@@ -14,19 +15,25 @@ interface TreeEditorLayoutProps {
|
|||||||
isMobile?: boolean
|
isMobile?: boolean
|
||||||
isMetadataOpen?: boolean
|
isMetadataOpen?: boolean
|
||||||
onCloseMetadata?: () => void
|
onCloseMetadata?: () => void
|
||||||
|
editingNodeId: string | null
|
||||||
|
onNodeSelect: (nodeId: string | null) => void
|
||||||
|
onSelectAnswerType: (nodeId: string, type: 'decision' | 'action' | 'solution') => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export function TreeEditorLayout({
|
export function TreeEditorLayout({
|
||||||
isMobile = false,
|
isMobile = false,
|
||||||
isMetadataOpen = false,
|
isMetadataOpen = false,
|
||||||
onCloseMetadata = () => {},
|
onCloseMetadata = () => {},
|
||||||
|
editingNodeId,
|
||||||
|
onNodeSelect,
|
||||||
|
onSelectAnswerType,
|
||||||
}: TreeEditorLayoutProps) {
|
}: TreeEditorLayoutProps) {
|
||||||
const editorMode = useTreeEditorStore(s => s.editorMode)
|
const editorMode = useTreeEditorStore(s => s.editorMode)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex flex-1 overflow-hidden',
|
'flex min-h-0 flex-1 overflow-hidden',
|
||||||
isMobile ? 'flex-col' : 'flex-row'
|
isMobile ? 'flex-col' : 'flex-row'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -56,16 +63,31 @@ export function TreeEditorLayout({
|
|||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{/* Flow Mode: Full-width visual canvas */}
|
{/* Flow Mode: React Flow canvas + side panels */}
|
||||||
<div className="flex-1 overflow-hidden">
|
<div className="flex-1 overflow-hidden">
|
||||||
<TreeCanvas />
|
<FlowCanvas
|
||||||
|
selectedNodeId={editingNodeId}
|
||||||
|
onNodeSelect={onNodeSelect}
|
||||||
|
onSelectAnswerType={onSelectAnswerType}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Metadata side panel — overlays the canvas from the right */}
|
{/* Node editor panel — takes real layout space */}
|
||||||
<MetadataSidePanel
|
{editingNodeId && (
|
||||||
isOpen={isMetadataOpen}
|
<NodeEditorPanel
|
||||||
onClose={onCloseMetadata}
|
nodeId={editingNodeId}
|
||||||
/>
|
onClose={() => onNodeSelect(null)}
|
||||||
|
onSelectType={onSelectAnswerType}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Metadata side panel — only show when node editor is closed */}
|
||||||
|
{!editingNodeId && (
|
||||||
|
<MetadataSidePanel
|
||||||
|
isOpen={isMetadataOpen}
|
||||||
|
onClose={onCloseMetadata}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -7,3 +7,7 @@ export { NodeFormAction } from './NodeFormAction'
|
|||||||
export { NodeFormResolution } from './NodeFormResolution'
|
export { NodeFormResolution } from './NodeFormResolution'
|
||||||
export { DynamicArrayField } from './DynamicArrayField'
|
export { DynamicArrayField } from './DynamicArrayField'
|
||||||
export { NodePicker } from './NodePicker'
|
export { NodePicker } from './NodePicker'
|
||||||
|
export { FlowCanvas } from './FlowCanvas'
|
||||||
|
export { FlowCanvasNode } from './FlowCanvasNode'
|
||||||
|
export { FlowCanvasAnswerNode } from './FlowCanvasAnswerNode'
|
||||||
|
export { NodeEditorPanel } from './NodeEditorPanel'
|
||||||
|
|||||||
199
frontend/src/components/tree-editor/useTreeLayout.ts
Normal file
199
frontend/src/components/tree-editor/useTreeLayout.ts
Normal file
@@ -0,0 +1,199 @@
|
|||||||
|
import { useMemo, useCallback, useState, useRef, useEffect } from 'react'
|
||||||
|
import type { Node, Edge } from '@xyflow/react'
|
||||||
|
import type { TreeStructure } from '@/types'
|
||||||
|
import { getLayoutedElements, NODE_WIDTH } from '@/lib/dagreLayout'
|
||||||
|
import type { FlowCanvasNodeData } from './FlowCanvasNode'
|
||||||
|
import type { FlowCanvasAnswerNodeData } from './FlowCanvasAnswerNode'
|
||||||
|
import { useTreeEditorStore } from '@/store/treeEditorStore'
|
||||||
|
|
||||||
|
const MAX_EDGE_LABEL_LENGTH = 35
|
||||||
|
|
||||||
|
function truncateLabel(label: string): string {
|
||||||
|
if (label.length <= MAX_EDGE_LABEL_LENGTH) return label
|
||||||
|
return label.slice(0, MAX_EDGE_LABEL_LENGTH).trimEnd() + '…'
|
||||||
|
}
|
||||||
|
|
||||||
|
function estimateNodeHeight(node: TreeStructure): number {
|
||||||
|
let height = 52 // header baseline
|
||||||
|
if (node.type === 'decision' && node.options) {
|
||||||
|
height += 24 // options header line
|
||||||
|
height += Math.min(node.options.length, 3) * 18 // option rows (max 3 shown)
|
||||||
|
if (node.options.length > 3) height += 18 // "+N more" row
|
||||||
|
}
|
||||||
|
if ((node.type === 'action' || node.type === 'solution') && node.description) {
|
||||||
|
height += 36 // description preview (2 lines)
|
||||||
|
}
|
||||||
|
if (node.type === 'answer') {
|
||||||
|
return 70 // fixed height for answer stubs
|
||||||
|
}
|
||||||
|
return height
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UseTreeLayoutResult {
|
||||||
|
nodes: Node[]
|
||||||
|
edges: Edge[]
|
||||||
|
collapsedNodeIds: Set<string>
|
||||||
|
toggleCollapse: (nodeId: string) => void
|
||||||
|
onNodesMeasured: (measuredNodes: Node[]) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useTreeLayout(): UseTreeLayoutResult {
|
||||||
|
const treeStructure = useTreeEditorStore(s => s.treeStructure)
|
||||||
|
const validationErrors = useTreeEditorStore(s => s.validationErrors)
|
||||||
|
const [collapsedNodeIds, setCollapsedNodeIds] = useState<Set<string>>(new Set())
|
||||||
|
const [measuredHeights, setMeasuredHeights] = useState<Map<string, number>>(new Map())
|
||||||
|
const correctionDone = useRef(false)
|
||||||
|
|
||||||
|
const toggleCollapse = useCallback((nodeId: string) => {
|
||||||
|
setCollapsedNodeIds(prev => {
|
||||||
|
const next = new Set(prev)
|
||||||
|
if (next.has(nodeId)) next.delete(nodeId)
|
||||||
|
else next.add(nodeId)
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Convert tree structure to flat nodes and edges
|
||||||
|
const { rawNodes, rawEdges } = useMemo(() => {
|
||||||
|
const nodes: Node[] = []
|
||||||
|
const edges: Edge[] = []
|
||||||
|
|
||||||
|
if (!treeStructure) return { rawNodes: nodes, rawEdges: edges }
|
||||||
|
|
||||||
|
function walk(node: TreeStructure, _parentId?: string | null) {
|
||||||
|
const isCollapsed = collapsedNodeIds.has(node.id)
|
||||||
|
const hasChildren = (node.children?.length ?? 0) > 0
|
||||||
|
const hasErrors = validationErrors.some(e => e.nodeId === node.id && e.severity === 'error')
|
||||||
|
|
||||||
|
const estimatedHeight = measuredHeights.get(node.id) ?? estimateNodeHeight(node)
|
||||||
|
|
||||||
|
if (node.type === 'answer') {
|
||||||
|
nodes.push({
|
||||||
|
id: node.id,
|
||||||
|
type: 'answerStub',
|
||||||
|
position: { x: 0, y: 0 }, // dagre will set this
|
||||||
|
data: {
|
||||||
|
node,
|
||||||
|
onSelectType: () => {}, // placeholder — set by FlowCanvas
|
||||||
|
} satisfies FlowCanvasAnswerNodeData,
|
||||||
|
style: { width: NODE_WIDTH },
|
||||||
|
measured: { width: NODE_WIDTH, height: estimatedHeight },
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
nodes.push({
|
||||||
|
id: node.id,
|
||||||
|
type: 'flowNode',
|
||||||
|
position: { x: 0, y: 0 },
|
||||||
|
data: {
|
||||||
|
node,
|
||||||
|
hasChildren,
|
||||||
|
isCollapsed,
|
||||||
|
hasValidationErrors: hasErrors,
|
||||||
|
isNew: false,
|
||||||
|
onToggleCollapse: () => {}, // placeholder — set by FlowCanvas
|
||||||
|
} satisfies FlowCanvasNodeData,
|
||||||
|
style: { width: NODE_WIDTH },
|
||||||
|
measured: { width: NODE_WIDTH, height: estimatedHeight },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip children if collapsed
|
||||||
|
if (isCollapsed) return
|
||||||
|
|
||||||
|
// Create edges and recurse into children
|
||||||
|
if (node.children) {
|
||||||
|
// For decision nodes: order children by option link, then unlinked
|
||||||
|
const orderedChildren = orderChildren(node)
|
||||||
|
|
||||||
|
for (const { child, optionLabel } of orderedChildren) {
|
||||||
|
const edgeLabel = optionLabel ? truncateLabel(optionLabel) : undefined
|
||||||
|
|
||||||
|
edges.push({
|
||||||
|
id: `${node.id}->${child.id}`,
|
||||||
|
source: node.id,
|
||||||
|
target: child.id,
|
||||||
|
type: 'smoothstep',
|
||||||
|
label: edgeLabel,
|
||||||
|
labelStyle: { fill: 'hsl(var(--muted-foreground))', fontSize: 11 },
|
||||||
|
labelBgStyle: { fill: 'hsl(var(--card))', fillOpacity: 0.9 },
|
||||||
|
labelBgPadding: [4, 2] as [number, number],
|
||||||
|
style: { stroke: 'hsl(var(--border))' },
|
||||||
|
})
|
||||||
|
|
||||||
|
walk(child, node.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
walk(treeStructure, null)
|
||||||
|
|
||||||
|
return { rawNodes: nodes, rawEdges: edges }
|
||||||
|
}, [treeStructure, collapsedNodeIds, validationErrors, measuredHeights])
|
||||||
|
|
||||||
|
// Run dagre layout
|
||||||
|
const { nodes, edges } = useMemo(() => {
|
||||||
|
if (rawNodes.length === 0) return { nodes: rawNodes, edges: rawEdges }
|
||||||
|
const layouted = getLayoutedElements(rawNodes, rawEdges)
|
||||||
|
return { nodes: layouted, edges: rawEdges }
|
||||||
|
}, [rawNodes, rawEdges])
|
||||||
|
|
||||||
|
// Height measurement correction callback
|
||||||
|
const onNodesMeasured = useCallback((measuredNodes: Node[]) => {
|
||||||
|
if (correctionDone.current) return
|
||||||
|
|
||||||
|
let needsCorrection = false
|
||||||
|
const newHeights = new Map(measuredHeights)
|
||||||
|
|
||||||
|
for (const mNode of measuredNodes) {
|
||||||
|
const actual = mNode.measured?.height
|
||||||
|
if (!actual) continue
|
||||||
|
const estimated = measuredHeights.get(mNode.id) ?? estimateNodeHeight(
|
||||||
|
(mNode.data as unknown as FlowCanvasNodeData)?.node ?? (mNode.data as unknown as FlowCanvasAnswerNodeData)?.node
|
||||||
|
)
|
||||||
|
if (Math.abs(actual - estimated) > 10) {
|
||||||
|
newHeights.set(mNode.id, actual)
|
||||||
|
needsCorrection = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (needsCorrection) {
|
||||||
|
correctionDone.current = true
|
||||||
|
setMeasuredHeights(newHeights)
|
||||||
|
}
|
||||||
|
}, [measuredHeights])
|
||||||
|
|
||||||
|
// Reset correction flag when tree structure changes
|
||||||
|
useEffect(() => {
|
||||||
|
correctionDone.current = false
|
||||||
|
}, [treeStructure, collapsedNodeIds])
|
||||||
|
|
||||||
|
return { nodes, edges, collapsedNodeIds, toggleCollapse, onNodesMeasured }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper: order children by decision option links
|
||||||
|
function orderChildren(node: TreeStructure): Array<{ child: TreeStructure; optionLabel?: string }> {
|
||||||
|
if (!node.children || node.children.length === 0) return []
|
||||||
|
|
||||||
|
if (node.type === 'decision' && node.options) {
|
||||||
|
const linked: Array<{ child: TreeStructure; optionLabel: string }> = []
|
||||||
|
const linkedIds = new Set<string>()
|
||||||
|
|
||||||
|
for (const opt of node.options) {
|
||||||
|
if (opt.next_node_id) {
|
||||||
|
const child = node.children.find(c => c.id === opt.next_node_id)
|
||||||
|
if (child) {
|
||||||
|
linked.push({ child, optionLabel: opt.label })
|
||||||
|
linkedIds.add(child.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const unlinked = node.children
|
||||||
|
.filter(c => !linkedIds.has(c.id))
|
||||||
|
.map(child => ({ child, optionLabel: undefined }))
|
||||||
|
|
||||||
|
return [...linked, ...unlinked]
|
||||||
|
}
|
||||||
|
|
||||||
|
return node.children.map(child => ({ child, optionLabel: undefined }))
|
||||||
|
}
|
||||||
@@ -315,3 +315,60 @@
|
|||||||
@apply invisible;
|
@apply invisible;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* React Flow dark theme overrides */
|
||||||
|
.react-flow__background {
|
||||||
|
background-color: transparent !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.react-flow__controls {
|
||||||
|
background-color: hsl(var(--card)) !important;
|
||||||
|
border: 1px solid hsl(var(--border)) !important;
|
||||||
|
border-radius: 0.75rem !important;
|
||||||
|
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.3) !important;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.react-flow__controls-button {
|
||||||
|
background-color: hsl(var(--card)) !important;
|
||||||
|
border-color: hsl(var(--border)) !important;
|
||||||
|
fill: hsl(var(--muted-foreground)) !important;
|
||||||
|
color: hsl(var(--muted-foreground)) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.react-flow__controls-button:hover {
|
||||||
|
background-color: hsl(var(--accent)) !important;
|
||||||
|
fill: hsl(var(--foreground)) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.react-flow__controls-button svg {
|
||||||
|
fill: inherit !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.react-flow__minimap {
|
||||||
|
background-color: hsl(var(--card)) !important;
|
||||||
|
border: 1px solid hsl(var(--border)) !important;
|
||||||
|
border-radius: 0.75rem !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.react-flow__edge-path {
|
||||||
|
stroke: hsl(var(--border));
|
||||||
|
}
|
||||||
|
|
||||||
|
.react-flow__edge-text {
|
||||||
|
fill: hsl(var(--muted-foreground));
|
||||||
|
}
|
||||||
|
|
||||||
|
.react-flow__edge-textbg {
|
||||||
|
fill: hsl(var(--card));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hide default React Flow attribution */
|
||||||
|
.react-flow__attribution {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Handle styles */
|
||||||
|
.react-flow__handle {
|
||||||
|
background-color: hsl(var(--border));
|
||||||
|
}
|
||||||
|
|||||||
44
frontend/src/lib/dagreLayout.ts
Normal file
44
frontend/src/lib/dagreLayout.ts
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import dagre from '@dagrejs/dagre'
|
||||||
|
import type { Node, Edge } from '@xyflow/react'
|
||||||
|
|
||||||
|
const NODE_WIDTH = 280
|
||||||
|
const DEFAULT_NODE_HEIGHT = 100
|
||||||
|
|
||||||
|
interface LayoutOptions {
|
||||||
|
direction?: 'TB' | 'LR'
|
||||||
|
rankSep?: number
|
||||||
|
nodeSep?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getLayoutedElements(
|
||||||
|
nodes: Node[],
|
||||||
|
edges: Edge[],
|
||||||
|
options: LayoutOptions = {}
|
||||||
|
): Node[] {
|
||||||
|
const { direction = 'TB', rankSep = 100, nodeSep = 40 } = options
|
||||||
|
|
||||||
|
const g = new dagre.graphlib.Graph()
|
||||||
|
g.setDefaultEdgeLabel(() => ({}))
|
||||||
|
g.setGraph({ rankdir: direction, ranksep: rankSep, nodesep: nodeSep })
|
||||||
|
|
||||||
|
nodes.forEach((node) => {
|
||||||
|
const height = node.measured?.height ?? node.data?.estimatedHeight ?? DEFAULT_NODE_HEIGHT
|
||||||
|
g.setNode(node.id, { width: NODE_WIDTH, height })
|
||||||
|
})
|
||||||
|
|
||||||
|
edges.forEach((edge) => {
|
||||||
|
g.setEdge(edge.source, edge.target)
|
||||||
|
})
|
||||||
|
|
||||||
|
dagre.layout(g)
|
||||||
|
|
||||||
|
return nodes.map((node) => {
|
||||||
|
const dagreNode = g.node(node.id)
|
||||||
|
// dagre gives center positions — convert to top-left for React Flow
|
||||||
|
const x = dagreNode.x - NODE_WIDTH / 2
|
||||||
|
const y = dagreNode.y - (dagreNode.height ?? DEFAULT_NODE_HEIGHT) / 2
|
||||||
|
return { ...node, position: { x, y } }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export { NODE_WIDTH, DEFAULT_NODE_HEIGHT }
|
||||||
@@ -45,6 +45,7 @@ export function TreeEditorPage() {
|
|||||||
setLoading,
|
setLoading,
|
||||||
setSaving,
|
setSaving,
|
||||||
selectNode,
|
selectNode,
|
||||||
|
updateNode,
|
||||||
setEditorMode,
|
setEditorMode,
|
||||||
} = useTreeEditorStore()
|
} = useTreeEditorStore()
|
||||||
|
|
||||||
@@ -55,6 +56,7 @@ export function TreeEditorPage() {
|
|||||||
const [treeStatus, setTreeStatus] = useState<TreeStatus>('draft')
|
const [treeStatus, setTreeStatus] = useState<TreeStatus>('draft')
|
||||||
const [showAnalytics, setShowAnalytics] = useState(false)
|
const [showAnalytics, setShowAnalytics] = useState(false)
|
||||||
const [isMetadataOpen, setIsMetadataOpen] = useState(false)
|
const [isMetadataOpen, setIsMetadataOpen] = useState(false)
|
||||||
|
const [editingNodeId, setEditingNodeId] = useState<string | null>(null)
|
||||||
|
|
||||||
// Mobile detection
|
// Mobile detection
|
||||||
const [isMobile, setIsMobile] = useState(false)
|
const [isMobile, setIsMobile] = useState(false)
|
||||||
@@ -211,6 +213,21 @@ export function TreeEditorPage() {
|
|||||||
selectNode(nodeId)
|
selectNode(nodeId)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleNodeSelect = useCallback((nodeId: string | null) => {
|
||||||
|
if (nodeId) {
|
||||||
|
setIsMetadataOpen(false) // close metadata when opening node editor
|
||||||
|
}
|
||||||
|
setEditingNodeId(nodeId)
|
||||||
|
selectNode(nodeId)
|
||||||
|
}, [selectNode])
|
||||||
|
|
||||||
|
const handleSelectAnswerType = useCallback((nodeId: string, type: 'decision' | 'action' | 'solution') => {
|
||||||
|
updateNode(nodeId, { type })
|
||||||
|
// Keep the panel open on the same node — it will now show the form for the new type
|
||||||
|
setEditingNodeId(nodeId)
|
||||||
|
selectNode(nodeId)
|
||||||
|
}, [updateNode, selectNode])
|
||||||
|
|
||||||
const handleSaveDraft = useCallback(async () => {
|
const handleSaveDraft = useCallback(async () => {
|
||||||
setSaving(true)
|
setSaving(true)
|
||||||
try {
|
try {
|
||||||
@@ -390,7 +407,7 @@ export function TreeEditorPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full flex-col">
|
<div className="flex h-full flex-col overflow-hidden">
|
||||||
|
|
||||||
{/* Draft Restore Prompt */}
|
{/* Draft Restore Prompt */}
|
||||||
{showDraftPrompt && (
|
{showDraftPrompt && (
|
||||||
@@ -507,6 +524,7 @@ export function TreeEditorPage() {
|
|||||||
onClick={() => {
|
onClick={() => {
|
||||||
setEditorMode('code')
|
setEditorMode('code')
|
||||||
setIsMetadataOpen(false) // Auto-close metadata panel on Code mode
|
setIsMetadataOpen(false) // Auto-close metadata panel on Code mode
|
||||||
|
setEditingNodeId(null) // Close node editor on Code mode
|
||||||
}}
|
}}
|
||||||
title="Code Mode — markdown editing (Ctrl+Shift+M)"
|
title="Code Mode — markdown editing (Ctrl+Shift+M)"
|
||||||
className={cn(
|
className={cn(
|
||||||
@@ -562,7 +580,12 @@ export function TreeEditorPage() {
|
|||||||
{editorMode === 'form' && (
|
{editorMode === 'form' && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setIsMetadataOpen(!isMetadataOpen)}
|
onClick={() => {
|
||||||
|
if (!isMetadataOpen) {
|
||||||
|
setEditingNodeId(null) // close node editor when opening metadata
|
||||||
|
}
|
||||||
|
setIsMetadataOpen(!isMetadataOpen)
|
||||||
|
}}
|
||||||
title="Edit flow metadata (name, description, category, tags)"
|
title="Edit flow metadata (name, description, category, tags)"
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex items-center gap-2 rounded-md border border-border px-3 py-2 text-sm font-medium transition-colors',
|
'flex items-center gap-2 rounded-md border border-border px-3 py-2 text-sm font-medium transition-colors',
|
||||||
@@ -652,6 +675,9 @@ export function TreeEditorPage() {
|
|||||||
isMobile={isMobile}
|
isMobile={isMobile}
|
||||||
isMetadataOpen={isMetadataOpen}
|
isMetadataOpen={isMetadataOpen}
|
||||||
onCloseMetadata={() => setIsMetadataOpen(false)}
|
onCloseMetadata={() => setIsMetadataOpen(false)}
|
||||||
|
editingNodeId={editingNodeId}
|
||||||
|
onNodeSelect={handleNodeSelect}
|
||||||
|
onSelectAnswerType={handleSelectAnswerType}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Flow Analytics Panel (collapsible) */}
|
{/* Flow Analytics Panel (collapsible) */}
|
||||||
|
|||||||
Reference in New Issue
Block a user