feat: canvas UX fixes — scroll, fullscreen, InfoTip tooltips, answer stub system (#80)
* feat: Add TreeCanvasNode inline editor card component Replaces modal-based node editing with inline expand/collapse cards. Each card shows node type, title, and options in compact mode, then renders the full edit form inline on expand — no modal required. Local draft state with save/cancel prevents premature store writes. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat: Add TreeCanvas layout with visual branching and orchestration Replaces NodeList + TreePreviewPanel with a single full-width canvas. Decision nodes branch horizontally; action/solution nodes flow vertically. Inline type picker adds nodes without modal interruption. Handles pending link resolution, inbound reference cleanup on delete, and selection sync. CSS dot-grid background + connector lines for structure clarity. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * refactor: Update forms for inline safety, add MetadataSidePanel, update layout - NodeFormDecision: option reorder via onUpdate (no premature store writes) - NodePicker: add allowCreate prop (default true) to hide Create New options during inline canvas editing, preventing side-effect node creation - MetadataSidePanel: 320px right slide-in overlay wrapping TreeMetadataForm, closes on backdrop click, close button, and Escape key - TreeEditorLayout: Flow mode now renders full-width TreeCanvas + MetadataSidePanel overlay; Code mode unchanged (Monaco + preview split) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat: Wire toolbar metadata toggle and integrate canvas layout - Add isMetadataOpen state in TreeEditorPage - Add Metadata toolbar button (visible in Flow mode only) - Auto-close metadata panel when switching to Code mode - Pass isMetadataOpen/onCloseMetadata props through TreeEditorLayout - Update Flow mode toggle tooltip to reflect new canvas editing Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * docs: add canvas UX fixes design doc (scroll, tooltips, answer stubs) Captures approved design for three post-implementation UX improvements to the tree canvas editor: card scroll fix, info tooltip replacement for hint text, and the new 'answer' node type for sketching decision branches before assigning types. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * docs: add implementation plan for canvas UX fixes 12-task plan covering scroll fix, info tooltips, and answer stub node type. Each task has exact file paths, code, and build verification steps. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: make canvas card expanded area scrollable with sticky header Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat: add fullscreen toggle to Modal, enable in NodeEditorModal Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat: add reusable InfoTip component for field-level help Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: replace hint paragraphs with InfoTip tooltips in NodeFormDecision Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: replace hint paragraphs with InfoTip tooltips in NodeFormAction Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: replace hint paragraphs with InfoTip tooltips in NodeFormResolution Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat: add 'answer' to NodeType union for branch placeholder stubs Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat: add AnswerStubCard component for unresolved branch placeholders Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: guard NODE_TYPE_CONFIG lookup against 'answer' type Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat: redesign NodeFormDecision to label-only options, remove NodePicker Users now type answer labels only. Stub nodes are created automatically by TreeCanvas when the decision node is saved. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat: auto-create answer stubs on decision save, render AnswerStubCard Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: add answer type to all Record<NodeType> icon and color maps Fixes NodeList, ContinuationModal, NodePicker, and TreePreviewNode. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat: allow 'answer' type in tree drafts, block on publish Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat: block publish if unresolved answer stub nodes exist Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat: serialize 'answer' stub nodes in markdown output Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: add defensive guard for answer nodes in session navigation Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat: add delete button with confirmation to AnswerStubCard Adds an inline delete flow to answer stub placeholder cards: - Trash icon button (top-right, subtle) visible in idle state - Click reveals "Delete this stub?" confirmation with Delete/Cancel - Confirmed delete calls onDelete(nodeId) wired to handleDelete in TreeCanvas Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: prevent category Cancel overflow and add Tab/Enter to create options - TreeMetadataForm: add min-w-0 + shrink-0 to keep Cancel button in-panel - NodeFormDecision: Tab or Enter on the last non-empty option input adds a new option and auto-focuses it; empty last input lets Tab pass through normally Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: re-sync draft from store when canvas card is opened When a decision node is saved with new options, stub next_node_id values are written back to the store. But the local draft was initialized once at mount and never refreshed, so reopening the card gave a stale draft with empty next_node_ids — causing duplicate stubs on every subsequent save. Fix: reset draft from the live node whenever isExpanded transitions to true. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix+feat: blank options, stub card dismiss, collapsible subtrees - TreeCanvas: strip blank-label options on save so they don't generate stubs; also filter them from the unlinked-option add-button list - AnswerStubCard: collapse type-picker when clicking outside the card - TreeCanvasNode: add subtree collapse toggle button (ChevronsDownUp icon) visible in compact mode when the node has children - TreeCanvas: track collapsedNodeIds; hide subtree behind a clickable "N nodes hidden" pill when collapsed Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: stop connector fork line from overlapping child cards Replace the two-element approach (separate fork line + child lanes div with mismatched maxWidth values) with a single relative-positioned container. The fork line is absolutely positioned and its left/right are calculated from the number of children so it spans exactly from the center of the first lane to the center of the last lane. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat: replace Show Drafts checkbox with Drafts tab in Flow Library - Remove the out-of-place checkbox; add 'Drafts' as a tab alongside All | Troubleshooting | Projects | Maintenance - Drafts tab sets showDrafts=true + typeFilter='all' so the API filter still works correctly via include_drafts - Move SortDropdown to the right side next to ViewToggle, so both secondary controls are grouped together Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit was merged in pull request #80.
This commit is contained in:
@@ -53,6 +53,13 @@ def validate_tree_structure(tree_structure: dict[str, Any]) -> tuple[bool, list[
|
||||
if "children" in tree_structure:
|
||||
_validate_children(tree_structure["children"], "root.children", errors)
|
||||
|
||||
# Block publish if any answer placeholder nodes remain
|
||||
if _has_answer_nodes(tree_structure):
|
||||
errors.append({
|
||||
"field": "tree_structure",
|
||||
"message": "Answer placeholders must be resolved to a node type before publishing."
|
||||
})
|
||||
|
||||
return len(errors) == 0, errors
|
||||
|
||||
|
||||
@@ -89,6 +96,10 @@ def _validate_node(node: dict[str, Any], path: str, errors: list[dict[str, str]]
|
||||
"message": "Solution nodes must have a non-empty solution"
|
||||
})
|
||||
|
||||
elif node_type == "answer":
|
||||
# Answer nodes are draft-only placeholders — no structural validation needed
|
||||
pass
|
||||
|
||||
else:
|
||||
errors.append({
|
||||
"field": f"{path}.type",
|
||||
@@ -115,6 +126,16 @@ def _validate_children(children: list[dict[str, Any]], path: str, errors: list[d
|
||||
_validate_children(child["children"], f"{child_path}.children", errors)
|
||||
|
||||
|
||||
def _has_answer_nodes(node: dict[str, Any]) -> bool:
|
||||
"""Recursively check if any node in the tree has type 'answer'."""
|
||||
if node.get("type") == "answer":
|
||||
return True
|
||||
for child in node.get("children", []):
|
||||
if _has_answer_nodes(child):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
# --- Procedural Tree Validation ---
|
||||
|
||||
VALID_STEP_TYPES = {"procedure_step", "procedure_end", "section_header"}
|
||||
|
||||
258
docs/plans/2026-02-18-canvas-ux-fixes-design.md
Normal file
258
docs/plans/2026-02-18-canvas-ux-fixes-design.md
Normal file
@@ -0,0 +1,258 @@
|
||||
# Canvas UX Fixes — Design Document
|
||||
|
||||
**Date:** 2026-02-18
|
||||
**Branch:** `feature/tree-editor-canvas`
|
||||
**Status:** Approved, pending implementation
|
||||
|
||||
---
|
||||
|
||||
## Context
|
||||
|
||||
The new TreeCanvas editor (Phase 1–4) was tested and three UX problems were identified:
|
||||
|
||||
1. **Scroll**: Expanded card forms have no height limit — long forms are cut off and unreachable
|
||||
2. **Busy forms**: Inline hint text (`<p className="text-xs">`) inside NodeForm components creates visual clutter
|
||||
3. **Answer stubs**: When building a decision node, users must immediately pick a child node type — there's no way to sketch out answer options first and decide types later
|
||||
|
||||
All three fixes apply exclusively to the canvas editor. No session, navigation, backend session-saving, or procedural flow code is affected.
|
||||
|
||||
---
|
||||
|
||||
## Fix 1: Card Scroll
|
||||
|
||||
### Problem
|
||||
|
||||
`TreeCanvasNode.tsx` renders the expanded editing area as an unbounded `<div>`. On decision nodes with many options, or on any node when the browser viewport is short, the card overflows off-screen. There is no scrollbar — content is unreachable. Tab cycling doesn't scroll the canvas to bring hidden fields into view.
|
||||
|
||||
### Design
|
||||
|
||||
Apply `max-h-[70vh] overflow-y-auto` to the expanded editing `<div>` inside `TreeCanvasNode.tsx`.
|
||||
|
||||
Make the save/cancel header row sticky (`sticky top-0 z-10 bg-card`) so the action buttons are always visible when the user scrolls the form content.
|
||||
|
||||
**Files changed:**
|
||||
- `frontend/src/components/tree-editor/TreeCanvasNode.tsx`
|
||||
- Add `max-h-[70vh] overflow-y-auto` to the expanded area `<div>` (currently `border-t border-border px-3 pb-3 pt-3`)
|
||||
- Add `sticky top-0 z-10 bg-card` to the card header `<div>` containing the save/cancel row when in expanded state
|
||||
|
||||
**No other files affected.**
|
||||
|
||||
---
|
||||
|
||||
## Fix 2: Info Tooltips
|
||||
|
||||
### Problem
|
||||
|
||||
`NodeFormDecision.tsx`, `NodeFormAction.tsx`, and `NodeFormResolution.tsx` each contain `<p className="mb-1 text-xs text-muted-foreground">` hint paragraphs below field labels. These add vertical height and visual noise inside a card that's already compact.
|
||||
|
||||
Examples of the current hint text:
|
||||
- "Supports markdown: **bold**, *italic*, - lists, 1. numbered lists, \`code\`"
|
||||
- "PowerShell or CLI commands to execute"
|
||||
- "Step-by-step instructions for resolving the issue"
|
||||
|
||||
### Design
|
||||
|
||||
Replace each hint `<p>` with a small `ⓘ` icon placed inline next to the field label. The icon shows a tooltip on hover containing the same text.
|
||||
|
||||
**Tooltip implementation:**
|
||||
|
||||
Use `title=""` on the icon element for a native browser tooltip. No third-party tooltip library needed — keeps the implementation minimal and consistent with the existing codebase pattern (the validation badge already uses `title={nodeErrors.map(...).join('\n')}`).
|
||||
|
||||
```tsx
|
||||
// Before
|
||||
<label className="block text-sm font-medium text-foreground">
|
||||
Description
|
||||
</label>
|
||||
<p className="mb-1 text-xs text-muted-foreground">
|
||||
Supports markdown: **bold**, *italic*, - lists, 1. numbered lists, `code`
|
||||
</p>
|
||||
|
||||
// After
|
||||
<label className="flex items-center gap-1.5 text-sm font-medium text-foreground">
|
||||
Description
|
||||
<span
|
||||
className="inline-flex items-center justify-center h-3.5 w-3.5 rounded-full border border-muted-foreground/40 text-[9px] text-muted-foreground cursor-help"
|
||||
title="Supports markdown: **bold**, *italic*, - lists, 1. numbered lists, `code`"
|
||||
>
|
||||
i
|
||||
</span>
|
||||
</label>
|
||||
```
|
||||
|
||||
**Files changed:**
|
||||
- `frontend/src/components/tree-editor/NodeFormDecision.tsx` — remove help_text hint `<p>`, replace with `ⓘ` on `Help Text` label
|
||||
- `frontend/src/components/tree-editor/NodeFormAction.tsx` — remove description markdown hint `<p>` and commands hint `<p>`, add `ⓘ` on those labels
|
||||
- `frontend/src/components/tree-editor/NodeFormResolution.tsx` — remove description markdown hint `<p>` and steps hint `<p>`, add `ⓘ` on those labels
|
||||
|
||||
---
|
||||
|
||||
## Fix 3: Answer Stubs (New `answer` Node Type)
|
||||
|
||||
### Problem
|
||||
|
||||
Decision nodes require the user to pick child node types at the same time they're creating the decision. This is backwards — you naturally know the answer options before you know what each one should do. The NodePicker in NodeFormDecision forces a concrete type selection (decision / action / solution) or leaves the option disconnected (`next_node_id: null`).
|
||||
|
||||
Users want to type answer labels first, see those answers appear as placeholder cards in the canvas, and then click each placeholder to assign a type and fill in details.
|
||||
|
||||
### Design
|
||||
|
||||
Introduce `'answer'` as a new internal NodeType that represents a typed-but-unresolved branch placeholder. Answer nodes are:
|
||||
- Created when a user types an answer label in the decision node form
|
||||
- Shown in the canvas as a dashed-border stub card with the answer label
|
||||
- Clickable to open a type picker (decision / action / solution) and convert to a real node
|
||||
- **Not publishable** — blocked by backend and frontend validation on publish
|
||||
|
||||
Answer nodes persist to draft saves so users don't lose their sketch when they navigate away.
|
||||
|
||||
### Data Model
|
||||
|
||||
**`frontend/src/types/tree.ts`**
|
||||
|
||||
```typescript
|
||||
// Before
|
||||
export type NodeType = 'decision' | 'action' | 'solution'
|
||||
|
||||
// After
|
||||
export type NodeType = 'decision' | 'action' | 'solution' | 'answer'
|
||||
```
|
||||
|
||||
`TreeStructure` interface: no new fields needed. Answer nodes use:
|
||||
- `id`: auto-generated UUID (same as other nodes)
|
||||
- `type`: `'answer'`
|
||||
- `title`: the answer label text (e.g. "Server", "Desktop")
|
||||
- No other fields required
|
||||
|
||||
### NodeFormDecision Redesign
|
||||
|
||||
Replace the current options UI (NodePicker per option → picks existing or creates new) with a two-zone layout:
|
||||
|
||||
**Zone 1 — Answer Labels**
|
||||
A simple list of text inputs, one per answer option. Each input edits `options[i].label`. Add/remove/reorder via `DynamicArrayField` (already available).
|
||||
|
||||
No `next_node_id` selection here. When the user saves, for each option that has a label but no `next_node_id`, a new answer-type stub node is created and linked automatically.
|
||||
|
||||
```
|
||||
Options (answer labels):
|
||||
[ Server ] [×]
|
||||
[ Desktop ] [×]
|
||||
[ + Add Answer ]
|
||||
```
|
||||
|
||||
**Zone 2 — (removed) NodePicker per option**
|
||||
The per-option NodePicker is removed entirely. The canvas becomes the way to traverse to a child and set its type.
|
||||
|
||||
### TreeCanvas Changes
|
||||
|
||||
**Rendering answer nodes:**
|
||||
|
||||
When a node has `type === 'answer'`, render an `AnswerStubCard` instead of a full `TreeCanvasNode`. The stub card:
|
||||
- Dashed border: `border-2 border-dashed border-border`
|
||||
- Colored left accent: none (neutral/muted)
|
||||
- Shows the answer label (`node.title`) centered
|
||||
- Shows a "+ Choose Type" label below the title
|
||||
- On click: opens an inline type picker (3 buttons: Decision / Action / Solution)
|
||||
- On type selection: calls `updateNode(node.id, { type: selectedType })` and immediately expands the node for editing
|
||||
|
||||
**New component:** `frontend/src/components/tree-editor/AnswerStubCard.tsx`
|
||||
|
||||
Props:
|
||||
```typescript
|
||||
interface AnswerStubCardProps {
|
||||
node: TreeStructure // type === 'answer'
|
||||
fromOption?: string // the answer label (same as node.title)
|
||||
onSelectType: (nodeId: string, type: 'decision' | 'action' | 'solution') => void
|
||||
}
|
||||
```
|
||||
|
||||
### Stub Creation Logic (TreeCanvas)
|
||||
|
||||
When a decision node is saved (`onSave`), the canvas compares options before/after:
|
||||
|
||||
For each option in the saved node:
|
||||
- If `option.next_node_id` is null/undefined → create a new answer stub node with `title = option.label` and link `option.next_node_id` to its ID.
|
||||
- If `option.next_node_id` already points to a node → leave it.
|
||||
|
||||
This creation logic lives in `TreeCanvas.tsx`'s `handleNodeSave()` function, which already handles pending link resolution.
|
||||
|
||||
### Backend Validation
|
||||
|
||||
**`backend/app/core/tree_validation.py`**
|
||||
|
||||
`_validate_node()` currently rejects unknown node types:
|
||||
```python
|
||||
if node_type not in ('decision', 'action', 'solution'):
|
||||
errors.append(...)
|
||||
```
|
||||
|
||||
Changes:
|
||||
1. Allow `'answer'` type through without structural validation (answer nodes have no required fields beyond `id` and `type`).
|
||||
2. Add a publish-time check in `can_publish_tree()` (or in `validate_tree_structure()` before publish): if any node has `type == 'answer'`, reject with a clear message: `"Answer placeholders must be resolved to a node type before publishing."`
|
||||
3. The draft save endpoint (`PUT /trees/:id`) does not call `can_publish_tree()`, so draft saves continue to work with answer nodes present.
|
||||
|
||||
### Frontend Publish Guard
|
||||
|
||||
In `TreeEditorPage.tsx`, before calling the publish API, add a check:
|
||||
```typescript
|
||||
const hasAnswerNodes = findAllAnswerNodes(tree.tree_structure).length > 0
|
||||
if (hasAnswerNodes) {
|
||||
// Show toast or inline error: "Resolve all answer placeholders before publishing."
|
||||
return
|
||||
}
|
||||
```
|
||||
|
||||
`findAllAnswerNodes` is a simple recursive traversal (can be a small utility function in `TreeCanvas.tsx` or a new file `lib/treeUtils.ts`).
|
||||
|
||||
### Visual Design (AnswerStubCard)
|
||||
|
||||
```
|
||||
┌ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┐
|
||||
Server
|
||||
[ ? Decision ] [ ⚡ Action ] [ ✓ Solution ]
|
||||
└ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ┘
|
||||
```
|
||||
|
||||
- Card: `min-w-[180px] max-w-[280px] rounded-xl border-2 border-dashed border-border bg-card/50`
|
||||
- Title: `text-sm font-heading font-medium text-foreground text-center py-2 px-3`
|
||||
- Type picker row (default state — not yet clicked): `text-xs text-muted-foreground text-center pb-2 cursor-pointer hover:text-foreground`
|
||||
- Clicking the card reveals three compact buttons for type selection
|
||||
- Type picker (expanded): three small buttons side-by-side in the card footer
|
||||
|
||||
---
|
||||
|
||||
## Files Changed Summary
|
||||
|
||||
| File | Change |
|
||||
|------|--------|
|
||||
| `frontend/src/components/tree-editor/TreeCanvasNode.tsx` | Fix 1: max-h + overflow-y + sticky header |
|
||||
| `frontend/src/components/tree-editor/NodeFormDecision.tsx` | Fix 2: ⓘ tooltip on help_text; Fix 3: replace NodePicker with answer label list |
|
||||
| `frontend/src/components/tree-editor/NodeFormAction.tsx` | Fix 2: ⓘ tooltips on description + commands fields |
|
||||
| `frontend/src/components/tree-editor/NodeFormResolution.tsx` | Fix 2: ⓘ tooltips on description + steps fields |
|
||||
| `frontend/src/components/tree-editor/TreeCanvas.tsx` | Fix 3: stub creation in handleNodeSave; AnswerStubCard rendering |
|
||||
| `frontend/src/components/tree-editor/AnswerStubCard.tsx` | Fix 3: NEW — dashed stub card with inline type picker |
|
||||
| `frontend/src/types/tree.ts` | Fix 3: add `'answer'` to NodeType union |
|
||||
| `backend/app/core/tree_validation.py` | Fix 3: allow 'answer' in draft; block on publish |
|
||||
| `frontend/src/pages/TreeEditorPage.tsx` | Fix 3: frontend publish guard |
|
||||
|
||||
---
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- No changes to session navigation, procedural flows, or maintenance flows
|
||||
- No changes to the Code mode editor
|
||||
- No changes to `treeEditorStore.ts` store actions (addNode, updateNode, deleteNode are used as-is)
|
||||
- No third-party tooltip library
|
||||
- No new backend endpoints
|
||||
|
||||
---
|
||||
|
||||
## Verification
|
||||
|
||||
1. Open a troubleshooting tree in the canvas editor
|
||||
2. Click a decision node → card expands, form is scrollable with sticky save/cancel header
|
||||
3. Field labels show `ⓘ` icons; hovering reveals the hint text
|
||||
4. Type answer labels in the Options section; click ✓ to save
|
||||
5. Answer stub cards appear as dashed cards below the decision node
|
||||
6. Click a stub card → type picker appears; select "Decision" → card converts and expands for editing
|
||||
7. Draft save works with answer nodes present (no backend error)
|
||||
8. Attempt to publish with unresolved answer nodes → blocked with a clear error message
|
||||
9. `npm run build` passes with no TypeScript errors
|
||||
1003
docs/plans/2026-02-18-canvas-ux-fixes-impl.md
Normal file
1003
docs/plans/2026-02-18-canvas-ux-fixes-impl.md
Normal file
File diff suppressed because it is too large
Load Diff
14
frontend/src/components/common/InfoTip.tsx
Normal file
14
frontend/src/components/common/InfoTip.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
interface InfoTipProps {
|
||||
text: string
|
||||
}
|
||||
|
||||
export function InfoTip({ text }: InfoTipProps) {
|
||||
return (
|
||||
<span
|
||||
className="inline-flex items-center justify-center h-3.5 w-3.5 rounded-full border border-muted-foreground/40 text-[9px] text-muted-foreground cursor-help shrink-0"
|
||||
title={text}
|
||||
>
|
||||
i
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useCallback, type ReactNode } from 'react'
|
||||
import { X } from 'lucide-react'
|
||||
import { useState, useEffect, useCallback, type ReactNode } from 'react'
|
||||
import { X, Maximize2, Minimize2 } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface ModalProps {
|
||||
@@ -10,9 +10,28 @@ interface ModalProps {
|
||||
/** Optional footer content that stays fixed at bottom (doesn't scroll) */
|
||||
footer?: ReactNode
|
||||
size?: 'sm' | 'md' | 'lg' | 'xl'
|
||||
/** If true, a fullscreen toggle button appears in the modal header */
|
||||
allowFullScreen?: boolean
|
||||
}
|
||||
|
||||
export function Modal({ isOpen, onClose, title, children, footer, size = 'md' }: ModalProps) {
|
||||
export function Modal({ isOpen, onClose, title, children, footer, size = 'md', allowFullScreen = false }: ModalProps) {
|
||||
const [isFullScreen, setIsFullScreen] = useState(() => {
|
||||
if (!allowFullScreen) return false
|
||||
try {
|
||||
return localStorage.getItem('rf-editor-fullscreen') === 'true'
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
})
|
||||
|
||||
const toggleFullScreen = () => {
|
||||
const next = !isFullScreen
|
||||
setIsFullScreen(next)
|
||||
try {
|
||||
localStorage.setItem('rf-editor-fullscreen', String(next))
|
||||
} catch {}
|
||||
}
|
||||
|
||||
// Close on Escape key
|
||||
const handleKeyDown = useCallback(
|
||||
(e: KeyboardEvent) => {
|
||||
@@ -61,9 +80,13 @@ export function Modal({ isOpen, onClose, title, children, footer, size = 'md' }:
|
||||
<div
|
||||
className={cn(
|
||||
'relative flex w-full flex-col border border-border bg-card shadow-lg',
|
||||
'max-h-[100vh] rounded-t-2xl sm:max-h-[85vh] sm:rounded-2xl',
|
||||
'animate-scale-in',
|
||||
sizeClasses[size]
|
||||
'animate-scale-in transition-all duration-200',
|
||||
isFullScreen
|
||||
? 'fixed inset-4 max-w-none w-auto h-auto rounded-2xl'
|
||||
: cn(
|
||||
'max-h-[100vh] rounded-t-2xl sm:max-h-[85vh] sm:rounded-2xl',
|
||||
sizeClasses[size]
|
||||
)
|
||||
)}
|
||||
>
|
||||
{/* Header - Fixed at top */}
|
||||
@@ -71,17 +94,32 @@ export function Modal({ isOpen, onClose, title, children, footer, size = 'md' }:
|
||||
<h2 id="modal-title" className="text-lg font-semibold text-foreground">
|
||||
{title}
|
||||
</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className={cn(
|
||||
'rounded-md p-1.5 text-muted-foreground transition-colors sm:p-1',
|
||||
'hover:bg-accent hover:text-foreground',
|
||||
'focus:outline-none focus:ring-2 focus:ring-primary/20'
|
||||
<div className="flex items-center gap-1">
|
||||
{allowFullScreen && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleFullScreen}
|
||||
className="rounded-md p-1 text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
title={isFullScreen ? 'Exit full screen' : 'Full screen'}
|
||||
>
|
||||
{isFullScreen
|
||||
? <Minimize2 className="h-4 w-4" />
|
||||
: <Maximize2 className="h-4 w-4" />
|
||||
}
|
||||
</button>
|
||||
)}
|
||||
aria-label="Close modal"
|
||||
>
|
||||
<X className="h-5 w-5" />
|
||||
</button>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className={cn(
|
||||
'rounded-md p-1.5 text-muted-foreground transition-colors sm:p-1',
|
||||
'hover:bg-accent hover:text-foreground',
|
||||
'focus:outline-none focus:ring-2 focus:ring-primary/20'
|
||||
)}
|
||||
aria-label="Close modal"
|
||||
>
|
||||
<X className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Body - Scrollable */}
|
||||
|
||||
@@ -21,13 +21,15 @@ interface ContinuationModalProps {
|
||||
const nodeTypeIcons: Record<NodeType, React.ReactNode> = {
|
||||
decision: <HelpCircle className="h-4 w-4 text-blue-500" />,
|
||||
action: <Zap className="h-4 w-4 text-amber-500" />,
|
||||
solution: <CheckCircle className="h-4 w-4 text-green-500" />
|
||||
solution: <CheckCircle className="h-4 w-4 text-green-500" />,
|
||||
answer: <HelpCircle className="h-4 w-4 opacity-40" />
|
||||
}
|
||||
|
||||
const nodeTypeLabels: Record<NodeType, string> = {
|
||||
decision: 'Decision',
|
||||
action: 'Action',
|
||||
solution: 'Solution'
|
||||
solution: 'Solution',
|
||||
answer: 'Answer'
|
||||
}
|
||||
|
||||
export function ContinuationModal({
|
||||
|
||||
121
frontend/src/components/tree-editor/AnswerStubCard.tsx
Normal file
121
frontend/src/components/tree-editor/AnswerStubCard.tsx
Normal file
@@ -0,0 +1,121 @@
|
||||
import { useState, useRef, useEffect } from 'react'
|
||||
import { HelpCircle, Zap, CheckCircle, Trash2 } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { TreeStructure } from '@/types'
|
||||
|
||||
interface AnswerStubCardProps {
|
||||
node: TreeStructure // type === 'answer'
|
||||
fromOption?: string
|
||||
onSelectType: (nodeId: string, type: 'decision' | 'action' | 'solution') => void
|
||||
onDelete: (nodeId: string) => void
|
||||
}
|
||||
|
||||
export function AnswerStubCard({ node, fromOption, onSelectType, onDelete }: AnswerStubCardProps) {
|
||||
const [picking, setPicking] = useState(false)
|
||||
const [confirming, setConfirming] = useState(false)
|
||||
const cardRef = useRef<HTMLDivElement>(null)
|
||||
const label = fromOption || node.title || 'Answer'
|
||||
|
||||
// Collapse picker when clicking outside the card
|
||||
useEffect(() => {
|
||||
if (!picking) return
|
||||
const handleOutsideClick = (e: MouseEvent) => {
|
||||
if (cardRef.current && !cardRef.current.contains(e.target as Node)) {
|
||||
setPicking(false)
|
||||
}
|
||||
}
|
||||
document.addEventListener('mousedown', handleOutsideClick)
|
||||
return () => document.removeEventListener('mousedown', handleOutsideClick)
|
||||
}, [picking])
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={cardRef}
|
||||
className={cn(
|
||||
'relative min-w-[180px] max-w-[280px] rounded-xl border-2 border-dashed border-border bg-card/50',
|
||||
'transition-all duration-150',
|
||||
!picking && !confirming && 'cursor-pointer hover:border-primary/40 hover:bg-accent/30'
|
||||
)}
|
||||
onClick={() => !picking && !confirming && setPicking(true)}
|
||||
>
|
||||
{/* Delete button — top-right corner */}
|
||||
{!picking && !confirming && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => { e.stopPropagation(); setConfirming(true) }}
|
||||
className="absolute top-1.5 right-1.5 rounded p-0.5 text-muted-foreground/40 hover:bg-red-500/10 hover:text-red-400 transition-colors"
|
||||
title="Delete stub"
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Label */}
|
||||
<div className="px-3 pt-2.5 pb-1 text-sm font-heading font-medium text-foreground text-center">
|
||||
{label}
|
||||
</div>
|
||||
|
||||
{/* Confirm delete */}
|
||||
{confirming ? (
|
||||
<div className="px-2 pb-2.5 text-center space-y-1.5">
|
||||
<p className="text-[10px] text-muted-foreground">Delete this stub?</p>
|
||||
<div className="flex items-center justify-center gap-1.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => { e.stopPropagation(); onDelete(node.id) }}
|
||||
className="rounded-md px-2 py-1 text-[10px] font-label border border-red-500/30 bg-red-500/10 text-red-400 hover:bg-red-500/20"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => { e.stopPropagation(); setConfirming(false) }}
|
||||
className="rounded-md px-2 py-1 text-[10px] font-label border border-border text-muted-foreground hover:bg-accent"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</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={cn(
|
||||
'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={cn(
|
||||
'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={cn(
|
||||
'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>
|
||||
)
|
||||
}
|
||||
|
||||
export default AnswerStubCard
|
||||
66
frontend/src/components/tree-editor/MetadataSidePanel.tsx
Normal file
66
frontend/src/components/tree-editor/MetadataSidePanel.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
import { useEffect } from 'react'
|
||||
import { X } from 'lucide-react'
|
||||
import { TreeMetadataForm } from './TreeMetadataForm'
|
||||
|
||||
interface MetadataSidePanelProps {
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export function MetadataSidePanel({ isOpen, onClose }: MetadataSidePanelProps) {
|
||||
// Close on Escape key
|
||||
useEffect(() => {
|
||||
if (!isOpen) return
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
onClose()
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('keydown', handleKeyDown)
|
||||
return () => document.removeEventListener('keydown', handleKeyDown)
|
||||
}, [isOpen, onClose])
|
||||
|
||||
if (!isOpen) return null
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Backdrop — click to close */}
|
||||
<div
|
||||
className="fixed inset-0 z-40 bg-background/40 backdrop-blur-sm"
|
||||
onClick={onClose}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
|
||||
{/* Side panel — slides in from right */}
|
||||
<div
|
||||
className="fixed right-0 top-0 z-50 flex h-full w-80 flex-col border-l border-border bg-card shadow-xl"
|
||||
role="dialog"
|
||||
aria-label="Flow metadata"
|
||||
>
|
||||
{/* Panel header */}
|
||||
<div className="flex items-center justify-between border-b border-border px-4 py-3">
|
||||
<h2 className="text-sm font-semibold text-foreground font-heading">
|
||||
Flow Details
|
||||
</h2>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="rounded p-1.5 text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
aria-label="Close metadata panel"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Scrollable metadata form */}
|
||||
<div className="flex-1 overflow-y-auto px-4 py-4">
|
||||
<TreeMetadataForm />
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default MetadataSidePanel
|
||||
@@ -83,7 +83,7 @@ export function NodeEditorModal({ node, onClose, isNewNode = false }: NodeEditor
|
||||
)
|
||||
|
||||
return (
|
||||
<Modal isOpen={true} onClose={onClose} title={getTitle()} size="lg" footer={footerContent}>
|
||||
<Modal isOpen={true} onClose={onClose} title={getTitle()} size="lg" footer={footerContent} allowFullScreen={true}>
|
||||
{/* Node ID display */}
|
||||
<div className="mb-4 text-xs text-muted-foreground">
|
||||
Node ID: <code className="rounded bg-accent px-1 py-0.5">{node.id}</code>
|
||||
|
||||
@@ -3,6 +3,7 @@ import { DynamicArrayField } from './DynamicArrayField'
|
||||
import { NodePicker } from './NodePicker'
|
||||
import { useTreeEditorStore } from '@/store/treeEditorStore'
|
||||
import { MarkdownContent } from '@/components/ui/MarkdownContent'
|
||||
import { InfoTip } from '@/components/common/InfoTip'
|
||||
import type { TreeStructure } from '@/types'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
@@ -75,8 +76,9 @@ export function NodeFormAction({ node, onUpdate }: NodeFormActionProps) {
|
||||
{/* Description */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="block text-sm font-medium text-foreground">
|
||||
<label className="flex items-center gap-1.5 text-sm font-medium text-foreground">
|
||||
Description
|
||||
<InfoTip text="Supports markdown: **bold**, *italic*, - lists, 1. numbered lists, `code`" />
|
||||
</label>
|
||||
{node.description && (
|
||||
<button
|
||||
@@ -88,9 +90,6 @@ export function NodeFormAction({ node, onUpdate }: NodeFormActionProps) {
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<p className="mb-1 text-xs text-muted-foreground">
|
||||
Supports markdown: **bold**, *italic*, - lists, 1. numbered lists, `code`
|
||||
</p>
|
||||
{showPreview && node.description ? (
|
||||
<div className="mt-1 rounded-md border border-border bg-accent/50 p-3 text-sm">
|
||||
<MarkdownContent content={node.description} />
|
||||
@@ -118,12 +117,10 @@ export function NodeFormAction({ node, onUpdate }: NodeFormActionProps) {
|
||||
|
||||
{/* Commands */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-foreground">
|
||||
<label className="flex items-center gap-1.5 text-sm font-medium text-foreground">
|
||||
Commands
|
||||
<InfoTip text="PowerShell or CLI commands to execute" />
|
||||
</label>
|
||||
<p className="mb-2 text-xs text-muted-foreground">
|
||||
PowerShell or CLI commands to execute
|
||||
</p>
|
||||
<DynamicArrayField
|
||||
items={node.commands || []}
|
||||
onAdd={handleAddCommand}
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { useRef, useEffect } from 'react'
|
||||
import { Play } from 'lucide-react'
|
||||
import { DynamicArrayField } from './DynamicArrayField'
|
||||
import { NodePicker } from './NodePicker'
|
||||
import { useTreeEditorStore } from '@/store/treeEditorStore'
|
||||
import type { TreeStructure, TreeOption } from '@/types'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { InfoTip } from '@/components/common/InfoTip'
|
||||
|
||||
interface NodeFormDecisionProps {
|
||||
node: TreeStructure
|
||||
@@ -16,8 +17,11 @@ const indexToLetter = (index: number): string => {
|
||||
}
|
||||
|
||||
export function NodeFormDecision({ node, onUpdate }: NodeFormDecisionProps) {
|
||||
const { reorderOptions, validationErrors } = useTreeEditorStore()
|
||||
const { validationErrors } = useTreeEditorStore()
|
||||
const isRootNode = node.id === 'root'
|
||||
// Track input elements by index so we can focus the newly added one
|
||||
const inputRefs = useRef<Map<number, HTMLInputElement>>(new Map())
|
||||
const shouldFocusLast = useRef(false)
|
||||
|
||||
const questionError = validationErrors.find(
|
||||
e => e.nodeId === node.id && e.field === 'question'
|
||||
@@ -27,6 +31,15 @@ export function NodeFormDecision({ node, onUpdate }: NodeFormDecisionProps) {
|
||||
e => e.nodeId === node.id && e.field === 'options'
|
||||
)
|
||||
|
||||
// After options array grows (due to keyboard-triggered add), focus the last input
|
||||
useEffect(() => {
|
||||
if (shouldFocusLast.current) {
|
||||
shouldFocusLast.current = false
|
||||
const lastIndex = (node.options?.length ?? 1) - 1
|
||||
inputRefs.current.get(lastIndex)?.focus()
|
||||
}
|
||||
}, [node.options?.length])
|
||||
|
||||
const handleAddOption = () => {
|
||||
const newOption: TreeOption = {
|
||||
id: crypto.randomUUID(),
|
||||
@@ -38,6 +51,12 @@ export function NodeFormDecision({ node, onUpdate }: NodeFormDecisionProps) {
|
||||
})
|
||||
}
|
||||
|
||||
// Add a new option and focus it (used by keyboard shortcut)
|
||||
const handleAddOptionAndFocus = () => {
|
||||
shouldFocusLast.current = true
|
||||
handleAddOption()
|
||||
}
|
||||
|
||||
const handleRemoveOption = (index: number) => {
|
||||
const newOptions = [...(node.options || [])]
|
||||
newOptions.splice(index, 1)
|
||||
@@ -51,7 +70,12 @@ export function NodeFormDecision({ node, onUpdate }: NodeFormDecisionProps) {
|
||||
}
|
||||
|
||||
const handleReorderOptions = (fromIndex: number, toIndex: number) => {
|
||||
reorderOptions(node.id, fromIndex, toIndex)
|
||||
// Mutate local draft via onUpdate (backward-compatible: modal path relays to store,
|
||||
// canvas path updates local draft without writing to store early)
|
||||
const newOptions = [...(node.options || [])]
|
||||
const [moved] = newOptions.splice(fromIndex, 1)
|
||||
newOptions.splice(toIndex, 0, moved)
|
||||
onUpdate({ options: newOptions })
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -81,11 +105,6 @@ export function NodeFormDecision({ node, onUpdate }: NodeFormDecisionProps) {
|
||||
<label className="block text-sm font-medium text-foreground">
|
||||
{isRootNode ? 'Starting Question' : 'Question'} <span className="text-red-400">*</span>
|
||||
</label>
|
||||
{isRootNode && (
|
||||
<p className="mt-0.5 text-xs text-muted-foreground">
|
||||
What's the main question to diagnose the issue?
|
||||
</p>
|
||||
)}
|
||||
<input
|
||||
type="text"
|
||||
value={node.question || ''}
|
||||
@@ -125,18 +144,12 @@ export function NodeFormDecision({ node, onUpdate }: NodeFormDecisionProps) {
|
||||
|
||||
{/* Options */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-foreground">
|
||||
<label className="flex items-center gap-1.5 text-sm font-medium text-foreground">
|
||||
{isRootNode ? 'Answer Options (Branches)' : 'Options'} <span className="text-red-400">*</span>
|
||||
<InfoTip text={isRootNode
|
||||
? "Add as many options as needed (A, B, C, D...). Each option leads to a different troubleshooting path."
|
||||
: "Each option can branch to a different next step."} />
|
||||
</label>
|
||||
{isRootNode ? (
|
||||
<p className="mt-0.5 text-xs text-muted-foreground">
|
||||
Add as many options as needed (A, B, C, D...). Each option leads to a completely different troubleshooting path.
|
||||
</p>
|
||||
) : (
|
||||
<p className="mt-0.5 text-xs text-muted-foreground">
|
||||
Each option can branch to a different next step.
|
||||
</p>
|
||||
)}
|
||||
{optionsError && (
|
||||
<p className="mt-1 text-xs text-red-400">{optionsError.message}</p>
|
||||
)}
|
||||
@@ -152,52 +165,45 @@ export function NodeFormDecision({ node, onUpdate }: NodeFormDecisionProps) {
|
||||
const optionLabelError = validationErrors.find(
|
||||
e => e.nodeId === node.id && e.field === `options[${index}].label`
|
||||
)
|
||||
const optionNextError = validationErrors.find(
|
||||
e => e.nodeId === node.id && e.field === `options[${index}].next_node_id`
|
||||
)
|
||||
const letter = indexToLetter(index)
|
||||
const isLastOption = index === (node.options?.length ?? 1) - 1
|
||||
|
||||
return (
|
||||
<div className="rounded-md border border-border bg-accent/50 p-3">
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
{/* Letter badge */}
|
||||
<span className={cn(
|
||||
'flex h-6 w-6 items-center justify-center rounded-full text-xs font-bold',
|
||||
isRootNode
|
||||
? 'bg-blue-500/20 text-blue-400'
|
||||
: 'bg-accent text-muted-foreground'
|
||||
)}>
|
||||
{letter}
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={cn(
|
||||
'flex h-6 w-6 shrink-0 items-center justify-center rounded-full text-xs font-bold',
|
||||
isRootNode ? 'bg-blue-500/20 text-blue-400' : 'bg-accent text-muted-foreground'
|
||||
)}>
|
||||
{letter}
|
||||
</span>
|
||||
<div className="flex-1">
|
||||
<input
|
||||
ref={(el) => {
|
||||
if (el) inputRefs.current.set(index, el)
|
||||
else inputRefs.current.delete(index)
|
||||
}}
|
||||
type="text"
|
||||
value={option.label}
|
||||
onChange={(e) => handleUpdateOption(index, { label: e.target.value })}
|
||||
placeholder={isRootNode
|
||||
? `Branch ${letter}: e.g., "Network Issues", "Application Errors"...`
|
||||
? `Branch ${letter}: e.g., "Network Issues"...`
|
||||
: `Option ${letter} label`}
|
||||
onKeyDown={(e) => {
|
||||
if ((e.key === 'Tab' || e.key === 'Enter') && isLastOption && option.label.trim()) {
|
||||
e.preventDefault()
|
||||
handleAddOptionAndFocus()
|
||||
}
|
||||
}}
|
||||
className={cn(
|
||||
'block flex-1 rounded-md border px-3 py-2 text-sm',
|
||||
'block w-full rounded-md border px-3 py-2 text-sm',
|
||||
'bg-background text-foreground placeholder:text-muted-foreground',
|
||||
'focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary',
|
||||
optionLabelError ? 'border-red-400' : 'border-border'
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
{optionLabelError && (
|
||||
<p className="mb-2 text-xs text-red-400">{optionLabelError.message}</p>
|
||||
)}
|
||||
<div className="pl-8">
|
||||
<NodePicker
|
||||
value={option.next_node_id}
|
||||
onChange={(nodeId) => handleUpdateOption(index, { next_node_id: nodeId })}
|
||||
parentNodeId={node.id}
|
||||
excludeNodeId={node.id}
|
||||
placeholder={isRootNode
|
||||
? `What happens when user selects "${option.label || `Branch ${letter}`}"?`
|
||||
: "Select or create next node..."}
|
||||
error={optionNextError?.message}
|
||||
/>
|
||||
{optionLabelError && (
|
||||
<p className="mt-1 text-xs text-red-400">{optionLabelError.message}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useState } from 'react'
|
||||
import { DynamicArrayField } from './DynamicArrayField'
|
||||
import { useTreeEditorStore } from '@/store/treeEditorStore'
|
||||
import { MarkdownContent } from '@/components/ui/MarkdownContent'
|
||||
import { InfoTip } from '@/components/common/InfoTip'
|
||||
import type { TreeStructure } from '@/types'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
@@ -70,8 +71,9 @@ export function NodeFormResolution({ node, onUpdate }: NodeFormResolutionProps)
|
||||
{/* Description */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="block text-sm font-medium text-foreground">
|
||||
<label className="flex items-center gap-1.5 text-sm font-medium text-foreground">
|
||||
Description
|
||||
<InfoTip text="Supports markdown: **bold**, *italic*, - lists, 1. numbered lists, `code`" />
|
||||
</label>
|
||||
{node.description && (
|
||||
<button
|
||||
@@ -83,9 +85,6 @@ export function NodeFormResolution({ node, onUpdate }: NodeFormResolutionProps)
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<p className="mb-1 text-xs text-muted-foreground">
|
||||
Supports markdown: **bold**, *italic*, - lists, 1. numbered lists, `code`
|
||||
</p>
|
||||
{showPreview && node.description ? (
|
||||
<div className="mt-1 rounded-md border border-border bg-accent/50 p-3 text-sm">
|
||||
<MarkdownContent content={node.description} />
|
||||
@@ -112,12 +111,10 @@ Document what was done and the outcome.
|
||||
|
||||
{/* Resolution Steps */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-foreground">
|
||||
<label className="flex items-center gap-1.5 text-sm font-medium text-foreground">
|
||||
Resolution Steps
|
||||
<InfoTip text="Step-by-step instructions for resolving the issue" />
|
||||
</label>
|
||||
<p className="mb-2 text-xs text-muted-foreground">
|
||||
Step-by-step instructions for resolving the issue
|
||||
</p>
|
||||
<DynamicArrayField
|
||||
items={node.resolution_steps || []}
|
||||
onAdd={handleAddStep}
|
||||
|
||||
@@ -91,13 +91,15 @@ function NodeListItem({
|
||||
const nodeTypeIcons: Record<NodeType, React.ReactNode> = {
|
||||
decision: <HelpCircle className="h-4 w-4" />,
|
||||
action: <Zap className="h-4 w-4" />,
|
||||
solution: <CheckCircle className="h-4 w-4" />
|
||||
solution: <CheckCircle className="h-4 w-4" />,
|
||||
answer: <HelpCircle className="h-4 w-4 opacity-50" />
|
||||
}
|
||||
|
||||
const nodeTypeColors: Record<NodeType, string> = {
|
||||
decision: 'bg-blue-500/20 text-blue-400',
|
||||
action: 'bg-yellow-500/20 text-yellow-400',
|
||||
solution: 'bg-green-500/20 text-green-400'
|
||||
solution: 'bg-green-500/20 text-green-400',
|
||||
answer: 'bg-muted text-muted-foreground border border-dashed border-border'
|
||||
}
|
||||
|
||||
const getNodeLabel = () => {
|
||||
|
||||
@@ -13,14 +13,16 @@ const CREATE_SOLUTION = `${CREATE_PREFIX}solution__`
|
||||
const NODE_TYPE_SYMBOLS: Record<NodeType, string> = {
|
||||
decision: '\u24D8', // Information/question symbol
|
||||
action: '\u26A1', // Lightning bolt for action
|
||||
solution: '\u2713' // Checkmark for solution
|
||||
solution: '\u2713', // Checkmark for solution
|
||||
answer: '\u25CC' // Dashed circle for placeholder
|
||||
}
|
||||
|
||||
// Node type labels for UI
|
||||
const NODE_TYPE_LABELS: Record<NodeType, string> = {
|
||||
decision: 'Decision',
|
||||
action: 'Action',
|
||||
solution: 'Solution'
|
||||
solution: 'Solution',
|
||||
answer: 'Answer'
|
||||
}
|
||||
|
||||
interface NodePickerProps {
|
||||
@@ -35,6 +37,9 @@ interface NodePickerProps {
|
||||
error?: string
|
||||
/** Callback when a new node is created (receives the new node ID) */
|
||||
onNodeCreated?: (nodeId: string) => void
|
||||
/** Whether to show the "Create New Node" options. Default: true.
|
||||
* Set to false in inline canvas editing to prevent premature store writes. */
|
||||
allowCreate?: boolean
|
||||
}
|
||||
|
||||
export function NodePicker({
|
||||
@@ -46,7 +51,8 @@ export function NodePicker({
|
||||
className,
|
||||
label,
|
||||
error,
|
||||
onNodeCreated
|
||||
onNodeCreated,
|
||||
allowCreate = true
|
||||
}: NodePickerProps) {
|
||||
const { getAvailableTargetNodes, addNode, updateNode } = useTreeEditorStore()
|
||||
const availableNodes = getAvailableTargetNodes(excludeNodeId)
|
||||
@@ -201,12 +207,14 @@ export function NodePicker({
|
||||
>
|
||||
<option value="">{placeholder}</option>
|
||||
|
||||
{/* Create new options */}
|
||||
<optgroup label="Create New Node">
|
||||
<option value={CREATE_DECISION}>+ New Decision (question)</option>
|
||||
<option value={CREATE_ACTION}>+ New Action (task)</option>
|
||||
<option value={CREATE_SOLUTION}>+ New Solution (endpoint)</option>
|
||||
</optgroup>
|
||||
{/* Create new options — hidden when allowCreate=false (e.g. canvas inline editing) */}
|
||||
{allowCreate && (
|
||||
<optgroup label="Create New Node">
|
||||
<option value={CREATE_DECISION}>+ New Decision (question)</option>
|
||||
<option value={CREATE_ACTION}>+ New Action (task)</option>
|
||||
<option value={CREATE_SOLUTION}>+ New Solution (endpoint)</option>
|
||||
</optgroup>
|
||||
)}
|
||||
|
||||
{/* Existing nodes grouped by type */}
|
||||
{groupedNodes.decisions.length > 0 && (
|
||||
|
||||
713
frontend/src/components/tree-editor/TreeCanvas.tsx
Normal file
713
frontend/src/components/tree-editor/TreeCanvas.tsx
Normal file
@@ -0,0 +1,713 @@
|
||||
import { useState, useCallback, useRef, useEffect } from 'react'
|
||||
import { HelpCircle, Zap, CheckCircle, Plus, X } from 'lucide-react'
|
||||
import { useTreeEditorStore, findNodeInTree } from '@/store/treeEditorStore'
|
||||
import { TreeCanvasNode } from './TreeCanvasNode'
|
||||
import { AnswerStubCard } from './AnswerStubCard'
|
||||
import type { TreeStructure, NodeType } from '@/types'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
// ─── Types ───────────────────────────────────────────────────────────────────
|
||||
|
||||
interface PendingLink {
|
||||
parentId: string
|
||||
optionId?: string // For decision option linking
|
||||
}
|
||||
|
||||
interface DragState {
|
||||
nodeId: string
|
||||
parentId: string | null
|
||||
index: number
|
||||
}
|
||||
|
||||
// ─── Reference cleanup helper ─────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Before deleting a node, clear all inbound references to it across the tree.
|
||||
* This prevents stale next_node_id / option.next_node_id references.
|
||||
*/
|
||||
function clearInboundReferences(
|
||||
nodeId: string,
|
||||
treeStructure: TreeStructure,
|
||||
updateNode: (id: string, updates: Partial<TreeStructure>) => void
|
||||
) {
|
||||
function walk(node: TreeStructure) {
|
||||
// Clear decision option references
|
||||
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
|
||||
),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Clear action next_node_id references
|
||||
if (node.type === 'action' && node.next_node_id === nodeId) {
|
||||
updateNode(node.id, { next_node_id: '' })
|
||||
}
|
||||
|
||||
// Recurse
|
||||
node.children?.forEach(walk)
|
||||
}
|
||||
|
||||
walk(treeStructure)
|
||||
}
|
||||
|
||||
// ─── Add-node type picker ─────────────────────────────────────────────────────
|
||||
|
||||
interface AddNodePickerProps {
|
||||
onSelect: (type: NodeType) => void
|
||||
onCancel: () => void
|
||||
}
|
||||
|
||||
function AddNodePicker({ onSelect, onCancel }: AddNodePickerProps) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 rounded-xl border border-dashed border-primary/40 bg-card px-3 py-2 shadow-sm">
|
||||
<span className="text-xs text-muted-foreground shrink-0">Add:</span>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSelect('decision')}
|
||||
className={cn(
|
||||
'flex items-center gap-1 rounded-md px-2 py-1 text-xs font-label',
|
||||
'border border-blue-500/30 bg-blue-500/10 text-blue-400 hover:bg-blue-500/20'
|
||||
)}
|
||||
>
|
||||
<HelpCircle className="h-3 w-3" />
|
||||
Decision
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSelect('action')}
|
||||
className={cn(
|
||||
'flex items-center gap-1 rounded-md px-2 py-1 text-xs font-label',
|
||||
'border border-yellow-500/30 bg-yellow-500/10 text-yellow-400 hover:bg-yellow-500/20'
|
||||
)}
|
||||
>
|
||||
<Zap className="h-3 w-3" />
|
||||
Action
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSelect('solution')}
|
||||
className={cn(
|
||||
'flex items-center gap-1 rounded-md px-2 py-1 text-xs font-label',
|
||||
'border border-green-500/30 bg-green-500/10 text-green-400 hover:bg-green-500/20'
|
||||
)}
|
||||
>
|
||||
<CheckCircle className="h-3 w-3" />
|
||||
Solution
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
className="ml-1 rounded p-0.5 text-muted-foreground hover:bg-accent"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Add-node trigger button ──────────────────────────────────────────────────
|
||||
|
||||
interface AddNodeButtonProps {
|
||||
label?: string
|
||||
onClick: () => void
|
||||
}
|
||||
|
||||
function AddNodeButton({ label = 'Add node', onClick }: AddNodeButtonProps) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
'flex items-center gap-1 rounded-lg px-3 py-1.5 text-xs font-label',
|
||||
'border border-dashed border-border text-muted-foreground',
|
||||
'hover:border-primary/40 hover:text-foreground hover:bg-accent/50',
|
||||
'transition-all duration-150'
|
||||
)}
|
||||
>
|
||||
<Plus className="h-3 w-3" />
|
||||
{label}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Add-key builder ──────────────────────────────────────────────────────────
|
||||
|
||||
/** Unique key for an add-target: "parentId" or "parentId:optionId" */
|
||||
function addKey(parentId: string, optionId?: string) {
|
||||
return optionId ? `${parentId}:${optionId}` : parentId
|
||||
}
|
||||
|
||||
// ─── TreeCanvas ───────────────────────────────────────────────────────────────
|
||||
|
||||
export function TreeCanvas() {
|
||||
const {
|
||||
treeStructure,
|
||||
addNode,
|
||||
updateNode,
|
||||
deleteNode,
|
||||
duplicateNode,
|
||||
reorderNodes,
|
||||
selectNode,
|
||||
selectedNodeId,
|
||||
} = useTreeEditorStore()
|
||||
|
||||
// ── Local canvas state ──
|
||||
const [expandedNodeId, setExpandedNodeId] = useState<string | null>(null)
|
||||
const [newNodeIds, setNewNodeIds] = useState<Set<string>>(new Set())
|
||||
const [collapsedNodeIds, setCollapsedNodeIds] = useState<Set<string>>(new Set())
|
||||
const [pendingAddKey, setPendingAddKey] = useState<string | null>(null)
|
||||
const [pendingLinks, setPendingLinks] = useState<Map<string, PendingLink>>(
|
||||
new Map()
|
||||
)
|
||||
const [dragState, setDragState] = useState<DragState | null>(null)
|
||||
const [dragOverTarget, setDragOverTarget] = useState<{
|
||||
parentId: string | null
|
||||
index: number
|
||||
} | null>(null)
|
||||
|
||||
// Node ref map for scroll-into-view
|
||||
const nodeRefs = useRef<Map<string, HTMLDivElement>>(new Map())
|
||||
|
||||
// ── Selection sync ──
|
||||
// When selectedNodeId changes externally (e.g. ValidationSummary click),
|
||||
// auto-expand that card and scroll it into view.
|
||||
useEffect(() => {
|
||||
if (selectedNodeId && selectedNodeId !== expandedNodeId) {
|
||||
setExpandedNodeId(selectedNodeId)
|
||||
const el = nodeRefs.current.get(selectedNodeId)
|
||||
if (el) {
|
||||
el.scrollIntoView({ behavior: 'smooth', block: 'nearest' })
|
||||
}
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [selectedNodeId])
|
||||
|
||||
// ── Card expand/collapse ──
|
||||
const handleToggleExpand = useCallback(
|
||||
(nodeId: string) => {
|
||||
setExpandedNodeId((prev) => (prev === nodeId ? null : nodeId))
|
||||
selectNode(nodeId)
|
||||
},
|
||||
[selectNode]
|
||||
)
|
||||
|
||||
// ── Save inline edits ──
|
||||
const handleSave = useCallback(
|
||||
(nodeId: string, updates: Partial<TreeStructure>) => {
|
||||
updateNode(nodeId, updates)
|
||||
|
||||
// For decision nodes: strip blank options, then create answer stubs for any
|
||||
// labelled option that doesn't yet have a linked child
|
||||
if (updates.options) {
|
||||
const options = updates.options.filter((o) => o.label.trim())
|
||||
const stubsToCreate: Array<{ opt: typeof options[number]; stubId: string }> = []
|
||||
|
||||
options.forEach((opt) => {
|
||||
if (!opt.next_node_id) {
|
||||
const stubId = addNode(nodeId, 'answer')
|
||||
updateNode(stubId, { title: opt.label })
|
||||
stubsToCreate.push({ opt, stubId })
|
||||
}
|
||||
})
|
||||
|
||||
// Write back: filtered options + any newly assigned next_node_ids
|
||||
const updatedOptions = options.map((o) => {
|
||||
const stub = stubsToCreate.find((s) => s.opt.id === o.id)
|
||||
return stub ? { ...o, next_node_id: stub.stubId } : o
|
||||
})
|
||||
updateNode(nodeId, { options: updatedOptions })
|
||||
}
|
||||
|
||||
// Resolve pending link for new nodes
|
||||
const link = pendingLinks.get(nodeId)
|
||||
if (link) {
|
||||
const parentNode = treeStructure
|
||||
? findNodeInTree(link.parentId, treeStructure)
|
||||
: null
|
||||
|
||||
if (parentNode) {
|
||||
if (link.optionId && parentNode.type === 'decision' && parentNode.options) {
|
||||
// Link the decision option to this new child node
|
||||
const updatedOptions = parentNode.options.map((o) =>
|
||||
o.id === link.optionId ? { ...o, next_node_id: nodeId } : o
|
||||
)
|
||||
updateNode(link.parentId, { options: updatedOptions })
|
||||
} else if (parentNode.type === 'action') {
|
||||
// Link the action's next node
|
||||
updateNode(link.parentId, { next_node_id: nodeId })
|
||||
}
|
||||
}
|
||||
|
||||
setPendingLinks((prev) => {
|
||||
const next = new Map(prev)
|
||||
next.delete(nodeId)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
setNewNodeIds((prev) => {
|
||||
const next = new Set(prev)
|
||||
next.delete(nodeId)
|
||||
return next
|
||||
})
|
||||
setExpandedNodeId(null)
|
||||
},
|
||||
[pendingLinks, treeStructure, updateNode]
|
||||
)
|
||||
|
||||
// ── Cancel new node ──
|
||||
const handleCancelNew = useCallback(
|
||||
(nodeId: string) => {
|
||||
deleteNode(nodeId)
|
||||
setNewNodeIds((prev) => {
|
||||
const next = new Set(prev)
|
||||
next.delete(nodeId)
|
||||
return next
|
||||
})
|
||||
setPendingLinks((prev) => {
|
||||
const next = new Map(prev)
|
||||
next.delete(nodeId)
|
||||
return next
|
||||
})
|
||||
if (expandedNodeId === nodeId) setExpandedNodeId(null)
|
||||
},
|
||||
[deleteNode, expandedNodeId]
|
||||
)
|
||||
|
||||
// ── Delete node (with inbound reference cleanup) ──
|
||||
const handleDelete = useCallback(
|
||||
(nodeId: string) => {
|
||||
if (!treeStructure) return
|
||||
clearInboundReferences(nodeId, treeStructure, updateNode)
|
||||
deleteNode(nodeId)
|
||||
if (expandedNodeId === nodeId) setExpandedNodeId(null)
|
||||
},
|
||||
[treeStructure, updateNode, deleteNode, expandedNodeId]
|
||||
)
|
||||
|
||||
// ── Duplicate node ──
|
||||
const handleDuplicate = useCallback(
|
||||
(nodeId: string) => {
|
||||
duplicateNode(nodeId)
|
||||
},
|
||||
[duplicateNode]
|
||||
)
|
||||
|
||||
// ── Subtree collapse toggle ──
|
||||
const handleToggleSubtreeCollapse = useCallback((nodeId: string) => {
|
||||
setCollapsedNodeIds((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(nodeId)) next.delete(nodeId)
|
||||
else next.add(nodeId)
|
||||
return next
|
||||
})
|
||||
}, [])
|
||||
|
||||
// ── Convert answer stub to a real node type ──
|
||||
const handleSelectAnswerType = useCallback(
|
||||
(nodeId: string, type: 'decision' | 'action' | 'solution') => {
|
||||
updateNode(nodeId, { type })
|
||||
setExpandedNodeId(nodeId)
|
||||
selectNode(nodeId)
|
||||
},
|
||||
[updateNode, selectNode]
|
||||
)
|
||||
|
||||
// ── Add node flow ──
|
||||
const handleAddNodeSelect = useCallback(
|
||||
(type: NodeType, parentId: string, optionId?: string) => {
|
||||
const newId = addNode(parentId, type)
|
||||
setNewNodeIds((prev) => new Set([...prev, newId]))
|
||||
setPendingLinks((prev) => {
|
||||
const next = new Map(prev)
|
||||
next.set(newId, { parentId, optionId })
|
||||
return next
|
||||
})
|
||||
setExpandedNodeId(newId)
|
||||
setPendingAddKey(null)
|
||||
},
|
||||
[addNode]
|
||||
)
|
||||
|
||||
// ── Drag & drop ──
|
||||
const handleDragStart = useCallback(
|
||||
(e: React.DragEvent, nodeId: string) => {
|
||||
e.dataTransfer.effectAllowed = 'move'
|
||||
// Find parent and index for this node
|
||||
const findParentAndIndex = (
|
||||
searchNode: TreeStructure,
|
||||
targetId: string,
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
_parentId: string | null
|
||||
): { parentId: string | null; index: number } | null => {
|
||||
if (searchNode.children) {
|
||||
for (let i = 0; i < searchNode.children.length; i++) {
|
||||
if (searchNode.children[i].id === targetId) {
|
||||
return { parentId: searchNode.id, index: i }
|
||||
}
|
||||
const found = findParentAndIndex(
|
||||
searchNode.children[i],
|
||||
targetId,
|
||||
searchNode.id
|
||||
)
|
||||
if (found) return found
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
if (!treeStructure) return
|
||||
const location = findParentAndIndex(treeStructure, nodeId, null)
|
||||
if (location) {
|
||||
setDragState({
|
||||
nodeId,
|
||||
parentId: location.parentId,
|
||||
index: location.index,
|
||||
})
|
||||
}
|
||||
},
|
||||
[treeStructure]
|
||||
)
|
||||
|
||||
const handleDragOver = useCallback(
|
||||
(e: React.DragEvent, parentId: string | null, index: number) => {
|
||||
e.preventDefault()
|
||||
setDragOverTarget({ parentId, index })
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
const handleDrop = useCallback(
|
||||
(e: React.DragEvent, targetParentId: string | null, targetIndex: number) => {
|
||||
e.preventDefault()
|
||||
if (!dragState || !targetParentId) {
|
||||
setDragState(null)
|
||||
setDragOverTarget(null)
|
||||
return
|
||||
}
|
||||
|
||||
const { parentId: sourceParentId, index: sourceIndex } = dragState
|
||||
|
||||
if (sourceParentId === targetParentId) {
|
||||
const adjustedIndex =
|
||||
sourceIndex < targetIndex ? targetIndex - 1 : targetIndex
|
||||
if (sourceIndex !== adjustedIndex) {
|
||||
reorderNodes(sourceParentId!, sourceIndex, adjustedIndex)
|
||||
}
|
||||
}
|
||||
// Cross-parent move intentionally not supported in canvas (complex to handle safely)
|
||||
|
||||
setDragState(null)
|
||||
setDragOverTarget(null)
|
||||
},
|
||||
[dragState, reorderNodes]
|
||||
)
|
||||
|
||||
const handleDragEnd = useCallback(() => {
|
||||
setDragState(null)
|
||||
setDragOverTarget(null)
|
||||
}, [])
|
||||
|
||||
// ── Recursive node renderer ──
|
||||
const renderNode = useCallback(
|
||||
(
|
||||
node: TreeStructure,
|
||||
parentId: string | null,
|
||||
index: number,
|
||||
optionLabel?: string
|
||||
): React.ReactNode => {
|
||||
const isExpanded = expandedNodeId === node.id
|
||||
const isNew = newNodeIds.has(node.id)
|
||||
const isSubtreeCollapsed = collapsedNodeIds.has(node.id)
|
||||
const nodeChildren = node.children || []
|
||||
|
||||
// For decision nodes, order children by option link order
|
||||
const orderedChildren: Array<{
|
||||
child: TreeStructure
|
||||
optionLabel?: string
|
||||
optionId?: string
|
||||
childIndex: number
|
||||
}> = []
|
||||
|
||||
if (node.type === 'decision' && node.options && nodeChildren.length > 0) {
|
||||
// First: children linked by options (in option order)
|
||||
const linkedChildIds = new Set<string>()
|
||||
node.options.forEach((opt) => {
|
||||
const linked = nodeChildren.find((c) => c.id === opt.next_node_id)
|
||||
if (linked) {
|
||||
orderedChildren.push({
|
||||
child: linked,
|
||||
optionLabel: opt.label || undefined,
|
||||
optionId: opt.id,
|
||||
childIndex: nodeChildren.indexOf(linked),
|
||||
})
|
||||
linkedChildIds.add(linked.id)
|
||||
}
|
||||
})
|
||||
// Then: unlinked children
|
||||
nodeChildren.forEach((child, idx) => {
|
||||
if (!linkedChildIds.has(child.id)) {
|
||||
orderedChildren.push({
|
||||
child,
|
||||
childIndex: idx,
|
||||
})
|
||||
}
|
||||
})
|
||||
} else {
|
||||
nodeChildren.forEach((child, idx) => {
|
||||
orderedChildren.push({ child, childIndex: idx })
|
||||
})
|
||||
}
|
||||
|
||||
// Determine if this node has any children to render
|
||||
const hasChildren = orderedChildren.length > 0
|
||||
|
||||
// Determine "add" targets for this node
|
||||
// For decision nodes: one add-button per option (not-yet-linked options)
|
||||
// For action nodes: one add-button below
|
||||
// For solution: none
|
||||
const unlinkedOptions =
|
||||
node.type === 'decision' && node.options
|
||||
? node.options.filter(
|
||||
(opt) =>
|
||||
opt.label.trim() &&
|
||||
(!opt.next_node_id ||
|
||||
!nodeChildren.find((c) => c.id === opt.next_node_id))
|
||||
)
|
||||
: []
|
||||
|
||||
const showSingleAddButton =
|
||||
node.type === 'action' && !hasChildren
|
||||
|
||||
return (
|
||||
<div
|
||||
key={node.id}
|
||||
className="flex flex-col items-center"
|
||||
ref={(el) => {
|
||||
if (el) nodeRefs.current.set(node.id, el as HTMLDivElement)
|
||||
else nodeRefs.current.delete(node.id)
|
||||
}}
|
||||
>
|
||||
{/* Drop indicator above */}
|
||||
{dragOverTarget?.parentId === parentId &&
|
||||
dragOverTarget.index === index && (
|
||||
<div className="mb-1 h-1 w-full rounded-full bg-primary" />
|
||||
)}
|
||||
|
||||
{/* Option label tag (above card, shown when this is a branch from a decision) */}
|
||||
{optionLabel && (
|
||||
<div className="mb-1 rounded bg-muted px-2 py-0.5 text-[10px] text-muted-foreground font-label">
|
||||
{optionLabel}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* The node card — answer stubs get their own component */}
|
||||
{node.type === 'answer' ? (
|
||||
<AnswerStubCard
|
||||
node={node}
|
||||
fromOption={optionLabel}
|
||||
onSelectType={handleSelectAnswerType}
|
||||
onDelete={handleDelete}
|
||||
/>
|
||||
) : (
|
||||
<TreeCanvasNode
|
||||
node={node}
|
||||
depth={0}
|
||||
fromOption={optionLabel}
|
||||
isExpanded={isExpanded}
|
||||
isNew={isNew}
|
||||
hasChildren={nodeChildren.length > 0}
|
||||
isSubtreeCollapsed={isSubtreeCollapsed}
|
||||
onToggleExpand={() => handleToggleExpand(node.id)}
|
||||
onToggleSubtreeCollapse={() => handleToggleSubtreeCollapse(node.id)}
|
||||
onSave={handleSave}
|
||||
onCancelNew={handleCancelNew}
|
||||
onDelete={handleDelete}
|
||||
onDuplicate={handleDuplicate}
|
||||
onDragStart={handleDragStart}
|
||||
onDragOver={(e) => handleDragOver(e, parentId, index)}
|
||||
onDrop={(e) => handleDrop(e, parentId, index)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Unlinked option add buttons (decision nodes with unlinked options) */}
|
||||
{!isExpanded && unlinkedOptions.length > 0 && (
|
||||
<div className="mt-3 flex flex-col items-center gap-2">
|
||||
{unlinkedOptions.map((opt) => {
|
||||
const key = addKey(node.id, opt.id)
|
||||
return (
|
||||
<div key={opt.id} className="flex flex-col items-center gap-1">
|
||||
<div className="h-4 w-px bg-border" />
|
||||
<span className="text-[10px] text-muted-foreground font-label">
|
||||
{opt.label || '(unlabeled option)'}
|
||||
</span>
|
||||
{pendingAddKey === key ? (
|
||||
<AddNodePicker
|
||||
onSelect={(type) =>
|
||||
handleAddNodeSelect(type, node.id, opt.id)
|
||||
}
|
||||
onCancel={() => setPendingAddKey(null)}
|
||||
/>
|
||||
) : (
|
||||
<AddNodeButton
|
||||
label="Add child"
|
||||
onClick={() => setPendingAddKey(key)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Single add button for action nodes without children */}
|
||||
{!isExpanded && showSingleAddButton && (
|
||||
<div className="mt-3 flex flex-col items-center gap-1">
|
||||
<div className="h-4 w-px bg-border" />
|
||||
{pendingAddKey === node.id ? (
|
||||
<AddNodePicker
|
||||
onSelect={(type) => handleAddNodeSelect(type, node.id)}
|
||||
onCancel={() => setPendingAddKey(null)}
|
||||
/>
|
||||
) : (
|
||||
<AddNodeButton
|
||||
label="Add next node"
|
||||
onClick={() => setPendingAddKey(node.id)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Collapsed subtree pill */}
|
||||
{hasChildren && !isExpanded && isSubtreeCollapsed && (
|
||||
<div className="mt-3 flex flex-col items-center">
|
||||
<div className="h-4 w-px bg-border" />
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleToggleSubtreeCollapse(node.id)}
|
||||
className="rounded-full border border-dashed border-border bg-card px-3 py-1 text-[10px] text-muted-foreground font-label hover:border-primary/40 hover:text-foreground transition-colors"
|
||||
>
|
||||
{orderedChildren.length} node{orderedChildren.length !== 1 ? 's' : ''} hidden — click to expand
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Connector + Children */}
|
||||
{hasChildren && !isExpanded && !isSubtreeCollapsed && (
|
||||
<div className="mt-3 flex flex-col items-center w-full">
|
||||
{/* Trunk line from card down */}
|
||||
<div className="h-4 w-px bg-border" />
|
||||
|
||||
{orderedChildren.length === 1 ? (
|
||||
// Single child: straight vertical
|
||||
<div className="flex flex-col items-center">
|
||||
{renderNode(
|
||||
orderedChildren[0].child,
|
||||
node.id,
|
||||
orderedChildren[0].childIndex,
|
||||
orderedChildren[0].optionLabel
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
// Multiple children: horizontal branching
|
||||
// The fork line and child lanes share the same flex container so the
|
||||
// line is sized by the actual rendered children, not a hardcoded estimate.
|
||||
<div className="flex items-start justify-center gap-6 w-full relative"
|
||||
style={{ maxWidth: `${orderedChildren.length * 360}px` }}
|
||||
>
|
||||
{/* Horizontal fork line — absolutely positioned, aligned to child centers.
|
||||
Spans from center of first lane to center of last lane. */}
|
||||
<div
|
||||
className="absolute top-0 h-px bg-border pointer-events-none"
|
||||
style={{
|
||||
left: `calc(${100 / (orderedChildren.length * 2)}%)`,
|
||||
right: `calc(${100 / (orderedChildren.length * 2)}%)`,
|
||||
}}
|
||||
/>
|
||||
|
||||
{orderedChildren.map(({ child, optionLabel: ol, childIndex }) => (
|
||||
<div
|
||||
key={child.id}
|
||||
className="flex flex-col items-center min-w-[260px]"
|
||||
>
|
||||
{/* Vertical stub into child lane */}
|
||||
<div className="h-4 w-px bg-border" />
|
||||
{renderNode(child, node.id, childIndex, ol)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
[
|
||||
expandedNodeId,
|
||||
newNodeIds,
|
||||
collapsedNodeIds,
|
||||
dragOverTarget,
|
||||
handleToggleExpand,
|
||||
handleToggleSubtreeCollapse,
|
||||
handleSave,
|
||||
handleCancelNew,
|
||||
handleDelete,
|
||||
handleDuplicate,
|
||||
handleSelectAnswerType,
|
||||
handleDragStart,
|
||||
handleDragOver,
|
||||
handleDrop,
|
||||
pendingAddKey,
|
||||
handleAddNodeSelect,
|
||||
]
|
||||
)
|
||||
|
||||
// ── Empty state ──
|
||||
if (!treeStructure) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<div className="text-center">
|
||||
<div className="mb-2 text-muted-foreground text-sm">
|
||||
No tree structure. Start by saving a tree name.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="relative h-full w-full overflow-auto"
|
||||
onDragEnd={handleDragEnd}
|
||||
style={{
|
||||
// Subtle dot grid background
|
||||
backgroundImage:
|
||||
'radial-gradient(circle, hsl(var(--border)) 1px, transparent 1px)',
|
||||
backgroundSize: '24px 24px',
|
||||
}}
|
||||
>
|
||||
<div className="flex min-h-full min-w-full items-start justify-center p-8 pb-24">
|
||||
<div className="flex flex-col items-center">
|
||||
{/* START badge above root */}
|
||||
<div className="mb-2 rounded-full border border-border bg-card px-3 py-1 text-xs font-label text-muted-foreground">
|
||||
START
|
||||
</div>
|
||||
<div className="mb-1 h-4 w-px bg-border" />
|
||||
|
||||
{renderNode(treeStructure, null, 0)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default TreeCanvas
|
||||
400
frontend/src/components/tree-editor/TreeCanvasNode.tsx
Normal file
400
frontend/src/components/tree-editor/TreeCanvasNode.tsx
Normal file
@@ -0,0 +1,400 @@
|
||||
import { useState, useCallback, useEffect } from 'react'
|
||||
import {
|
||||
HelpCircle,
|
||||
Zap,
|
||||
CheckCircle,
|
||||
Play,
|
||||
Check,
|
||||
X,
|
||||
Copy,
|
||||
Trash2,
|
||||
GripVertical,
|
||||
AlertCircle,
|
||||
AlertTriangle,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
ChevronsDownUp,
|
||||
ChevronsUpDown,
|
||||
} from 'lucide-react'
|
||||
import { useTreeEditorStore } from '@/store/treeEditorStore'
|
||||
import { NodeFormDecision } from './NodeFormDecision'
|
||||
import { NodeFormAction } from './NodeFormAction'
|
||||
import { NodeFormResolution } from './NodeFormResolution'
|
||||
import type { TreeStructure } from '@/types'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface TreeCanvasNodeProps {
|
||||
node: TreeStructure
|
||||
depth: number
|
||||
fromOption?: string
|
||||
isExpanded: boolean
|
||||
isNew: boolean
|
||||
hasChildren?: boolean
|
||||
isSubtreeCollapsed?: boolean
|
||||
onToggleExpand: () => void
|
||||
onToggleSubtreeCollapse?: () => void
|
||||
onSave: (nodeId: string, updates: Partial<TreeStructure>) => void
|
||||
onCancelNew: (nodeId: string) => void
|
||||
onDelete: (nodeId: string) => void
|
||||
onDuplicate: (nodeId: string) => void
|
||||
onDragStart: (e: React.DragEvent, nodeId: string) => void
|
||||
onDragOver: (e: React.DragEvent) => void
|
||||
onDrop: (e: React.DragEvent) => void
|
||||
}
|
||||
|
||||
/** Clone a node without its children (for local draft state) */
|
||||
function cloneNodeWithoutChildren(node: TreeStructure): TreeStructure {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const { children, ...rest } = node
|
||||
return structuredClone(rest) as TreeStructure
|
||||
}
|
||||
|
||||
const NODE_TYPE_CONFIG = {
|
||||
decision: {
|
||||
icon: HelpCircle,
|
||||
label: 'Decision',
|
||||
borderClass: 'border-l-4 border-l-blue-500',
|
||||
badgeClass: 'bg-blue-500/20 text-blue-400',
|
||||
},
|
||||
action: {
|
||||
icon: Zap,
|
||||
label: 'Action',
|
||||
borderClass: 'border-l-4 border-l-yellow-500',
|
||||
badgeClass: 'bg-yellow-500/20 text-yellow-400',
|
||||
},
|
||||
solution: {
|
||||
icon: CheckCircle,
|
||||
label: 'Solution',
|
||||
borderClass: 'border-l-4 border-l-green-500',
|
||||
badgeClass: 'bg-green-500/20 text-green-400',
|
||||
},
|
||||
} as const
|
||||
|
||||
export function TreeCanvasNode({
|
||||
node,
|
||||
fromOption,
|
||||
isExpanded,
|
||||
isNew,
|
||||
hasChildren = false,
|
||||
isSubtreeCollapsed = false,
|
||||
onToggleExpand,
|
||||
onToggleSubtreeCollapse,
|
||||
onSave,
|
||||
onCancelNew,
|
||||
onDelete,
|
||||
onDuplicate,
|
||||
onDragStart,
|
||||
onDragOver,
|
||||
onDrop,
|
||||
}: TreeCanvasNodeProps) {
|
||||
const { validationErrors, selectedNodeId, selectNode } = useTreeEditorStore()
|
||||
const isRoot = node.id === 'root'
|
||||
const isSelected = selectedNodeId === node.id
|
||||
|
||||
const nodeErrors = validationErrors.filter(
|
||||
(e) => e.nodeId === node.id && e.severity === 'error'
|
||||
)
|
||||
const nodeWarnings = validationErrors.filter(
|
||||
(e) => e.nodeId === node.id && e.severity === 'warning'
|
||||
)
|
||||
const hasError = nodeErrors.length > 0
|
||||
const hasWarning = nodeWarnings.length > 0
|
||||
|
||||
// Local draft state for inline editing
|
||||
const [draft, setDraft] = useState<TreeStructure>(() =>
|
||||
cloneNodeWithoutChildren(node)
|
||||
)
|
||||
|
||||
// Reset draft if node ID changes (e.g. navigating between nodes)
|
||||
const [lastNodeId, setLastNodeId] = useState(node.id)
|
||||
if (node.id !== lastNodeId) {
|
||||
setDraft(cloneNodeWithoutChildren(node))
|
||||
setLastNodeId(node.id)
|
||||
}
|
||||
|
||||
// Re-sync draft from store whenever the card is opened, so stale next_node_id
|
||||
// values (written back after stub creation) don't cause duplicate stubs on re-save
|
||||
useEffect(() => {
|
||||
if (isExpanded) {
|
||||
setDraft(cloneNodeWithoutChildren(node))
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [isExpanded])
|
||||
|
||||
const handleDraftUpdate = useCallback((updates: Partial<TreeStructure>) => {
|
||||
setDraft((prev) => ({ ...prev, ...updates }))
|
||||
}, [])
|
||||
|
||||
const handleSave = (e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
// Strip children from draft before passing to onSave
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const { children, ...draftWithoutChildren } = draft
|
||||
onSave(node.id, draftWithoutChildren)
|
||||
}
|
||||
|
||||
const handleCancel = (e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
if (isNew) {
|
||||
onCancelNew(node.id)
|
||||
} else {
|
||||
// Discard draft changes and collapse
|
||||
setDraft(cloneNodeWithoutChildren(node))
|
||||
onToggleExpand()
|
||||
}
|
||||
}
|
||||
|
||||
const handleCardClick = () => {
|
||||
selectNode(node.id)
|
||||
onToggleExpand()
|
||||
}
|
||||
|
||||
const config = node.type in NODE_TYPE_CONFIG
|
||||
? NODE_TYPE_CONFIG[node.type as keyof typeof NODE_TYPE_CONFIG]
|
||||
: NODE_TYPE_CONFIG.decision // fallback for 'answer' (rendered by AnswerStubCard)
|
||||
const TypeIcon = config.icon
|
||||
|
||||
const getTitle = () => {
|
||||
if (node.type === 'decision') return node.question || 'Untitled Question'
|
||||
return node.title || `Untitled ${node.type}`
|
||||
}
|
||||
|
||||
const getOptionsSummary = () => {
|
||||
if (node.type !== 'decision' || !node.options?.length) return null
|
||||
const count = node.options.length
|
||||
return `${count} option${count !== 1 ? 's' : ''}`
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'relative rounded-xl border border-border bg-card shadow-sm transition-all duration-150',
|
||||
config.borderClass,
|
||||
isExpanded && 'ring-1 ring-primary shadow-md',
|
||||
isSelected && !isExpanded && 'ring-1 ring-primary/50',
|
||||
hasError && 'ring-1 ring-destructive',
|
||||
hasWarning && !hasError && 'ring-1 ring-yellow-500/70',
|
||||
isNew && 'ring-1 ring-yellow-400/60',
|
||||
'min-w-[240px] max-w-[340px]'
|
||||
)}
|
||||
onDragOver={onDragOver}
|
||||
onDrop={onDrop}
|
||||
>
|
||||
{/* Card Header */}
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center gap-2 px-3 py-2.5',
|
||||
!isExpanded && 'cursor-pointer hover:bg-accent/50 rounded-t-xl',
|
||||
!isExpanded && 'rounded-xl',
|
||||
isExpanded && 'sticky top-0 z-10 bg-card rounded-t-xl'
|
||||
)}
|
||||
onClick={!isExpanded ? handleCardClick : undefined}
|
||||
>
|
||||
{/* Drag handle (hide for root) */}
|
||||
{!isRoot && (
|
||||
<span
|
||||
className="cursor-grab shrink-0"
|
||||
draggable
|
||||
onDragStart={(e) => {
|
||||
e.stopPropagation()
|
||||
onDragStart(e, node.id)
|
||||
}}
|
||||
>
|
||||
<GripVertical className="h-4 w-4 text-muted-foreground/50" />
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Node type badge */}
|
||||
{isRoot ? (
|
||||
<span className="flex items-center gap-1 rounded px-1.5 py-0.5 text-xs font-semibold bg-blue-500/30 text-blue-400 font-label shrink-0">
|
||||
<Play className="h-3 w-3" />
|
||||
START
|
||||
</span>
|
||||
) : (
|
||||
<span
|
||||
className={cn(
|
||||
'flex items-center gap-1 rounded px-1.5 py-0.5 text-xs font-label shrink-0',
|
||||
config.badgeClass
|
||||
)}
|
||||
>
|
||||
<TypeIcon className="h-3 w-3" />
|
||||
{config.label}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* From-option label */}
|
||||
{fromOption && (
|
||||
<span className="rounded bg-muted px-1.5 py-0.5 text-[10px] text-muted-foreground truncate max-w-[80px]">
|
||||
{fromOption}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Title text (compact mode) */}
|
||||
{!isExpanded && (
|
||||
<span className="flex-1 truncate text-sm font-heading font-medium text-foreground">
|
||||
{getTitle()}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Options count badge */}
|
||||
{!isExpanded && getOptionsSummary() && (
|
||||
<span className="text-[10px] text-muted-foreground shrink-0 font-label">
|
||||
{getOptionsSummary()}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Validation badges (compact mode) */}
|
||||
{!isExpanded && hasError && (
|
||||
<span
|
||||
className="flex items-center gap-0.5 rounded bg-destructive/20 px-1.5 py-0.5 text-[10px] text-destructive shrink-0"
|
||||
title={nodeErrors.map((e) => e.message).join('\n')}
|
||||
>
|
||||
<AlertCircle className="h-2.5 w-2.5" />
|
||||
{nodeErrors.length}
|
||||
</span>
|
||||
)}
|
||||
{!isExpanded && !hasError && hasWarning && (
|
||||
<span
|
||||
className="flex items-center gap-0.5 rounded bg-yellow-500/20 px-1.5 py-0.5 text-[10px] text-yellow-500 shrink-0"
|
||||
title={nodeWarnings.map((e) => e.message).join('\n')}
|
||||
>
|
||||
<AlertTriangle className="h-2.5 w-2.5" />
|
||||
{nodeWarnings.length}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Unsaved badge */}
|
||||
{!isExpanded && isNew && (
|
||||
<span className="rounded bg-yellow-500/20 px-1.5 py-0.5 text-[10px] text-yellow-500 font-label shrink-0">
|
||||
Unsaved
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Subtree collapse toggle — only in compact mode when node has children */}
|
||||
{!isExpanded && hasChildren && onToggleSubtreeCollapse && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => { e.stopPropagation(); onToggleSubtreeCollapse() }}
|
||||
title={isSubtreeCollapsed ? 'Expand subtree' : 'Collapse subtree'}
|
||||
className="rounded p-0.5 text-muted-foreground/50 hover:bg-accent hover:text-foreground shrink-0"
|
||||
>
|
||||
{isSubtreeCollapsed
|
||||
? <ChevronsUpDown className="h-3.5 w-3.5" />
|
||||
: <ChevronsDownUp className="h-3.5 w-3.5" />
|
||||
}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Expand/collapse chevron */}
|
||||
{!isExpanded ? (
|
||||
<ChevronRight className="h-3.5 w-3.5 text-muted-foreground shrink-0" />
|
||||
) : (
|
||||
<ChevronDown className="h-3.5 w-3.5 text-muted-foreground shrink-0" />
|
||||
)}
|
||||
|
||||
{/* Editing action buttons (expanded state) */}
|
||||
{isExpanded && (
|
||||
<div className="ml-auto flex items-center gap-1 shrink-0">
|
||||
{/* New badge */}
|
||||
{isNew && (
|
||||
<span className="rounded bg-yellow-500/20 px-1.5 py-0.5 text-[10px] text-yellow-500 font-label">
|
||||
Unsaved
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Duplicate (hide for root) */}
|
||||
{!isRoot && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onDuplicate(node.id)
|
||||
}}
|
||||
title="Duplicate node"
|
||||
className="rounded p-1 text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
>
|
||||
<Copy className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Delete (hide for root) */}
|
||||
{!isRoot && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onDelete(node.id)
|
||||
}}
|
||||
title="Delete node"
|
||||
className="rounded p-1 text-muted-foreground hover:bg-destructive/20 hover:text-destructive"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Cancel */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCancel}
|
||||
title={isNew ? 'Cancel (deletes this node)' : 'Cancel changes'}
|
||||
className="rounded p-1 text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
|
||||
{/* Save */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSave}
|
||||
title="Save changes"
|
||||
className="rounded p-1 bg-gradient-brand text-white hover:opacity-90"
|
||||
>
|
||||
<Check className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Expanded editing area */}
|
||||
{isExpanded && (
|
||||
<div className="border-t border-border px-3 pb-3 pt-3 max-h-[70vh] overflow-y-auto">
|
||||
{/* Validation errors */}
|
||||
{(hasError || hasWarning) && (
|
||||
<div className="mb-3 space-y-1">
|
||||
{nodeErrors.map((error, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="rounded-md bg-red-400/10 px-3 py-2 text-xs text-red-400"
|
||||
>
|
||||
{error.message}
|
||||
</div>
|
||||
))}
|
||||
{!hasError &&
|
||||
nodeWarnings.map((warning, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="rounded-md bg-yellow-400/10 px-3 py-2 text-xs text-yellow-400"
|
||||
>
|
||||
{warning.message}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Type-specific form — uses draft, not live node */}
|
||||
{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>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default TreeCanvasNode
|
||||
@@ -1,7 +1,7 @@
|
||||
import { lazy, Suspense } from 'react'
|
||||
import { TreeMetadataForm } from './TreeMetadataForm'
|
||||
import { NodeList } from './NodeList'
|
||||
import { TreePreviewPanel } from '@/components/tree-preview/TreePreviewPanel'
|
||||
import { TreeCanvas } from './TreeCanvas'
|
||||
import { MetadataSidePanel } from './MetadataSidePanel'
|
||||
import { useTreeEditorStore } from '@/store/treeEditorStore'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
@@ -12,9 +12,15 @@ const CodeModeEditor = lazy(() =>
|
||||
|
||||
interface TreeEditorLayoutProps {
|
||||
isMobile?: boolean
|
||||
isMetadataOpen?: boolean
|
||||
onCloseMetadata?: () => void
|
||||
}
|
||||
|
||||
export function TreeEditorLayout({ isMobile = false }: TreeEditorLayoutProps) {
|
||||
export function TreeEditorLayout({
|
||||
isMobile = false,
|
||||
isMetadataOpen = false,
|
||||
onCloseMetadata = () => {},
|
||||
}: TreeEditorLayoutProps) {
|
||||
const editorMode = useTreeEditorStore(s => s.editorMode)
|
||||
|
||||
return (
|
||||
@@ -26,7 +32,7 @@ export function TreeEditorLayout({ isMobile = false }: TreeEditorLayoutProps) {
|
||||
>
|
||||
{editorMode === 'code' ? (
|
||||
<>
|
||||
{/* Code Mode: Monaco editor (60%) + Preview (40%) */}
|
||||
{/* Code Mode: Monaco editor (60%) + Preview (40%) — unchanged */}
|
||||
<div className={cn(
|
||||
'flex flex-col overflow-hidden border-border',
|
||||
isMobile ? 'h-full w-full border-b' : 'w-3/5 border-r'
|
||||
@@ -50,24 +56,16 @@ export function TreeEditorLayout({ isMobile = false }: TreeEditorLayoutProps) {
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{/* Flow Mode: Form editor (60%) + Preview (40%) */}
|
||||
<div className={cn(
|
||||
'flex flex-col overflow-y-auto border-border',
|
||||
isMobile ? 'h-full w-full border-b' : 'w-3/5 border-r'
|
||||
)}>
|
||||
<div className="space-y-4 p-4">
|
||||
<TreeMetadataForm />
|
||||
<NodeList />
|
||||
</div>
|
||||
{/* Flow Mode: Full-width visual canvas */}
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<TreeCanvas />
|
||||
</div>
|
||||
|
||||
{/* Right Panel - Preview */}
|
||||
<div className={cn(
|
||||
'flex-1 overflow-hidden bg-accent/50',
|
||||
isMobile ? 'hidden' : 'block'
|
||||
)}>
|
||||
<TreePreviewPanel />
|
||||
</div>
|
||||
{/* Metadata side panel — overlays the canvas from the right */}
|
||||
<MetadataSidePanel
|
||||
isOpen={isMetadataOpen}
|
||||
onClose={onCloseMetadata}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -132,19 +132,20 @@ export function TreeMetadataForm() {
|
||||
onChange={(e) => setCategory(e.target.value)}
|
||||
placeholder="Enter new category"
|
||||
className={cn(
|
||||
'block flex-1 rounded-md border border-border px-3 py-2 text-sm',
|
||||
'block min-w-0 flex-1 rounded-md border border-border px-3 py-2 text-sm',
|
||||
'bg-card text-foreground placeholder:text-muted-foreground',
|
||||
'focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary/20'
|
||||
)}
|
||||
autoFocus
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setCustomCategory(false)
|
||||
setCategory('')
|
||||
setCategoryId(null)
|
||||
}}
|
||||
className="rounded-md border border-border px-3 py-2 text-sm text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
className="shrink-0 rounded-md border border-border px-2.5 py-2 text-xs text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
|
||||
@@ -87,25 +87,29 @@ export function TreePreviewNode({
|
||||
const nodeTypeColors: Record<NodeType, string> = {
|
||||
decision: 'border-blue-500/50 bg-blue-500/10',
|
||||
action: 'border-yellow-500/50 bg-yellow-500/10',
|
||||
solution: 'border-green-500/50 bg-green-500/10'
|
||||
solution: 'border-green-500/50 bg-green-500/10',
|
||||
answer: 'border-dashed border-border bg-muted/50'
|
||||
}
|
||||
|
||||
const nodeTypeSelectedColors: Record<NodeType, string> = {
|
||||
decision: 'border-blue-500 bg-blue-500/20 ring-2 ring-blue-500/50 shadow-lg shadow-blue-500/20',
|
||||
action: 'border-yellow-500 bg-yellow-500/20 ring-2 ring-yellow-500/50 shadow-lg shadow-yellow-500/20',
|
||||
solution: 'border-green-500 bg-green-500/20 ring-2 ring-green-500/50 shadow-lg shadow-green-500/20'
|
||||
solution: 'border-green-500 bg-green-500/20 ring-2 ring-green-500/50 shadow-lg shadow-green-500/20',
|
||||
answer: 'border-border bg-muted/50'
|
||||
}
|
||||
|
||||
const nodeTypeHoveredColors: Record<NodeType, string> = {
|
||||
decision: 'border-blue-400 bg-blue-500/15 ring-1 ring-blue-400/50',
|
||||
action: 'border-yellow-400 bg-yellow-500/15 ring-1 ring-yellow-400/50',
|
||||
solution: 'border-green-400 bg-green-500/15 ring-1 ring-green-400/50'
|
||||
solution: 'border-green-400 bg-green-500/15 ring-1 ring-green-400/50',
|
||||
answer: 'border-border bg-muted/50'
|
||||
}
|
||||
|
||||
const nodeTypeIcons: Record<NodeType, React.ReactNode> = {
|
||||
decision: <HelpCircle className="h-4 w-4 text-blue-500" />,
|
||||
action: <Zap className="h-4 w-4 text-yellow-500" />,
|
||||
solution: <CheckCircle className="h-4 w-4 text-green-500" />
|
||||
solution: <CheckCircle className="h-4 w-4 text-green-500" />,
|
||||
answer: <HelpCircle className="h-4 w-4 opacity-40" />
|
||||
}
|
||||
|
||||
const getNodeLabel = () => {
|
||||
|
||||
@@ -78,6 +78,10 @@ function serializeNode(
|
||||
if (node.resolution_steps?.length) {
|
||||
node.resolution_steps.forEach((step, i) => body.push(`${i + 1}. ${step}`))
|
||||
}
|
||||
} else if (node.type === 'answer') {
|
||||
// Answer placeholder — render as a clearly marked stub
|
||||
body.push(`## [ANSWER PLACEHOLDER] ${node.title || 'Untitled'}`, '')
|
||||
body.push('> This is an unresolved answer stub. Convert it to a Decision, Action, or Solution before publishing.')
|
||||
}
|
||||
|
||||
blocks.push(fm.join('\n') + '\n' + body.join('\n'))
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { useEffect, useState, useCallback } from 'react'
|
||||
import { useParams, useNavigate, useBlocker } from 'react-router-dom'
|
||||
import { useStore } from 'zustand'
|
||||
import { Undo2, Redo2, Save, CheckCircle2, Monitor, FileText, Code2, LayoutList, BarChart3 } from 'lucide-react'
|
||||
import { Undo2, Redo2, Save, CheckCircle2, Monitor, FileText, Code2, LayoutList, BarChart3, Settings } from 'lucide-react'
|
||||
import { getMonacoEditor } from '@/components/tree-editor/code-mode'
|
||||
import { treesApi } from '@/api/trees'
|
||||
import { treeMarkdownApi } from '@/api/treeMarkdown'
|
||||
import type { TreeCreate, TreeUpdate, TreeStatus } from '@/types'
|
||||
import type { TreeCreate, TreeUpdate, TreeStatus, TreeStructure } from '@/types'
|
||||
import { useTreeEditorStore, useTreeEditorTemporal } from '@/store/treeEditorStore'
|
||||
import { TreeEditorLayout } from '@/components/tree-editor/TreeEditorLayout'
|
||||
import { ValidationSummary } from '@/components/tree-editor/ValidationSummary'
|
||||
@@ -15,6 +15,12 @@ import { cn, safeGetItem } from '@/lib/utils'
|
||||
import { toast } from '@/lib/toast'
|
||||
import { FlowAnalyticsPanel } from '@/components/analytics/FlowAnalyticsPanel'
|
||||
|
||||
/** Recursively check if any node in the tree has type 'answer' */
|
||||
function hasAnswerNodes(node: TreeStructure): boolean {
|
||||
if (node.type === 'answer') return true
|
||||
return (node.children || []).some(hasAnswerNodes)
|
||||
}
|
||||
|
||||
export function TreeEditorPage() {
|
||||
const { id } = useParams<{ id: string }>()
|
||||
const navigate = useNavigate()
|
||||
@@ -48,6 +54,7 @@ export function TreeEditorPage() {
|
||||
const [showDraftPrompt, setShowDraftPrompt] = useState(false)
|
||||
const [treeStatus, setTreeStatus] = useState<TreeStatus>('draft')
|
||||
const [showAnalytics, setShowAnalytics] = useState(false)
|
||||
const [isMetadataOpen, setIsMetadataOpen] = useState(false)
|
||||
|
||||
// Mobile detection
|
||||
const [isMobile, setIsMobile] = useState(false)
|
||||
@@ -291,6 +298,14 @@ export function TreeEditorPage() {
|
||||
return
|
||||
}
|
||||
|
||||
// Block publish if any answer placeholder nodes remain
|
||||
const currentStructure = useTreeEditorStore.getState().treeStructure
|
||||
if (currentStructure && hasAnswerNodes(currentStructure)) {
|
||||
toast.error('Resolve all answer placeholders before publishing. Click each dashed stub card to assign a type.')
|
||||
setSaving(false)
|
||||
return
|
||||
}
|
||||
|
||||
// Validate tree structure
|
||||
const errors = validate()
|
||||
const hasErrors = errors.some(e => e.severity === 'error')
|
||||
@@ -475,7 +490,7 @@ export function TreeEditorPage() {
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setEditorMode('form')}
|
||||
title="Flow Mode — form-based editing"
|
||||
title="Flow Mode — visual canvas editing"
|
||||
className={cn(
|
||||
'flex items-center gap-1.5 rounded-l-md px-3 py-1.5 text-xs font-medium transition-colors',
|
||||
editorMode === 'form'
|
||||
@@ -489,7 +504,10 @@ export function TreeEditorPage() {
|
||||
<div className="h-5 w-px bg-border" />
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setEditorMode('code')}
|
||||
onClick={() => {
|
||||
setEditorMode('code')
|
||||
setIsMetadataOpen(false) // Auto-close metadata panel on Code mode
|
||||
}}
|
||||
title="Code Mode — markdown editing (Ctrl+Shift+M)"
|
||||
className={cn(
|
||||
'flex items-center gap-1.5 rounded-r-md px-3 py-1.5 text-xs font-medium transition-colors',
|
||||
@@ -540,6 +558,24 @@ export function TreeEditorPage() {
|
||||
|
||||
<div className="mx-2 h-6 w-px bg-border" />
|
||||
|
||||
{/* Metadata panel toggle — Flow mode only */}
|
||||
{editorMode === 'form' && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsMetadataOpen(!isMetadataOpen)}
|
||||
title="Edit flow metadata (name, description, category, tags)"
|
||||
className={cn(
|
||||
'flex items-center gap-2 rounded-md border border-border px-3 py-2 text-sm font-medium transition-colors',
|
||||
isMetadataOpen
|
||||
? 'bg-accent text-foreground'
|
||||
: 'bg-card text-muted-foreground hover:bg-accent hover:text-foreground'
|
||||
)}
|
||||
>
|
||||
<Settings className="h-4 w-4" />
|
||||
Metadata
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Analytics toggle (only for existing trees) */}
|
||||
{isEditMode && (
|
||||
<button
|
||||
@@ -612,7 +648,11 @@ export function TreeEditorPage() {
|
||||
)}
|
||||
|
||||
{/* Main Editor */}
|
||||
<TreeEditorLayout isMobile={isMobile} />
|
||||
<TreeEditorLayout
|
||||
isMobile={isMobile}
|
||||
isMetadataOpen={isMetadataOpen}
|
||||
onCloseMetadata={() => setIsMetadataOpen(false)}
|
||||
/>
|
||||
|
||||
{/* Flow Analytics Panel (collapsible) */}
|
||||
{showAnalytics && id && (
|
||||
|
||||
@@ -326,35 +326,40 @@ export function TreeLibraryPage() {
|
||||
|
||||
{/* View Controls */}
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex rounded-lg border border-border p-0.5">
|
||||
{(['all', 'troubleshooting', 'procedural', 'maintenance'] as const).map((t) => (
|
||||
{/* Type filter tabs — includes Drafts as a first-class filter */}
|
||||
<div className="flex rounded-lg border border-border p-0.5">
|
||||
{(['all', 'troubleshooting', 'procedural', 'maintenance', 'drafts'] as const).map((t) => {
|
||||
const isActive = t === 'drafts' ? showDrafts && typeFilter === 'all' : !showDrafts && typeFilter === t
|
||||
return (
|
||||
<button
|
||||
key={t}
|
||||
onClick={() => setTypeFilter(t)}
|
||||
onClick={() => {
|
||||
if (t === 'drafts') {
|
||||
setShowDrafts(true)
|
||||
setTypeFilter('all')
|
||||
} else {
|
||||
setShowDrafts(false)
|
||||
setTypeFilter(t)
|
||||
}
|
||||
}}
|
||||
className={cn(
|
||||
'rounded-md px-3 py-1 text-xs font-medium transition-colors',
|
||||
typeFilter === t
|
||||
isActive
|
||||
? 'bg-accent text-foreground'
|
||||
: 'text-muted-foreground hover:text-foreground'
|
||||
)}
|
||||
>
|
||||
{t === 'all' ? 'All' : t === 'troubleshooting' ? 'Troubleshooting' : t === 'procedural' ? 'Projects' : 'Maintenance'}
|
||||
{t === 'all' ? 'All' : t === 'troubleshooting' ? 'Troubleshooting' : t === 'procedural' ? 'Projects' : t === 'maintenance' ? 'Maintenance' : 'Drafts'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<SortDropdown value={treeLibrarySortBy} onChange={setTreeLibrarySortBy} />
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={showDrafts}
|
||||
onChange={(e) => setShowDrafts(e.target.checked)}
|
||||
className="h-4 w-4 rounded border-border text-primary focus:ring-2 focus:ring-primary/20 focus:ring-offset-2"
|
||||
/>
|
||||
<span className="text-sm text-muted-foreground">Show my drafts</span>
|
||||
</label>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Right controls: sort + view toggle */}
|
||||
<div className="flex items-center gap-2">
|
||||
<SortDropdown value={treeLibrarySortBy} onChange={setTreeLibrarySortBy} />
|
||||
<ViewToggle view={treeLibraryView} onChange={setTreeLibraryView} />
|
||||
</div>
|
||||
<ViewToggle view={treeLibraryView} onChange={setTreeLibraryView} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -757,6 +757,15 @@ export function TreeNavigationPage() {
|
||||
|
||||
{/* Current Node */}
|
||||
<div className="bg-card border border-border rounded-xl p-6 shadow-sm">
|
||||
{/* Answer placeholder guard */}
|
||||
{currentNode && currentNode.type === 'answer' && (
|
||||
<div className="rounded-lg border border-yellow-500/30 bg-yellow-500/10 p-6 text-center">
|
||||
<p className="text-sm font-medium text-yellow-400">
|
||||
This tree contains an unresolved placeholder node. Please contact the tree author to complete it before use.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Decision Node */}
|
||||
{currentNode && currentNode.type === 'decision' && (
|
||||
<>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { CategoryInfo } from './category'
|
||||
|
||||
// Tree node types
|
||||
export type NodeType = 'decision' | 'action' | 'solution'
|
||||
export type NodeType = 'decision' | 'action' | 'solution' | 'answer'
|
||||
|
||||
export interface TreeOption {
|
||||
id: string
|
||||
|
||||
Reference in New Issue
Block a user