feat: add NodeEditorPanel side panel for React Flow canvas editing
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
264
frontend/src/components/tree-editor/NodeEditorPanel.tsx
Normal file
264
frontend/src/components/tree-editor/NodeEditorPanel.tsx
Normal file
@@ -0,0 +1,264 @@
|
||||
import { useState, useCallback, useEffect, useRef } from 'react'
|
||||
import { HelpCircle, Zap, CheckCircle, X, Trash2, Copy, Save } from 'lucide-react'
|
||||
import { useTreeEditorStore, findNodeInTree } from '@/store/treeEditorStore'
|
||||
import { NodeFormDecision } from './NodeFormDecision'
|
||||
import { NodeFormAction } from './NodeFormAction'
|
||||
import { NodeFormResolution } from './NodeFormResolution'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { TreeStructure, NodeType } from '@/types'
|
||||
|
||||
interface NodeEditorPanelProps {
|
||||
nodeId: string
|
||||
onClose: () => void
|
||||
onSelectType?: (nodeId: string, type: 'decision' | 'action' | 'solution') => void
|
||||
}
|
||||
|
||||
const TYPE_CONFIG: Record<Exclude<NodeType, 'answer'>, { icon: typeof HelpCircle; label: string; badgeClass: string }> = {
|
||||
decision: { icon: HelpCircle, label: 'Decision', badgeClass: 'bg-blue-500/20 text-blue-400' },
|
||||
action: { icon: Zap, label: 'Action', badgeClass: 'bg-yellow-500/20 text-yellow-400' },
|
||||
solution: { icon: CheckCircle, label: 'Solution', badgeClass: 'bg-green-500/20 text-green-400' },
|
||||
}
|
||||
|
||||
function cloneWithoutChildren(node: TreeStructure): TreeStructure {
|
||||
const { children, ...rest } = node
|
||||
return structuredClone(rest) as TreeStructure
|
||||
}
|
||||
|
||||
export function NodeEditorPanel({ nodeId, onClose, onSelectType }: NodeEditorPanelProps) {
|
||||
const treeStructure = useTreeEditorStore(s => s.treeStructure)
|
||||
const updateNode = useTreeEditorStore(s => s.updateNode)
|
||||
const deleteNode = useTreeEditorStore(s => s.deleteNode)
|
||||
const duplicateNode = useTreeEditorStore(s => s.duplicateNode)
|
||||
const addNode = useTreeEditorStore(s => s.addNode)
|
||||
const selectNode = useTreeEditorStore(s => s.selectNode)
|
||||
|
||||
const node = treeStructure ? findNodeInTree(nodeId, treeStructure) : null
|
||||
const [draft, setDraft] = useState<TreeStructure | null>(null)
|
||||
const [isDirty, setIsDirty] = useState(false)
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false)
|
||||
const panelRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
// Initialize/reset draft when nodeId changes
|
||||
useEffect(() => {
|
||||
if (node) {
|
||||
setDraft(cloneWithoutChildren(node))
|
||||
setIsDirty(false)
|
||||
setShowDeleteConfirm(false)
|
||||
}
|
||||
}, [nodeId]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// Escape to close
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
handleClose()
|
||||
}
|
||||
}
|
||||
document.addEventListener('keydown', handleKeyDown)
|
||||
return () => document.removeEventListener('keydown', handleKeyDown)
|
||||
}, [isDirty]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const handleDraftUpdate = useCallback((updates: Partial<TreeStructure>) => {
|
||||
setDraft(prev => prev ? { ...prev, ...updates } : prev)
|
||||
setIsDirty(true)
|
||||
}, [])
|
||||
|
||||
const handleSave = useCallback(() => {
|
||||
if (!draft || !node) return
|
||||
const { children, ...draftWithoutChildren } = draft
|
||||
updateNode(nodeId, draftWithoutChildren)
|
||||
|
||||
// Auto-create answer stubs for new decision options without next_node_id
|
||||
if (draft.options) {
|
||||
const options = draft.options.filter(o => o.label.trim())
|
||||
const stubsCreated: Array<{ optId: string; stubId: string }> = []
|
||||
|
||||
options.forEach(opt => {
|
||||
if (!opt.next_node_id) {
|
||||
const stubId = addNode(nodeId, 'answer')
|
||||
updateNode(stubId, { title: opt.label })
|
||||
stubsCreated.push({ optId: opt.id, stubId })
|
||||
}
|
||||
})
|
||||
|
||||
if (stubsCreated.length > 0) {
|
||||
const updatedOptions = options.map(o => {
|
||||
const stub = stubsCreated.find(s => s.optId === o.id)
|
||||
return stub ? { ...o, next_node_id: stub.stubId } : o
|
||||
})
|
||||
updateNode(nodeId, { options: updatedOptions })
|
||||
}
|
||||
}
|
||||
|
||||
setIsDirty(false)
|
||||
}, [draft, node, nodeId, updateNode, addNode])
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
if (isDirty) {
|
||||
if (!window.confirm('You have unsaved changes. Discard them?')) return
|
||||
}
|
||||
onClose()
|
||||
}, [isDirty, onClose])
|
||||
|
||||
const handleDelete = useCallback(() => {
|
||||
if (!treeStructure) return
|
||||
clearInboundReferences(nodeId, treeStructure, updateNode)
|
||||
deleteNode(nodeId)
|
||||
onClose()
|
||||
}, [nodeId, treeStructure, updateNode, deleteNode, onClose])
|
||||
|
||||
const handleDuplicate = useCallback(() => {
|
||||
const newId = duplicateNode(nodeId)
|
||||
if (newId) {
|
||||
selectNode(newId)
|
||||
}
|
||||
}, [nodeId, duplicateNode, selectNode])
|
||||
|
||||
if (!node || !draft) return null
|
||||
|
||||
// Answer stub: show type picker instead of form
|
||||
if (node.type === 'answer') {
|
||||
return (
|
||||
<div ref={panelRef} className="flex h-full w-[400px] shrink-0 flex-col border-l border-border bg-card">
|
||||
<div className="flex items-center justify-between border-b border-border px-4 py-3">
|
||||
<span className="text-sm font-heading font-medium text-foreground">
|
||||
{node.title || 'Answer Placeholder'}
|
||||
</span>
|
||||
<button onClick={handleClose} className="rounded-md p-1 text-muted-foreground hover:bg-accent hover:text-foreground">
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex flex-1 flex-col items-center justify-center gap-4 px-4">
|
||||
<p className="text-sm text-muted-foreground text-center">Choose a type for this node:</p>
|
||||
<div className="flex gap-2">
|
||||
{(['decision', 'action', 'solution'] as const).map(type => {
|
||||
const cfg = TYPE_CONFIG[type]
|
||||
const TypeIcon = cfg.icon
|
||||
return (
|
||||
<button
|
||||
key={type}
|
||||
type="button"
|
||||
onClick={() => onSelectType?.(nodeId, type)}
|
||||
className={cn(
|
||||
'flex items-center gap-1.5 rounded-lg px-3 py-2 text-sm font-label border transition-colors',
|
||||
type === 'decision' && 'border-blue-500/30 bg-blue-500/10 text-blue-400 hover:bg-blue-500/20',
|
||||
type === 'action' && 'border-yellow-500/30 bg-yellow-500/10 text-yellow-400 hover:bg-yellow-500/20',
|
||||
type === 'solution' && 'border-green-500/30 bg-green-500/10 text-green-400 hover:bg-green-500/20',
|
||||
)}
|
||||
>
|
||||
<TypeIcon className="h-4 w-4" /> {cfg.label}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const config = TYPE_CONFIG[node.type as Exclude<NodeType, 'answer'>] ?? TYPE_CONFIG.decision
|
||||
const TypeIcon = config.icon
|
||||
const title = node.type === 'decision' ? (node.question || 'Untitled Decision') : (node.title || `Untitled ${config.label}`)
|
||||
const isRoot = treeStructure?.id === nodeId
|
||||
|
||||
return (
|
||||
<div ref={panelRef} className="flex h-full w-[400px] shrink-0 flex-col border-l border-border bg-card">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-2 border-b border-border px-4 py-3">
|
||||
<span className={cn('flex h-5 w-5 shrink-0 items-center justify-center rounded', config.badgeClass)}>
|
||||
<TypeIcon className="h-3 w-3" />
|
||||
</span>
|
||||
<span className="flex-1 truncate text-sm font-heading font-medium text-foreground">{title}</span>
|
||||
<button onClick={handleClose} className="rounded-md p-1 text-muted-foreground hover:bg-accent hover:text-foreground">
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Body — scrollable form area */}
|
||||
<div className="flex-1 overflow-y-auto px-4 py-3">
|
||||
{draft.type === 'decision' && <NodeFormDecision node={draft} onUpdate={handleDraftUpdate} />}
|
||||
{draft.type === 'action' && <NodeFormAction node={draft} onUpdate={handleDraftUpdate} />}
|
||||
{draft.type === 'solution' && <NodeFormResolution node={draft} onUpdate={handleDraftUpdate} />}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex items-center gap-2 border-t border-border px-4 py-3">
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={!isDirty}
|
||||
className={cn(
|
||||
'flex items-center gap-1.5 rounded-lg px-4 py-2 text-sm font-medium text-white shadow-lg shadow-primary/20 transition-opacity',
|
||||
isDirty ? 'bg-gradient-brand hover:opacity-90' : 'bg-gradient-brand opacity-50 cursor-not-allowed'
|
||||
)}
|
||||
>
|
||||
<Save className="h-3.5 w-3.5" /> Save
|
||||
</button>
|
||||
<button
|
||||
onClick={handleClose}
|
||||
className="rounded-lg border border-border px-4 py-2 text-sm text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<div className="flex-1" />
|
||||
{!isRoot && (
|
||||
<>
|
||||
<button
|
||||
onClick={handleDuplicate}
|
||||
className="rounded-md p-2 text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
title="Duplicate"
|
||||
>
|
||||
<Copy className="h-4 w-4" />
|
||||
</button>
|
||||
{showDeleteConfirm ? (
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={handleDelete}
|
||||
className="rounded-md bg-red-500/20 px-2 py-1 text-xs text-red-400 hover:bg-red-500/30"
|
||||
>
|
||||
Confirm
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowDeleteConfirm(false)}
|
||||
className="rounded-md px-2 py-1 text-xs text-muted-foreground hover:bg-accent"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => setShowDeleteConfirm(true)}
|
||||
className="rounded-md p-2 text-muted-foreground hover:bg-accent hover:text-red-400"
|
||||
title="Delete"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Clear all next_node_id references to a node before deleting
|
||||
function clearInboundReferences(
|
||||
nodeId: string,
|
||||
treeStructure: TreeStructure,
|
||||
updateNode: (id: string, updates: Partial<TreeStructure>) => void
|
||||
) {
|
||||
function walk(node: TreeStructure) {
|
||||
if (node.type === 'decision' && node.options) {
|
||||
const needsUpdate = node.options.some(o => o.next_node_id === nodeId)
|
||||
if (needsUpdate) {
|
||||
updateNode(node.id, {
|
||||
options: node.options.map(o => o.next_node_id === nodeId ? { ...o, next_node_id: '' } : o),
|
||||
})
|
||||
}
|
||||
}
|
||||
if (node.type === 'action' && node.next_node_id === nodeId) {
|
||||
updateNode(node.id, { next_node_id: '' })
|
||||
}
|
||||
node.children?.forEach(walk)
|
||||
}
|
||||
walk(treeStructure)
|
||||
}
|
||||
Reference in New Issue
Block a user