Procedural flow/flow editor fixes
This commit is contained in:
@@ -261,6 +261,12 @@ navigate(`/trees/${newTree.id}/edit`)
|
||||
|
||||
**18. Pydantic partial updates — use `model_fields_set`:** When a PUT/PATCH endpoint needs to distinguish "field not sent" from "field sent as null", check `data.model_fields_set` instead of `data.field is not None`. This allows clients to explicitly clear nullable fields like `description`.
|
||||
|
||||
**19. `gh pr merge` fails with worktrees:** When `main` is checked out in the primary worktree, `gh pr merge` crashes with "fatal: 'main' is already used by worktree". Use the API directly instead: `gh api repos/ORG/REPO/pulls/N/merge --method PUT --field merge_method=squash`
|
||||
|
||||
**20. `'answer'` node type in TreeStructure:** `answer` is a transient stub type used only in the canvas editor. Any code that switches on `node.type` (validation, markdown serializer, session nav guard) must explicitly handle `'answer'` or it will hit an unhandled-type error.
|
||||
|
||||
**21. Test fixtures in `conftest.py`:** Available fixtures are `client` (async HTTP client), `test_db` (async session), `test_user` (registers user, returns email/password/user_data), `auth_headers` (Bearer token dict), `test_tree` (creates a tree), `test_admin` (super_admin user), `admin_auth_headers` (admin Bearer token). There is NO `async_client` or `engineer_token` fixture.
|
||||
|
||||
---
|
||||
|
||||
## RBAC & Permissions
|
||||
|
||||
574
docs/plans/IMPLEMENTATION-PLAN-TREE-EDITOR-CANVAS.md
Normal file
574
docs/plans/IMPLEMENTATION-PLAN-TREE-EDITOR-CANVAS.md
Normal file
@@ -0,0 +1,574 @@
|
||||
# Implementation Plan: Tree Editor Canvas Redesign
|
||||
|
||||
> **Date:** February 17, 2026
|
||||
> **Scope:** Replace NodeList + NodeEditorModal + TreePreviewPanel in Flow mode with a visual canvas + inline card editing
|
||||
> **Estimated Components:** 3 new files, 4 modified files
|
||||
> **Phases:** 4 (sequential)
|
||||
> **Branch:** `feature/tree-editor-canvas`
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
Replace the current text-outline + modal + passive preview layout with a single-pane visual canvas where nodes are cards, editing is inline, and branches are visually connected. The tree IS the editor — no separate preview panel needed.
|
||||
|
||||
### Current State
|
||||
|
||||
The tree editor uses a text-outline metaphor:
|
||||
- Nodes listed as indented rows with ASCII tree lines in `NodeList.tsx`
|
||||
- Editing requires opening `NodeEditorModal.tsx` for each node (click row → modal opens → edit → Done → modal closes)
|
||||
- Passive `TreePreviewPanel.tsx` takes 40% of space but offers no editing
|
||||
- Adding nodes is a two-step picker-then-modal flow
|
||||
- Options/branches are opaque — can't see where each branch leads without clicking into the node
|
||||
|
||||
### Target State (5 Outcomes)
|
||||
|
||||
1. Flow mode uses a full-width `TreeCanvas` editor; preview panel is removed from Flow mode
|
||||
2. Node editing is inline in cards with local draft + ✓ save + ✕ cancel (no modal)
|
||||
3. Branches are visually rendered with parent-child connector lines and horizontal splits
|
||||
4. Metadata moves to a right slide-in panel, collapsed by default, opened from toolbar in Flow mode only
|
||||
5. Code mode remains functionally unchanged (Monaco + preview split)
|
||||
|
||||
### Layout After Redesign
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ TOOLBAR: [Flow Name] [Undo] [Redo] [Metadata] [Save] [Publish] │
|
||||
├─────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ [START] │
|
||||
│ │ │
|
||||
│ ┌─────────────────────────────┐ │
|
||||
│ │ ? What type of issue? │ ← Decision card │
|
||||
│ │ ↳ [A] Network Issues │ │
|
||||
│ │ ↳ [B] App Errors │ │
|
||||
│ └─────────────────────────────┘ │
|
||||
│ │ │ │
|
||||
│ [Network card] [App Errors card] │
|
||||
│ │ │
|
||||
│ [Solution card] │
|
||||
│ │
|
||||
│ [+ Add node here] ← contextual add buttons │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Reusable Code Inventory
|
||||
|
||||
Before building anything, confirm these existing pieces are available and understand their APIs:
|
||||
|
||||
| Asset | Location | How It's Used |
|
||||
|-------|----------|---------------|
|
||||
| `useTreeEditorStore()` | `store/treeEditorStore.ts` | All CRUD: `addNode`, `updateNode`, `deleteNode`, `duplicateNode`, `reorderNodes`, `selectNode`, `findNode`, `validationErrors` |
|
||||
| `NodeFormDecision` | `components/tree-editor/NodeFormDecision.tsx` | Decision node fields (question, help_text, options) — will be rendered inline in card |
|
||||
| `NodeFormAction` | `components/tree-editor/NodeFormAction.tsx` | Action node fields — will be rendered inline in card |
|
||||
| `NodeFormResolution` | `components/tree-editor/NodeFormResolution.tsx` | Solution node fields — will be rendered inline in card |
|
||||
| `DynamicArrayField` | `components/tree-editor/DynamicArrayField.tsx` | Reuse for options array in inline card |
|
||||
| `NodePicker` | `components/tree-editor/NodePicker.tsx` | Reuse for option target selection (needs `allowCreate` prop added) |
|
||||
| `TreeMetadataForm` | `components/tree-editor/TreeMetadataForm.tsx` | Wraps into MetadataSidePanel as-is |
|
||||
| `cn()` | `@/lib/utils` | Tailwind class merging utility |
|
||||
| Design tokens | `tailwind.config.js` | `bg-card`, `border-border`, `text-foreground`, `font-heading`, `font-label` |
|
||||
| Brand colors | `tailwind.config.js` | Blue (decision), Yellow (action), Green (solution) |
|
||||
| `buildSharedLinksMap()` | `components/tree-preview/TreePreviewPanel.tsx` | Shared node detection logic — extract and reuse for jump/reference indicators |
|
||||
|
||||
---
|
||||
|
||||
## Link Model & Rendering Rules
|
||||
|
||||
**IMPORTANT:** These rules govern how the canvas renders node connections. Read before implementing Phase 2.
|
||||
|
||||
### Tree-First Rendering
|
||||
|
||||
Render only structural children (nodes in `node.children[]`) with visual connector lines. Do NOT draw cross-canvas connector lines for shared/cross-linked nodes.
|
||||
|
||||
For links to non-child/shared targets (where `option.next_node_id` points to a node that is NOT a direct child), show a compact "jump/reference" indicator badge in the card content instead of a connector line.
|
||||
|
||||
### Decision Child Lane Ordering
|
||||
|
||||
When a decision node has children, order the horizontal child lanes:
|
||||
1. **First:** Children whose `id` matches an `option.next_node_id` — ordered by option order
|
||||
2. **Then:** Append any remaining unlinked children
|
||||
|
||||
### Action Next-Child Rule
|
||||
|
||||
- If a child's `id` matches the action node's `next_node_id`, treat it as the primary "next" lane
|
||||
- If no child matches `next_node_id`, keep child visible but show the link as an "unbound reference" indicator
|
||||
|
||||
### Deletion Safety
|
||||
|
||||
**Before calling `deleteNode(nodeId)`**, the canvas must clean up all inbound references:
|
||||
- Scan all decision nodes: clear any `options[].next_node_id` that equals the deleted node's ID
|
||||
- Scan all action nodes: clear any `next_node_id` that equals the deleted node's ID
|
||||
|
||||
This prevents stale link references. The current `treeEditorStore.deleteNode()` does NOT do this cleanup — the canvas orchestration layer handles it.
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: TreeCanvasNode Component (Core Inline Editor Card)
|
||||
|
||||
**File:** `frontend/src/components/tree-editor/TreeCanvasNode.tsx` (NEW)
|
||||
|
||||
### Props Interface
|
||||
|
||||
```typescript
|
||||
interface TreeCanvasNodeProps {
|
||||
node: TreeStructure
|
||||
depth: number
|
||||
fromOption?: string // Which parent option label leads here
|
||||
isExpanded: boolean
|
||||
isNew: boolean // Show "Unsaved" badge, cancel triggers delete
|
||||
onToggleExpand: () => void
|
||||
onSave: (nodeId: string, updates: Partial<TreeStructure>) => void
|
||||
onCancelNew: (nodeId: string) => void // Delete unsaved node
|
||||
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
|
||||
}
|
||||
```
|
||||
|
||||
### Card States
|
||||
|
||||
**Compact (default):**
|
||||
- Node type badge/icon (Decision ?, Action ⚡, Solution ✓)
|
||||
- Title text (question for decisions, title for action/solution)
|
||||
- Option labels or option count for decisions
|
||||
- Validation error badge (red dot if errors on this node)
|
||||
- "Unsaved" badge (yellow, if `isNew`)
|
||||
- Click anywhere on card → calls `onToggleExpand`
|
||||
|
||||
**Expanded (editing):**
|
||||
- Local draft state: `const [draft, setDraft] = useState<Partial<TreeStructure>>(() => cloneNodeWithoutChildren(node))`
|
||||
- Renders the appropriate existing form subcomponent inline:
|
||||
- Decision: `<NodeFormDecision node={draft} onUpdate={setDraftField} />`
|
||||
- Action: `<NodeFormAction node={draft} onUpdate={setDraftField} />`
|
||||
- Solution: `<NodeFormResolution node={draft} onUpdate={setDraftField} />`
|
||||
- Header actions row:
|
||||
- ✓ Save button → calls `onSave(node.id, draft)` (strip children from draft before passing)
|
||||
- ✕ Cancel button → if `isNew`, calls `onCancelNew(node.id)`. Otherwise resets draft to node values and collapses
|
||||
- Duplicate button (hide if root)
|
||||
- Delete button (hide if root)
|
||||
- Drag handle in header (hide if root)
|
||||
|
||||
### Card Styling
|
||||
|
||||
Aesthetic direction: "Precision engineering tool" — clean, minimal chrome, confident typography.
|
||||
|
||||
```
|
||||
All cards: bg-card border-border rounded-xl shadow-sm
|
||||
Decision: border-l-4 border-blue-500
|
||||
Action: border-l-4 border-yellow-500
|
||||
Solution: border-l-4 border-green-500
|
||||
Expanded ring: ring-1 ring-primary
|
||||
Titles: font-heading (Plus Jakarta Sans)
|
||||
Type badges: font-label (Outfit)
|
||||
```
|
||||
|
||||
### Implementation Steps
|
||||
|
||||
1. Create `TreeCanvasNode.tsx` with compact view only (type badge, title, option count)
|
||||
2. Add expanded view with local draft state and inline form rendering
|
||||
3. Wire save/cancel/delete/duplicate actions
|
||||
4. Add drag handle events
|
||||
5. Add validation badge and unsaved badge
|
||||
6. Style with brand tokens
|
||||
|
||||
### Verification
|
||||
|
||||
- Render a single card in isolation with mock data
|
||||
- Confirm compact → expanded toggle works
|
||||
- Confirm save commits draft (log output), cancel resets
|
||||
- Confirm cancel on `isNew=true` calls `onCancelNew`
|
||||
- Run `npm run build` — no TypeScript errors
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: TreeCanvas Component (Layout & Orchestration)
|
||||
|
||||
**File:** `frontend/src/components/tree-editor/TreeCanvas.tsx` (NEW)
|
||||
|
||||
### Canvas State Model
|
||||
|
||||
```typescript
|
||||
// Local UI state (NOT in Zustand store — canvas-only concerns)
|
||||
const [expandedNodeIds, setExpandedNodeIds] = useState<Set<string>>(new Set())
|
||||
const [newNodeIds, setNewNodeIds] = useState<Set<string>>(new Set())
|
||||
const [pendingAddTarget, setPendingAddTarget] = useState<string | null>(null)
|
||||
const [pendingLinkByNodeId, setPendingLinkByNodeId] = useState<Map<string, {
|
||||
parentId: string
|
||||
optionId?: string // For decision option linking
|
||||
}>>(new Map())
|
||||
const [dragState, setDragState] = useState<{
|
||||
nodeId: string
|
||||
parentId: string
|
||||
index: number
|
||||
} | null>(null)
|
||||
```
|
||||
|
||||
**Single expanded card policy:** Only one card expanded at a time. When expanding a card, collapse the previously expanded one.
|
||||
|
||||
### Rendering
|
||||
|
||||
Recursive rendering of `treeStructure` from the store:
|
||||
- Root at top, rendered as a "START" card
|
||||
- Vertical flow downward
|
||||
- When a decision node has multiple options with children, render children in horizontal lanes side-by-side
|
||||
- Single-pane scrollable area (`overflow-auto`)
|
||||
- Background: `bg-background` with subtle CSS radial dot grid pattern
|
||||
|
||||
### Connector Lines
|
||||
|
||||
Use CSS borders (not SVG) for connecting lines:
|
||||
- **Parent-to-children trunk line:** `border-l border-border` extending down from parent
|
||||
- **Horizontal fork line:** `border-t border-border` connecting sibling lane tops
|
||||
- **Vertical stubs:** `border-l border-border` dropping into each child card
|
||||
|
||||
### Add-Node Flow
|
||||
|
||||
| Parent Type | Add Button Behavior |
|
||||
|-------------|-------------------|
|
||||
| Decision | Show `+ Add child` per option row (next to each option label) |
|
||||
| Action | Show single `+ Add child` below the card |
|
||||
| Solution | No add button (terminal node) |
|
||||
|
||||
- `+ Add` buttons use dashed border, appear on hover of parent card bottom edge
|
||||
- Clicking `+ Add` sets `pendingAddTarget` → shows inline type picker (decision/action/solution buttons) at that position
|
||||
- Selecting a type:
|
||||
1. Calls `addNode(parentId, type)` → gets new node ID
|
||||
2. Adds node ID to `newNodeIds`
|
||||
3. Adds entry to `pendingLinkByNodeId` (parent ID + option ID if from a decision option)
|
||||
4. Auto-expands the new node card
|
||||
5. Clears `pendingAddTarget`
|
||||
|
||||
### Save Behavior for New Child Nodes
|
||||
|
||||
When user clicks ✓ on a new node:
|
||||
1. Call `updateNode(nodeId, draft)` to save content to store
|
||||
2. If `pendingLinkByNodeId.has(nodeId)`:
|
||||
- Get the pending link info (`parentId`, `optionId`)
|
||||
- If `optionId` exists: update parent's `options[].next_node_id` to point to this node
|
||||
- If no `optionId` (action parent): update parent's `next_node_id` to point to this node
|
||||
3. Remove from `newNodeIds`
|
||||
4. Remove from `pendingLinkByNodeId`
|
||||
|
||||
### Cancel Behavior for New Nodes
|
||||
|
||||
When user clicks ✕ on a new (unsaved) node:
|
||||
1. Call `deleteNode(nodeId)`
|
||||
2. Remove from `newNodeIds`
|
||||
3. Remove from `pendingLinkByNodeId`
|
||||
|
||||
### Delete Behavior (Any Node)
|
||||
|
||||
When user clicks delete on any node:
|
||||
1. **Clean inbound references first** (see Link Model section above):
|
||||
- Walk the full tree and clear any `options[].next_node_id` or `next_node_id` matching the node being deleted
|
||||
2. Then call `deleteNode(nodeId)`
|
||||
3. Remove from `expandedNodeIds` if present
|
||||
|
||||
### Sibling Reorder
|
||||
|
||||
- Drag handle in card header (Phase 1 wired the events)
|
||||
- Drop zones rendered between sibling cards (visual indicator line)
|
||||
- On drop: call `reorderNodes(parentId, fromIndex, toIndex)`
|
||||
|
||||
### Selection Integration
|
||||
|
||||
- Click on a card calls `selectNode(nodeId)` in the store
|
||||
- Watch `selectedNodeId` from store (including changes from `ValidationSummary` clicks):
|
||||
- Auto-expand the selected node's card
|
||||
- Scroll the card into view with `scrollIntoView({ behavior: 'smooth', block: 'nearest' })`
|
||||
|
||||
### Implementation Steps
|
||||
|
||||
1. Create `TreeCanvas.tsx` with recursive tree rendering (compact cards only, no editing)
|
||||
2. Add CSS connector lines between parent and children
|
||||
3. Add horizontal branching for decision nodes with multiple children
|
||||
4. Add canvas state model (expandedNodeIds, newNodeIds, etc.)
|
||||
5. Wire card expand/collapse with single-expanded-card policy
|
||||
6. Add inline type picker and add-node flow with pending link tracking
|
||||
7. Wire save/cancel with pending link resolution
|
||||
8. Wire delete with inbound reference cleanup
|
||||
9. Add drag-and-drop sibling reorder
|
||||
10. Add selection sync (auto-expand + scroll into view)
|
||||
11. Add grid background pattern
|
||||
12. Style add buttons (dashed border, hover reveal)
|
||||
|
||||
### Verification
|
||||
|
||||
- Create a new tree → see canvas with root START card
|
||||
- Click root → expands inline (no modal)
|
||||
- Add 2 options, save → see branch lanes
|
||||
- Add child from each option → pending link resolves on save
|
||||
- Cancel a new node → node deleted, link cleaned up
|
||||
- Delete a linked node → parent's reference cleared
|
||||
- Drag reorder siblings
|
||||
- Run `npm run build` — no TypeScript errors
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: Form Refactoring + Layout Update
|
||||
|
||||
### 3A: Form Refactoring for Inline Reuse Safety
|
||||
|
||||
**Files to modify:**
|
||||
- `frontend/src/components/tree-editor/NodeFormDecision.tsx`
|
||||
- `frontend/src/components/tree-editor/NodePicker.tsx`
|
||||
|
||||
#### NodeFormDecision.tsx Changes
|
||||
|
||||
**Problem:** Currently, option reordering calls `reorderOptions()` directly on the store. In inline canvas editing, this would write to the store before the user clicks ✓ save (breaking the local draft model).
|
||||
|
||||
**Fix:** Change option reordering to mutate the local `node.options` array through the `onUpdate` callback instead of calling the store directly.
|
||||
|
||||
```typescript
|
||||
// BEFORE (writes to store immediately):
|
||||
const handleReorderOptions = (fromIndex: number, toIndex: number) => {
|
||||
reorderOptions(node.id, fromIndex, toIndex)
|
||||
}
|
||||
|
||||
// AFTER (mutates local draft via onUpdate):
|
||||
const handleReorderOptions = (fromIndex: number, toIndex: number) => {
|
||||
const newOptions = [...(node.options || [])]
|
||||
const [moved] = newOptions.splice(fromIndex, 1)
|
||||
newOptions.splice(toIndex, 0, moved)
|
||||
onUpdate({ options: newOptions })
|
||||
}
|
||||
```
|
||||
|
||||
**Keep modal compatibility:** This change is backward-compatible. In the legacy modal path, `onUpdate` already propagates to the store. In the canvas path, `onUpdate` updates the local draft.
|
||||
|
||||
#### NodePicker.tsx Changes
|
||||
|
||||
**Problem:** `NodePicker` currently has create-new-node options (`__create_decision__`, etc.) that call `addNode()` on the store. In canvas inline editing, this would create nodes as a side effect of browsing the picker during draft editing.
|
||||
|
||||
**Fix:** Add an `allowCreate` prop:
|
||||
|
||||
```typescript
|
||||
interface NodePickerProps {
|
||||
// ... existing props
|
||||
allowCreate?: boolean // default: true
|
||||
}
|
||||
```
|
||||
|
||||
- When `allowCreate={false}`, hide the "Create New" option group
|
||||
- Pass `allowCreate={false}` from `TreeCanvasNode` expanded editing
|
||||
- Pass `allowCreate={true}` (default) in legacy modal path
|
||||
|
||||
### 3B: Layout Update
|
||||
|
||||
**File:** `frontend/src/components/tree-editor/TreeEditorLayout.tsx`
|
||||
|
||||
#### Changes
|
||||
|
||||
```typescript
|
||||
interface TreeEditorLayoutProps {
|
||||
isMobile?: boolean
|
||||
isMetadataOpen: boolean // NEW
|
||||
onCloseMetadata: () => void // NEW
|
||||
}
|
||||
```
|
||||
|
||||
**Flow mode (replace the 60/40 split):**
|
||||
|
||||
```
|
||||
BEFORE:
|
||||
<div className="w-3/5">
|
||||
<TreeMetadataForm />
|
||||
<NodeList />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<TreePreviewPanel />
|
||||
</div>
|
||||
|
||||
AFTER:
|
||||
<TreeCanvas /> (full width)
|
||||
<MetadataSidePanel isOpen={isMetadataOpen} onClose={onCloseMetadata} /> (overlay)
|
||||
```
|
||||
|
||||
**Code mode:** Unchanged. Keep existing 60/40 Monaco + preview behavior.
|
||||
|
||||
### 3C: MetadataSidePanel Component
|
||||
|
||||
**File:** `frontend/src/components/tree-editor/MetadataSidePanel.tsx` (NEW)
|
||||
|
||||
Right-side slide-in panel (320px wide) that wraps `<TreeMetadataForm />`.
|
||||
|
||||
```typescript
|
||||
interface MetadataSidePanelProps {
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
}
|
||||
```
|
||||
|
||||
Behavior:
|
||||
- Slides in from right edge, overlays the canvas (does NOT resize it)
|
||||
- Close triggers: panel close button, backdrop click, Escape key
|
||||
- Uses existing overlay/backdrop pattern from other modals in the app
|
||||
- Only rendered in Flow mode
|
||||
|
||||
### Implementation Steps
|
||||
|
||||
1. Refactor `NodeFormDecision.tsx` — option reorder through `onUpdate`
|
||||
2. Add `allowCreate` prop to `NodePicker.tsx`
|
||||
3. Create `MetadataSidePanel.tsx`
|
||||
4. Update `TreeEditorLayout.tsx` — swap Flow mode layout, add metadata panel props
|
||||
5. Verify legacy modal path still works (if still referenced anywhere)
|
||||
|
||||
### Verification
|
||||
|
||||
- Open tree editor in Flow mode → see full-width canvas (no 60/40 split)
|
||||
- Open tree editor in Code mode → see unchanged Monaco + preview layout
|
||||
- Click Metadata button → panel slides in from right, canvas doesn't resize
|
||||
- Close metadata panel via close button, backdrop click, and Escape key
|
||||
- Edit options in inline card → reorder does NOT write to store until ✓ save
|
||||
- NodePicker in inline card → no "Create New" options shown
|
||||
- Run `npm run build` — no TypeScript errors
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: Toolbar Wiring + Exports
|
||||
|
||||
**File:** `frontend/src/pages/TreeEditorPage.tsx` (modify)
|
||||
|
||||
### Changes
|
||||
|
||||
1. Add local state: `const [isMetadataOpen, setIsMetadataOpen] = useState(false)`
|
||||
2. Add "Metadata" toolbar button — visible in Flow mode only
|
||||
3. Auto-close metadata panel when switching to Code mode:
|
||||
```typescript
|
||||
// In mode switch handler:
|
||||
if (newMode === 'code') setIsMetadataOpen(false)
|
||||
```
|
||||
4. Pass metadata props into `TreeEditorLayout`:
|
||||
```typescript
|
||||
<TreeEditorLayout
|
||||
isMetadataOpen={isMetadataOpen}
|
||||
onCloseMetadata={() => setIsMetadataOpen(false)}
|
||||
/>
|
||||
```
|
||||
5. Keep all existing toolbar actions unchanged: undo/redo, save/publish, validate, analytics
|
||||
|
||||
**File:** `frontend/src/components/tree-editor/index.ts` (modify)
|
||||
|
||||
Add exports:
|
||||
```typescript
|
||||
export { TreeCanvas } from './TreeCanvas'
|
||||
export { TreeCanvasNode } from './TreeCanvasNode'
|
||||
export { MetadataSidePanel } from './MetadataSidePanel'
|
||||
```
|
||||
|
||||
Keep all legacy exports (`NodeList`, `NodeEditorModal`, `TreePreviewPanel`) — they remain in the codebase but are no longer imported in the active Flow path.
|
||||
|
||||
### Implementation Steps
|
||||
|
||||
1. Add metadata panel state and toolbar button to `TreeEditorPage.tsx`
|
||||
2. Add auto-close on Code mode switch
|
||||
3. Pass props through to `TreeEditorLayout`
|
||||
4. Update `index.ts` exports
|
||||
5. Verify no dead imports remain
|
||||
|
||||
### Verification
|
||||
|
||||
- Flow mode toolbar shows "Metadata" button
|
||||
- Code mode toolbar does NOT show "Metadata" button
|
||||
- Click Metadata → panel opens. Switch to Code → panel auto-closes
|
||||
- Undo/redo/save/publish/validate all still work
|
||||
- Run `npm run build` — no TypeScript errors
|
||||
|
||||
---
|
||||
|
||||
## Critical Files Summary
|
||||
|
||||
| File | Action | Phase | Notes |
|
||||
|------|--------|-------|-------|
|
||||
| `TreeCanvasNode.tsx` | Create | 1 | Inline-editing card with local draft + commit model |
|
||||
| `TreeCanvas.tsx` | Create | 2 | Main canvas orchestration with full state model |
|
||||
| `MetadataSidePanel.tsx` | Create | 3 | 320px right slide-in overlay wrapping TreeMetadataForm |
|
||||
| `NodeFormDecision.tsx` | Refactor | 3 | Option reorder through onUpdate (remove store write) |
|
||||
| `NodePicker.tsx` | Refactor | 3 | Add `allowCreate` prop (default true) |
|
||||
| `TreeEditorLayout.tsx` | Modify | 3 | Replace 60/40 split with full-width canvas + overlay |
|
||||
| `TreeEditorPage.tsx` | Modify | 4 | Add metadata panel toggle, auto-close on Code switch |
|
||||
| `index.ts` | Update | 4 | Export new components, keep legacy exports |
|
||||
| `treeEditorStore.ts` | No changes | — | Store logic is solid as-is |
|
||||
| `NodeList.tsx` | Keep (legacy) | — | Removed from active Flow path |
|
||||
| `NodeEditorModal.tsx` | Keep (legacy) | — | Removed from active Flow path |
|
||||
| `TreePreviewPanel.tsx` | Keep (legacy) | — | Removed from active Flow path |
|
||||
|
||||
---
|
||||
|
||||
## Assumptions & Defaults
|
||||
|
||||
- **Link rendering:** Tree-first (no full cross-canvas graph lines for shared targets)
|
||||
- **Inline edit commit:** On checkmark save (local draft, cancel discards)
|
||||
- **Metadata drawer:** Flow mode only
|
||||
- **Expanded card policy:** One expanded card at a time
|
||||
- **Legacy components:** Remain in repo, removed from active Flow path only
|
||||
- **Connector lines:** CSS borders (not SVG — simpler, matches existing patterns)
|
||||
|
||||
---
|
||||
|
||||
## Testing
|
||||
|
||||
### Automated Tests
|
||||
|
||||
**Files:** `TreeCanvas.test.tsx`, `TreeCanvasNode.test.tsx`
|
||||
|
||||
| Test Case | What It Verifies |
|
||||
|-----------|-----------------|
|
||||
| Select node expands and scrolls card | Selection sync works |
|
||||
| Inline save commits store updates | Draft → store pipeline works |
|
||||
| Cancel on new node triggers deletion | Unsaved node cleanup works |
|
||||
| Add child from decision option sets `next_node_id` on save | Pending link resolution works |
|
||||
| Delete node clears inbound references | Reference cleanup works |
|
||||
| Sibling drag reorder calls `reorderNodes` | Drag-and-drop wiring works |
|
||||
| `allowCreate={false}` hides create options in NodePicker | Form safety works |
|
||||
| Option reorder in inline card does NOT write to store | Draft isolation works |
|
||||
|
||||
Run: `npm run build && npm run test` (or targeted vitest files)
|
||||
|
||||
### Manual Acceptance Checklist
|
||||
|
||||
- [ ] Create a new tree — canvas shows root START card
|
||||
- [ ] Click root card — expands inline with decision fields (no modal appears)
|
||||
- [ ] Fill in question, add 2 options, click ✓ — saves inline, see branch lanes
|
||||
- [ ] `+ Add child` buttons appear below each option
|
||||
- [ ] Add a child node inline — verify parent link is set correctly
|
||||
- [ ] Cancel a new unsaved node — confirm it is deleted from the tree
|
||||
- [ ] Delete a node that is referenced by another — confirm references are cleaned
|
||||
- [ ] Drag to reorder sibling nodes
|
||||
- [ ] Open Metadata panel — edit metadata — close panel (button, backdrop, Escape)
|
||||
- [ ] Validate and Publish — confirm tree saves correctly
|
||||
- [ ] Switch Flow → Code mode — metadata panel auto-closes, Code mode works normally
|
||||
- [ ] Switch Code → Flow mode — canvas renders correctly
|
||||
- [ ] Run `npm run build` — no TypeScript errors
|
||||
- [ ] Run `npm run test` — all tests pass
|
||||
|
||||
---
|
||||
|
||||
## Git Strategy
|
||||
|
||||
- Branch: `feature/tree-editor-canvas`
|
||||
- One commit per phase (4 commits total)
|
||||
- Commit messages:
|
||||
1. `feat: Add TreeCanvasNode inline editor card component`
|
||||
2. `feat: Add TreeCanvas layout with visual branching and orchestration`
|
||||
3. `refactor: Update forms for inline safety, add MetadataSidePanel, update layout`
|
||||
4. `feat: Wire toolbar metadata toggle and update exports`
|
||||
- PR when all 4 phases pass `npm run build && npm run test`
|
||||
- Include `Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>`
|
||||
|
||||
---
|
||||
|
||||
## Notes for Implementation
|
||||
|
||||
1. **Read existing code first:** Before creating any new file, read the files listed in the Reusable Code Inventory to understand current patterns and prop interfaces
|
||||
2. **Follow existing patterns:** Match the component structure, Tailwind usage, and TypeScript conventions already in the tree-editor directory
|
||||
3. **Dark mode:** All new components must support light/dark themes via existing Tailwind classes
|
||||
4. **Keyboard navigation:** Support Escape to close metadata panel, Tab through form fields in expanded cards
|
||||
5. **Loading states:** Canvas should handle the case where `treeStructure` is null (show empty state)
|
||||
6. **No store changes:** The `treeEditorStore.ts` should NOT be modified. All new state is local to the canvas components
|
||||
7. **Test each phase independently:** Each phase should leave the app in a buildable, testable state before moving to the next
|
||||
745
docs/plans/MASTER-PLAN-editor-ux-fixes.md
Normal file
745
docs/plans/MASTER-PLAN-editor-ux-fixes.md
Normal file
@@ -0,0 +1,745 @@
|
||||
# Master Plan: Flow Editor UX Fixes + Answer Stub Placeholders
|
||||
|
||||
> **For Claude Code:** Implement this plan task-by-task in order. Each phase must build and pass tests before proceeding to the next. Commit after each task.
|
||||
>
|
||||
> **Working directory:** Use the active tree-editor-canvas worktree or main branch as appropriate.
|
||||
|
||||
---
|
||||
|
||||
## Plan Overview
|
||||
|
||||
This plan fixes three UX pain points in the tree editor:
|
||||
|
||||
1. **Can't reach bottom of editor** — scrollable content + optional fullscreen toggle
|
||||
2. **Form clutter** — replace always-visible hint paragraphs with info-on-demand tooltips
|
||||
3. **Forced child-type selection slows branching** — introduce `'answer'` placeholder stubs so users can name branches first and pick types later
|
||||
|
||||
---
|
||||
|
||||
## Plan Comparison Notes
|
||||
|
||||
This master plan was synthesized from two candidate plans. Here's what was chosen and why:
|
||||
|
||||
| Area | Plan 1 (Strategy Doc) | Plan 2 (Canvas Implementation) | Master Plan Choice | Rationale |
|
||||
|------|----------------------|-------------------------------|-------------------|-----------|
|
||||
| **Scroll fix** | Modal-level `allowFullScreen` prop on `Modal.tsx` with localStorage persistence | Canvas-level CSS fix: `max-h-[70vh] overflow-y-auto` + sticky header on `TreeCanvasNode.tsx` | **Both** — Canvas CSS fix for inline cards AND Modal fullscreen for modal editor | They fix different surfaces. The canvas inline editor and the modal editor are separate code paths. Both need the fix. |
|
||||
| **Fullscreen toggle** | `Maximize2`/`Minimize2` icons, `allowFullScreen` opt-in prop, localStorage persistence | Not included | **Include** (Plan 1) | Fullscreen editing is a meaningful UX upgrade for complex nodes. The opt-in prop pattern keeps other modals unaffected. |
|
||||
| **Info tooltips** | Conceptual — mentions `FieldHelp.tsx` helper component + "Show tips" toggle | Line-by-line implementation — native `title` attribute on inline ⓘ badge spans | **Plan 2's inline approach, but extract to a reusable component** | Plan 2's approach is concrete and proven. But repeating the same 4-line `<span>` everywhere creates maintenance debt. Extract to a tiny `<InfoTip text="..." />` component, then use it everywhere. Skip the "Show tips" toggle — it adds complexity without clear user value. |
|
||||
| **Placeholder node naming** | Calls it `'choice'` | Calls it `'answer'` | **`'answer'`** | In a troubleshooting tree, decision options ARE answers to the question. "Choice" is ambiguous — it could mean the decision itself. "Answer" is intuitive: "What type of device?" → answers: "Server", "Desktop", "Laptop". |
|
||||
| **Answer stub creation** | Manual — user clicks "Create Placeholder" per option | Automatic — saving a decision node auto-creates stubs for any option without a `next_node_id` | **Automatic** (Plan 2) | Automatic creation is faster and requires zero extra clicks. The whole point of stubs is reducing friction. Making users manually create them defeats the purpose. |
|
||||
| **Answer stub UI** | Conversion via node editor modal (Convert to Decision/Action/Solution buttons) | Dedicated `AnswerStubCard` component — click card → inline type picker with color-coded buttons | **`AnswerStubCard`** (Plan 2) | A dedicated visual component with dashed border and inline type picker is more discoverable and faster than opening a modal just to convert. Users see the stub, click it, pick a type — done in one interaction. |
|
||||
| **NodePicker removal** | Keeps NodePicker, adds choice creation alongside it | Removes NodePicker from decision form entirely — options become label-only inputs | **Remove NodePicker** (Plan 2) | This is the key UX insight. The old flow forced users to pick a child type while still writing the question. The new flow: write your question → name your answers → save → stubs appear → convert each stub when ready. This matches how humans actually think about branching. |
|
||||
| **Publish validation** | Backend `can_publish_tree` check + frontend disabled publish button | Backend `validate_tree_structure` check + frontend `hasAnswerNodes` guard with toast message | **Both layers** (combined) | Defense in depth. Frontend gives instant feedback via toast. Backend prevents bad data regardless of client. |
|
||||
| **Markdown parser/code mode** | Explicitly handles `answer` in markdown parser, validator, and serializer | Not addressed | **Include** (Plan 1) | Important for data integrity. If a user switches to code/markdown mode, answer nodes shouldn't get silently dropped or cause parse errors. |
|
||||
| **Runtime defensive guard** | Includes guard in session navigation — if `answer` encountered at runtime, show blocking message | Not addressed | **Include** (Plan 1) | Published trees should never have answer nodes, but defensive programming matters. A clear "this tree has unresolved placeholders" message is better than a crash. |
|
||||
| **Testing plan** | Comprehensive list of frontend + backend + manual test scenarios | Build verification per task + final manual checklist | **Plan 1's scope with Plan 2's per-task verification** | Plan 1 defines what to test; Plan 2's approach of verifying builds after every task catches issues early. |
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: Scrollability + Fullscreen Editor
|
||||
|
||||
### Task 1.1: Fix canvas inline card scroll (TreeCanvasNode)
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/src/components/tree-editor/TreeCanvasNode.tsx`
|
||||
|
||||
**Changes:**
|
||||
|
||||
1. Make the card header sticky when expanded. Find the header `<div>` (the one with `flex items-center gap-2 px-3 py-2.5`). Add conditional sticky classes:
|
||||
```tsx
|
||||
isExpanded && 'sticky top-0 z-10 bg-card rounded-t-xl'
|
||||
```
|
||||
|
||||
2. Make the expanded editing area scrollable. Find the expanded content `<div>` (the one with `border-t border-border px-3 pb-3 pt-3`). Add max height and scroll:
|
||||
```tsx
|
||||
className="border-t border-border px-3 pb-3 pt-3 max-h-[70vh] overflow-y-auto"
|
||||
```
|
||||
|
||||
**Verify:** `npm run build` — clean build, no errors.
|
||||
|
||||
**Commit:** `fix: make canvas card expanded area scrollable with sticky header`
|
||||
|
||||
---
|
||||
|
||||
### Task 1.2: Add fullscreen toggle to Modal component
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/src/components/common/Modal.tsx`
|
||||
- Modify: `frontend/src/components/tree-editor/NodeEditorModal.tsx`
|
||||
|
||||
**Changes to Modal.tsx:**
|
||||
|
||||
1. Add new optional prop: `allowFullScreen?: boolean` (default `false`).
|
||||
|
||||
2. Add state inside Modal:
|
||||
```tsx
|
||||
const [isFullScreen, setIsFullScreen] = useState(() => {
|
||||
if (!allowFullScreen) return false
|
||||
try {
|
||||
return localStorage.getItem('rf-editor-fullscreen') === 'true'
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
3. Persist preference on toggle:
|
||||
```tsx
|
||||
const toggleFullScreen = () => {
|
||||
const next = !isFullScreen
|
||||
setIsFullScreen(next)
|
||||
try {
|
||||
localStorage.setItem('rf-editor-fullscreen', String(next))
|
||||
} catch {}
|
||||
}
|
||||
```
|
||||
|
||||
4. Add `Maximize2` and `Minimize2` imports from `lucide-react`.
|
||||
|
||||
5. Render expand/collapse button in the modal header (next to the close button) only when `allowFullScreen` is `true`:
|
||||
```tsx
|
||||
{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>
|
||||
)}
|
||||
```
|
||||
|
||||
6. Apply conditional sizing classes on the modal container:
|
||||
- Default: existing size classes (whatever `size="lg"` currently maps to, e.g. `max-w-2xl`)
|
||||
- Full screen: `fixed inset-4 max-w-none w-auto h-auto` (fills viewport with small margin)
|
||||
- Add `transition-all duration-200` for smooth animation between modes.
|
||||
- The modal body must remain `overflow-y-auto` in both modes.
|
||||
|
||||
**Changes to NodeEditorModal.tsx:**
|
||||
|
||||
Pass the new prop to Modal:
|
||||
```tsx
|
||||
<Modal isOpen={true} onClose={onClose} title={getTitle()} size="lg" footer={footerContent} allowFullScreen={true}>
|
||||
```
|
||||
|
||||
**Do NOT change:** Any other modal usage in the app. Only NodeEditorModal opts in.
|
||||
|
||||
**Verify:** `npm run build` — clean build.
|
||||
|
||||
**Commit:** `feat: add fullscreen toggle to Modal component, enable in NodeEditorModal`
|
||||
|
||||
---
|
||||
|
||||
### Task 1.3: Verify scroll contract across both editor surfaces
|
||||
|
||||
**Manual verification checklist:**
|
||||
- [ ] Open a decision node in canvas inline editor → resize browser to short viewport → form scrolls, sticky header (save/cancel) stays visible
|
||||
- [ ] Open a node in the modal editor → content scrolls, header/footer fixed
|
||||
- [ ] Click fullscreen toggle → modal fills viewport with margin → content still scrolls
|
||||
- [ ] Click collapse → returns to normal size smoothly
|
||||
- [ ] Refresh page → fullscreen preference persisted
|
||||
- [ ] Other modals (StepDetailModal, CustomStepModal, etc.) are unaffected
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Info-On-Demand Tooltips
|
||||
|
||||
### Task 2.0: Create reusable InfoTip component
|
||||
|
||||
**Files:**
|
||||
- Create: `frontend/src/components/common/InfoTip.tsx`
|
||||
|
||||
**Content:**
|
||||
```tsx
|
||||
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>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
This is a tiny component but it prevents repeating the same 4-line span pattern in every form file. Import it as `import { InfoTip } from '@/components/common/InfoTip'`.
|
||||
|
||||
**Verify:** `npm run build` — clean build.
|
||||
|
||||
**Commit:** `feat: add reusable InfoTip component for field-level help`
|
||||
|
||||
---
|
||||
|
||||
### Task 2.1: Replace hint text in NodeFormDecision
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/src/components/tree-editor/NodeFormDecision.tsx`
|
||||
|
||||
**Changes:**
|
||||
|
||||
1. Import `InfoTip` from `@/components/common/InfoTip`.
|
||||
|
||||
2. Remove the root node hint `<p>` block ("What's the main question to diagnose the issue?") — the input placeholder already conveys this.
|
||||
|
||||
3. Replace the options hint `<p>` paragraphs (both root and non-root variants) with an `<InfoTip>` on the label:
|
||||
```tsx
|
||||
<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>
|
||||
```
|
||||
|
||||
4. Keep all required markers (`*`) and field-level validation error messages visible — only remove the instructional paragraphs.
|
||||
|
||||
**Verify:** `npm run build` — clean build.
|
||||
|
||||
**Commit:** `fix: replace hint paragraphs with info tooltips in NodeFormDecision`
|
||||
|
||||
---
|
||||
|
||||
### Task 2.2: Replace hint text in NodeFormAction
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/src/components/tree-editor/NodeFormAction.tsx`
|
||||
|
||||
**Changes:**
|
||||
|
||||
1. Import `InfoTip`.
|
||||
|
||||
2. Description field — replace the markdown hint `<p>` with InfoTip on the label:
|
||||
```tsx
|
||||
<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>
|
||||
```
|
||||
|
||||
3. Commands field — replace the hint `<p>` with InfoTip on the label:
|
||||
```tsx
|
||||
<label className="flex items-center gap-1.5 text-sm font-medium text-foreground">
|
||||
Commands
|
||||
<InfoTip text="PowerShell or CLI commands to execute" />
|
||||
</label>
|
||||
```
|
||||
|
||||
**Verify:** `npm run build` — clean build.
|
||||
|
||||
**Commit:** `fix: replace hint paragraphs with info tooltips in NodeFormAction`
|
||||
|
||||
---
|
||||
|
||||
### Task 2.3: Replace hint text in NodeFormResolution
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/src/components/tree-editor/NodeFormResolution.tsx`
|
||||
|
||||
**Changes:**
|
||||
|
||||
1. Import `InfoTip`.
|
||||
|
||||
2. Description field — replace the markdown hint `<p>` with InfoTip on the label (same pattern as NodeFormAction).
|
||||
|
||||
3. Resolution Steps field — replace the hint `<p>` with InfoTip:
|
||||
```tsx
|
||||
<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>
|
||||
```
|
||||
|
||||
**Verify:** `npm run build` — clean build.
|
||||
|
||||
**Commit:** `fix: replace hint paragraphs with info tooltips in NodeFormResolution`
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: Answer Stub Placeholder System
|
||||
|
||||
### Task 3.1: Add `'answer'` to the NodeType union
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/src/types/tree.ts`
|
||||
|
||||
**Change:**
|
||||
```typescript
|
||||
// Before
|
||||
export type NodeType = 'decision' | 'action' | 'solution'
|
||||
|
||||
// After
|
||||
export type NodeType = 'decision' | 'action' | 'solution' | 'answer'
|
||||
```
|
||||
|
||||
**Note:** This will cause a TypeScript error in `TreeCanvasNode.tsx` because `NODE_TYPE_CONFIG` doesn't have an `'answer'` key. That's expected and fixed in Task 3.3.
|
||||
|
||||
**Verify:** `npm run build` — note the expected error, proceed.
|
||||
|
||||
**Commit:** `feat: add 'answer' to NodeType union for branch placeholder stubs`
|
||||
|
||||
---
|
||||
|
||||
### Task 3.2: Create the AnswerStubCard component
|
||||
|
||||
**Files:**
|
||||
- Create: `frontend/src/components/tree-editor/AnswerStubCard.tsx`
|
||||
|
||||
**Content:**
|
||||
```tsx
|
||||
import { useState } from 'react'
|
||||
import { HelpCircle, Zap, CheckCircle } 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
|
||||
}
|
||||
|
||||
export function AnswerStubCard({ node, fromOption, onSelectType }: AnswerStubCardProps) {
|
||||
const [picking, setPicking] = useState(false)
|
||||
const label = fromOption || node.title || 'Answer'
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'min-w-[180px] max-w-[280px] rounded-xl border-2 border-dashed border-border bg-card/50',
|
||||
'transition-all duration-150',
|
||||
!picking && 'cursor-pointer hover:border-primary/40 hover:bg-accent/30'
|
||||
)}
|
||||
onClick={() => !picking && setPicking(true)}
|
||||
>
|
||||
{/* Label */}
|
||||
<div className="px-3 pt-2.5 pb-1 text-sm font-heading font-medium text-foreground text-center">
|
||||
{label}
|
||||
</div>
|
||||
|
||||
{/* Prompt / type picker */}
|
||||
{!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
|
||||
```
|
||||
|
||||
**Design rationale:** Dashed border visually distinguishes stubs from real nodes. Color-coded type buttons match the existing node type color scheme. Single-click interaction (click card → pick type) is the fastest possible conversion flow.
|
||||
|
||||
**Verify:** `npm run build` — no errors mentioning AnswerStubCard.
|
||||
|
||||
**Commit:** `feat: add AnswerStubCard component for unresolved branch placeholders`
|
||||
|
||||
---
|
||||
|
||||
### Task 3.3: Guard TreeCanvasNode against `'answer'` type
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/src/components/tree-editor/TreeCanvasNode.tsx`
|
||||
|
||||
**Change:** Guard the `NODE_TYPE_CONFIG` lookup so `'answer'` doesn't crash:
|
||||
|
||||
```tsx
|
||||
// Before
|
||||
const config = NODE_TYPE_CONFIG[node.type]
|
||||
|
||||
// After
|
||||
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 instead)
|
||||
```
|
||||
|
||||
**Note:** Answer nodes should never be rendered by TreeCanvasNode — TreeCanvas routes them to AnswerStubCard. This is a safety fallback only.
|
||||
|
||||
**Verify:** `npm run build` — the TypeScript error from Task 3.1 should now be resolved. Clean build.
|
||||
|
||||
**Commit:** `fix: guard NODE_TYPE_CONFIG lookup against 'answer' type`
|
||||
|
||||
---
|
||||
|
||||
### Task 3.4: Redesign NodeFormDecision — label-only options (remove NodePicker)
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/src/components/tree-editor/NodeFormDecision.tsx`
|
||||
|
||||
**This is the biggest UX change in the plan.** The old flow forced users to pick a child node type for each option while still writing the decision question. The new flow lets them just name their answers — stub nodes are created automatically on save.
|
||||
|
||||
**Changes:**
|
||||
|
||||
1. Remove the `NodePicker` import — it's no longer used in this form.
|
||||
|
||||
2. Replace the `DynamicArrayField` `renderItem` for options. The new renderItem shows only a letter badge + label text input per option. No NodePicker, no next_node_id selector:
|
||||
```tsx
|
||||
renderItem={(option, index) => {
|
||||
const optionLabelError = validationErrors.find(
|
||||
e => e.nodeId === node.id && e.field === `options[${index}].label`
|
||||
)
|
||||
const letter = indexToLetter(index)
|
||||
|
||||
return (
|
||||
<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>
|
||||
<input
|
||||
type="text"
|
||||
value={option.label}
|
||||
onChange={(e) => handleUpdateOption(index, { label: e.target.value })}
|
||||
placeholder={isRootNode
|
||||
? `Branch ${letter}: e.g., "Network Issues"...`
|
||||
: `Option ${letter} label`}
|
||||
className={cn(
|
||||
'block flex-1 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'
|
||||
)}
|
||||
/>
|
||||
{optionLabelError && (
|
||||
<p className="mt-1 text-xs text-red-400">{optionLabelError.message}</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
```
|
||||
|
||||
3. Remove the `optionNextError` validation lookup (no longer displayed since NodePicker is gone).
|
||||
|
||||
4. Remove the old `<div className="rounded-md border border-border bg-accent/50 p-3">` wrapper from the old renderItem if present — the new renderItem renders flat rows.
|
||||
|
||||
**Verify:** `npm run build` — clean build. Ensure no unused `NodePicker` import warnings.
|
||||
|
||||
**Commit:** `feat: redesign NodeFormDecision to label-only options (remove NodePicker)`
|
||||
|
||||
---
|
||||
|
||||
### Task 3.5: Wire up auto-creation and rendering in TreeCanvas
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/src/components/tree-editor/TreeCanvas.tsx`
|
||||
|
||||
**Changes:**
|
||||
|
||||
1. Import `AnswerStubCard`:
|
||||
```tsx
|
||||
import { AnswerStubCard } from './AnswerStubCard'
|
||||
```
|
||||
|
||||
2. Add `handleSelectAnswerType` callback (converts answer stub to a real type):
|
||||
```tsx
|
||||
const handleSelectAnswerType = useCallback(
|
||||
(nodeId: string, type: 'decision' | 'action' | 'solution') => {
|
||||
updateNode(nodeId, { type })
|
||||
setExpandedNodeId(nodeId)
|
||||
selectNode(nodeId)
|
||||
},
|
||||
[updateNode, selectNode]
|
||||
)
|
||||
```
|
||||
|
||||
3. Update `handleSave` — after `updateNode(nodeId, updates)`, auto-create answer stubs for any decision option that has a label but no `next_node_id`:
|
||||
```tsx
|
||||
if (updates.type === 'decision' || updates.options) {
|
||||
const options = updates.options || []
|
||||
options.forEach((opt) => {
|
||||
if (!opt.next_node_id && opt.label.trim()) {
|
||||
const stubId = addNode(nodeId, 'answer')
|
||||
updateNode(stubId, { title: opt.label })
|
||||
const updatedOptions = options.map((o) =>
|
||||
o.id === opt.id ? { ...o, next_node_id: stubId } : o
|
||||
)
|
||||
updateNode(nodeId, { options: updatedOptions })
|
||||
}
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
4. Add `handleSelectAnswerType` to the `renderNode` `useCallback` dependency array.
|
||||
|
||||
5. In `renderNode`, conditionally render `AnswerStubCard` for answer-type nodes instead of `TreeCanvasNode`:
|
||||
```tsx
|
||||
{node.type === 'answer' ? (
|
||||
<AnswerStubCard
|
||||
node={node}
|
||||
fromOption={optionLabel}
|
||||
onSelectType={handleSelectAnswerType}
|
||||
/>
|
||||
) : (
|
||||
<TreeCanvasNode ... />
|
||||
)}
|
||||
```
|
||||
|
||||
**Verify:** `npm run build` — clean build.
|
||||
|
||||
**Commit:** `feat: auto-create answer stubs on decision save, render AnswerStubCard`
|
||||
|
||||
---
|
||||
|
||||
### Task 3.6: Guard NodeList against `'answer'` type (list editor compatibility)
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/src/components/tree-editor/NodeList.tsx`
|
||||
|
||||
**Changes:**
|
||||
|
||||
The `nodeTypeIcons` and `nodeTypeColors` Record types in `NodeListItem` only have keys for `decision`, `action`, `solution`. Add `answer`:
|
||||
|
||||
```tsx
|
||||
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" />,
|
||||
answer: <HelpCircle className="h-4 w-4 opacity-50" />
|
||||
}
|
||||
|
||||
const nodeTypeColors: Record<NodeType, string> = {
|
||||
decision: 'bg-blue-500/20 text-blue-600 dark:text-blue-400',
|
||||
action: 'bg-yellow-500/20 text-yellow-600 dark:text-yellow-400',
|
||||
solution: 'bg-green-500/20 text-green-600 dark:text-green-400',
|
||||
answer: 'bg-muted text-muted-foreground border border-dashed border-border'
|
||||
}
|
||||
```
|
||||
|
||||
**Verify:** `npm run build` — clean build.
|
||||
|
||||
**Commit:** `fix: add answer type to NodeList icon and color maps`
|
||||
|
||||
---
|
||||
|
||||
## Phase 4: Validation + Backend Safety
|
||||
|
||||
### Task 4.1: Backend — allow `'answer'` in drafts, block on publish
|
||||
|
||||
**Files:**
|
||||
- Modify: `backend/app/core/tree_validation.py`
|
||||
|
||||
**Changes:**
|
||||
|
||||
1. In `_validate_node`, add an `elif` for `'answer'` before the `else` (unknown type) branch:
|
||||
```python
|
||||
elif node_type == "answer":
|
||||
# Answer nodes are draft-only placeholders — no structural validation needed
|
||||
pass
|
||||
```
|
||||
|
||||
2. Add a recursive helper function:
|
||||
```python
|
||||
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
|
||||
```
|
||||
|
||||
3. In `validate_tree_structure`, after the recursive `_validate_children` call and before the return, add:
|
||||
```python
|
||||
# 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."
|
||||
})
|
||||
```
|
||||
|
||||
**Verify:** Run backend tests — `pytest --override-ini="addopts=" -q` — all tests pass.
|
||||
|
||||
**Commit:** `feat: allow 'answer' type in tree drafts, block on publish`
|
||||
|
||||
---
|
||||
|
||||
### Task 4.2: Frontend — publish guard with toast message
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/src/pages/TreeEditorPage.tsx`
|
||||
|
||||
**Changes:**
|
||||
|
||||
1. Add utility function before the component:
|
||||
```typescript
|
||||
function hasAnswerNodes(node: TreeStructure): boolean {
|
||||
if (node.type === 'answer') return true
|
||||
return (node.children || []).some(hasAnswerNodes)
|
||||
}
|
||||
```
|
||||
|
||||
2. In `handlePublish`, after the tree name check and before `validate()`, add:
|
||||
```typescript
|
||||
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
|
||||
}
|
||||
```
|
||||
|
||||
**Verify:** `npm run build` — clean build.
|
||||
|
||||
**Commit:** `feat: block publish if unresolved answer stub nodes exist`
|
||||
|
||||
---
|
||||
|
||||
### Task 4.3: Markdown parser/serializer compatibility
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/src/utils/treeMarkdownSync.ts` (or wherever markdown sync lives)
|
||||
- Modify: `backend/app/core/tree_markdown_parser.py` (if exists)
|
||||
- Modify: `backend/app/core/tree_markdown_validator.py` (if exists)
|
||||
|
||||
**Changes:**
|
||||
|
||||
Ensure the markdown serializer and parser handle `type: 'answer'` gracefully:
|
||||
|
||||
1. **Serializer** (`treeStructureToMarkdownPreview` or equivalent): Serialize answer nodes with a clear marker, e.g.:
|
||||
```markdown
|
||||
### [ANSWER PLACEHOLDER] Server
|
||||
> This is an unresolved answer stub. Convert it to a Decision, Action, or Solution before publishing.
|
||||
```
|
||||
|
||||
2. **Parser**: Accept `type: answer` in parsed markdown without errors. Map it back to a node with `type: 'answer'`.
|
||||
|
||||
3. **Validator**: If a markdown validator exists, treat `answer` nodes as a publish-blocking warning (same rule as the structural validator).
|
||||
|
||||
**Note:** If these files don't exist yet, skip this task — the backend structural validation in Task 4.1 is the primary safety net.
|
||||
|
||||
**Verify:** `npm run build` + backend tests pass.
|
||||
|
||||
**Commit:** `feat: handle 'answer' type in markdown parser/serializer`
|
||||
|
||||
---
|
||||
|
||||
### Task 4.4: Runtime defensive guard in session navigation
|
||||
|
||||
**Files:**
|
||||
- Modify: `frontend/src/pages/TreeNavigationPage.tsx`
|
||||
|
||||
**Changes:**
|
||||
|
||||
In the session player's node rendering logic, add a guard for `answer` type nodes. If the current node has `type === 'answer'`, display a blocking message instead of the normal node UI:
|
||||
|
||||
```tsx
|
||||
{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-600 dark:text-yellow-400">
|
||||
This tree contains an unresolved placeholder node. Please contact the tree author to complete it before use.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
```
|
||||
|
||||
**Rationale:** Published trees should never have answer nodes (blocked by validation), but this guard prevents crashes if data is somehow inconsistent. It shows a clear, non-technical message.
|
||||
|
||||
**Verify:** `npm run build` — clean build.
|
||||
|
||||
**Commit:** `fix: add defensive guard for answer nodes in session navigation`
|
||||
|
||||
---
|
||||
|
||||
## Phase 5: Final Verification
|
||||
|
||||
### Task 5.1: Full build and test suite
|
||||
|
||||
```bash
|
||||
# Frontend
|
||||
cd frontend && npm run build
|
||||
|
||||
# Backend
|
||||
cd backend && pytest --override-ini="addopts=" -q
|
||||
```
|
||||
|
||||
Both must pass with zero errors.
|
||||
|
||||
### Task 5.2: Manual test checklist
|
||||
|
||||
1. [ ] Open a decision node in canvas editor → card expands → resize browser short → form scrolls, header stays sticky
|
||||
2. [ ] Open a node via modal editor → content scrolls → header/footer fixed
|
||||
3. [ ] Click fullscreen toggle in modal → fills viewport → click again → returns to normal → preference persists on refresh
|
||||
4. [ ] Other modals (step library, custom step, etc.) have NO fullscreen button
|
||||
5. [ ] Hover ⓘ badges on all form fields → tooltip text appears → no always-visible hint paragraphs remain
|
||||
6. [ ] Create a new decision node → type question → type answer labels ("Server", "Desktop") → save
|
||||
7. [ ] Two dashed stub cards appear below the decision node
|
||||
8. [ ] Click "Server" stub → three type buttons appear (Decision / Action / Solution)
|
||||
9. [ ] Click "Action" → stub converts to Action card in expanded editing mode
|
||||
10. [ ] Save draft → succeeds (answer stubs allowed in drafts)
|
||||
11. [ ] Leave an unresolved stub → click Publish → blocked with toast: "Resolve all answer placeholders before publishing."
|
||||
12. [ ] Convert all stubs → Publish → succeeds
|
||||
13. [ ] `npm run build` passes with zero TypeScript errors
|
||||
14. [ ] All backend tests pass
|
||||
|
||||
---
|
||||
|
||||
## Summary of Files Changed
|
||||
|
||||
### New Files
|
||||
| File | Description |
|
||||
|------|-------------|
|
||||
| `frontend/src/components/common/InfoTip.tsx` | Reusable info tooltip badge component |
|
||||
| `frontend/src/components/tree-editor/AnswerStubCard.tsx` | Visual stub card with inline type picker |
|
||||
|
||||
### Modified Files
|
||||
| File | Changes |
|
||||
|------|---------|
|
||||
| `frontend/src/components/tree-editor/TreeCanvasNode.tsx` | Sticky header + scrollable expanded area + answer type guard |
|
||||
| `frontend/src/components/common/Modal.tsx` | `allowFullScreen` prop + expand/collapse toggle + localStorage persistence |
|
||||
| `frontend/src/components/tree-editor/NodeEditorModal.tsx` | Pass `allowFullScreen={true}` |
|
||||
| `frontend/src/components/tree-editor/NodeFormDecision.tsx` | InfoTip tooltips + label-only options (NodePicker removed) |
|
||||
| `frontend/src/components/tree-editor/NodeFormAction.tsx` | InfoTip tooltips |
|
||||
| `frontend/src/components/tree-editor/NodeFormResolution.tsx` | InfoTip tooltips |
|
||||
| `frontend/src/types/tree.ts` | Add `'answer'` to NodeType union |
|
||||
| `frontend/src/components/tree-editor/TreeCanvas.tsx` | Auto-create stubs + render AnswerStubCard + handleSelectAnswerType |
|
||||
| `frontend/src/components/tree-editor/NodeList.tsx` | Add answer type to icon/color maps |
|
||||
| `frontend/src/pages/TreeEditorPage.tsx` | Publish guard with hasAnswerNodes check |
|
||||
| `frontend/src/pages/TreeNavigationPage.tsx` | Runtime defensive guard for answer nodes |
|
||||
| `backend/app/core/tree_validation.py` | Allow answer in drafts, block on publish |
|
||||
| `frontend/src/utils/treeMarkdownSync.ts` | Handle answer type in serializer (if exists) |
|
||||
|
||||
### No REST API Changes Required
|
||||
The tree structure is stored as JSONB — the `answer` type flows through existing create/update endpoints without schema changes. Only the validation layer needs to know about it.
|
||||
Reference in New Issue
Block a user