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>
This commit is contained in:
365
frontend/src/components/tree-editor/TreeCanvasNode.tsx
Normal file
365
frontend/src/components/tree-editor/TreeCanvasNode.tsx
Normal file
@@ -0,0 +1,365 @@
|
|||||||
|
import { useState, useCallback } from 'react'
|
||||||
|
import {
|
||||||
|
HelpCircle,
|
||||||
|
Zap,
|
||||||
|
CheckCircle,
|
||||||
|
Play,
|
||||||
|
Check,
|
||||||
|
X,
|
||||||
|
Copy,
|
||||||
|
Trash2,
|
||||||
|
GripVertical,
|
||||||
|
AlertCircle,
|
||||||
|
AlertTriangle,
|
||||||
|
ChevronDown,
|
||||||
|
ChevronRight,
|
||||||
|
} 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
|
||||||
|
onToggleExpand: () => 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,
|
||||||
|
onToggleExpand,
|
||||||
|
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 changes while editing (e.g. store update from undo)
|
||||||
|
const [lastNodeId, setLastNodeId] = useState(node.id)
|
||||||
|
if (node.id !== lastNodeId) {
|
||||||
|
setDraft(cloneNodeWithoutChildren(node))
|
||||||
|
setLastNodeId(node.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
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_CONFIG[node.type]
|
||||||
|
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'
|
||||||
|
)}
|
||||||
|
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>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 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">
|
||||||
|
{/* 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
|
||||||
Reference in New Issue
Block a user