feat: add FlowCanvasNode compact card for React Flow canvas
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
154
frontend/src/components/tree-editor/FlowCanvasNode.tsx
Normal file
154
frontend/src/components/tree-editor/FlowCanvasNode.tsx
Normal file
@@ -0,0 +1,154 @@
|
|||||||
|
import { memo } from 'react'
|
||||||
|
import { Handle, Position, type NodeProps } from '@xyflow/react'
|
||||||
|
import { HelpCircle, Zap, CheckCircle, ChevronDown, ChevronRight, AlertCircle } from 'lucide-react'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
import type { TreeStructure, NodeType } from '@/types'
|
||||||
|
|
||||||
|
const NODE_TYPE_CONFIG: Record<Exclude<NodeType, 'answer'>, {
|
||||||
|
icon: typeof HelpCircle
|
||||||
|
label: string
|
||||||
|
borderClass: string
|
||||||
|
badgeClass: string
|
||||||
|
minimapColor: string
|
||||||
|
}> = {
|
||||||
|
decision: {
|
||||||
|
icon: HelpCircle,
|
||||||
|
label: 'Decision',
|
||||||
|
borderClass: 'border-l-4 border-l-blue-500',
|
||||||
|
badgeClass: 'bg-blue-500/20 text-blue-400',
|
||||||
|
minimapColor: '#3b82f6',
|
||||||
|
},
|
||||||
|
action: {
|
||||||
|
icon: Zap,
|
||||||
|
label: 'Action',
|
||||||
|
borderClass: 'border-l-4 border-l-yellow-500',
|
||||||
|
badgeClass: 'bg-yellow-500/20 text-yellow-400',
|
||||||
|
minimapColor: '#eab308',
|
||||||
|
},
|
||||||
|
solution: {
|
||||||
|
icon: CheckCircle,
|
||||||
|
label: 'Solution',
|
||||||
|
borderClass: 'border-l-4 border-l-green-500',
|
||||||
|
badgeClass: 'bg-green-500/20 text-green-400',
|
||||||
|
minimapColor: '#22c55e',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FlowCanvasNodeData {
|
||||||
|
node: TreeStructure
|
||||||
|
hasChildren: boolean
|
||||||
|
isCollapsed: boolean
|
||||||
|
hasValidationErrors: boolean
|
||||||
|
isNew: boolean
|
||||||
|
onToggleCollapse: (nodeId: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
function FlowCanvasNodeComponent({ data, selected }: NodeProps) {
|
||||||
|
const { node, hasChildren, isCollapsed, hasValidationErrors, isNew, onToggleCollapse } = data as unknown as FlowCanvasNodeData
|
||||||
|
const nodeType = node.type as Exclude<NodeType, 'answer'>
|
||||||
|
const config = NODE_TYPE_CONFIG[nodeType] ?? NODE_TYPE_CONFIG.decision
|
||||||
|
const Icon = config.icon
|
||||||
|
|
||||||
|
const title = node.type === 'decision'
|
||||||
|
? (node.question || 'Untitled Decision')
|
||||||
|
: (node.title || `Untitled ${config.label}`)
|
||||||
|
|
||||||
|
const optionCount = node.options?.length ?? 0
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* Target handle at top */}
|
||||||
|
<Handle type="target" position={Position.Top} className="!bg-border !w-2 !h-2 !border-0" />
|
||||||
|
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'w-[280px] rounded-xl border border-border bg-card shadow-sm cursor-pointer transition-all',
|
||||||
|
config.borderClass,
|
||||||
|
selected && 'ring-1 ring-primary shadow-md'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center gap-2 px-3 py-2.5">
|
||||||
|
{/* Type badge */}
|
||||||
|
<span className={cn('flex h-5 w-5 shrink-0 items-center justify-center rounded', config.badgeClass)}>
|
||||||
|
<Icon className="h-3 w-3" />
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{/* Title */}
|
||||||
|
<span className="flex-1 truncate text-sm font-heading font-medium text-foreground">
|
||||||
|
{title}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{/* Badges */}
|
||||||
|
{isNew && (
|
||||||
|
<span className="rounded-full bg-yellow-500/20 px-1.5 py-0.5 text-[10px] font-label text-yellow-400">
|
||||||
|
New
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{hasValidationErrors && (
|
||||||
|
<AlertCircle className="h-3.5 w-3.5 shrink-0 text-red-400" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Decision options preview */}
|
||||||
|
{node.type === 'decision' && optionCount > 0 && (
|
||||||
|
<div className="border-t border-border px-3 py-1.5">
|
||||||
|
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||||
|
<span className="font-label">{optionCount} option{optionCount !== 1 ? 's' : ''}</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 space-y-0.5">
|
||||||
|
{node.options!.slice(0, 3).map((opt, i) => (
|
||||||
|
<div key={opt.id} className="truncate text-xs text-muted-foreground">
|
||||||
|
<span className="font-label text-foreground/60">{String.fromCharCode(65 + i)}</span>{' '}
|
||||||
|
{opt.label || '(empty)'}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{optionCount > 3 && (
|
||||||
|
<div className="text-xs text-muted-foreground">+{optionCount - 3} more</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Description preview for action/solution */}
|
||||||
|
{(node.type === 'action' || node.type === 'solution') && node.description && (
|
||||||
|
<div className="border-t border-border px-3 py-1.5">
|
||||||
|
<div className="line-clamp-2 text-xs text-muted-foreground">{node.description}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Collapse chevron */}
|
||||||
|
{hasChildren && (
|
||||||
|
<div className="flex justify-center border-t border-border py-1">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
onToggleCollapse(node.id)
|
||||||
|
}}
|
||||||
|
className="flex items-center gap-1 rounded px-2 py-0.5 text-xs text-muted-foreground hover:bg-accent hover:text-foreground transition-colors"
|
||||||
|
>
|
||||||
|
{isCollapsed ? (
|
||||||
|
<>
|
||||||
|
<ChevronRight className="h-3 w-3" />
|
||||||
|
<span className="font-label">Expand</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<ChevronDown className="h-3 w-3" />
|
||||||
|
<span className="font-label">Collapse</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Source handle at bottom */}
|
||||||
|
<Handle type="source" position={Position.Bottom} className="!bg-border !w-2 !h-2 !border-0" />
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const FlowCanvasNode = memo(FlowCanvasNodeComponent)
|
||||||
|
export { NODE_TYPE_CONFIG }
|
||||||
Reference in New Issue
Block a user