Files
resolutionflow/frontend/src/components/tree-editor/TreeCanvasNode.tsx
chihlasm d365c38b61 chore: Tailwind CSS v3 → v4 migration (#99)
* chore: run Tailwind v4 upgrade tool (Phase 1)

- Upgraded tailwindcss v3 → v4.2.1, postcss plugin to @tailwindcss/postcss
- Deleted tailwind.config.js, migrated theme to CSS @theme block in index.css
- Replaced @tailwind directives with @import 'tailwindcss'
- Added @custom-variant dark, @utility blocks for custom utilities
- Updated class names across 128 files (shadow-sm → shadow-xs, etc.)
- Removed autoprefixer (built into v4)
- Added migration plan doc

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* chore: switch from @tailwindcss/postcss to @tailwindcss/vite (Phase 2)

- Replaced @tailwindcss/postcss with @tailwindcss/vite plugin
- Deleted postcss.config.js (no longer needed)
- Tailwind now runs as a native Vite plugin for faster HMR

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* refactor: convert to OKLCH colors, move keyframes into @theme (Phase 3-4)

- Replaced all HSL color indirection with direct OKLCH values in @theme
- Moved all keyframes inside @theme block (v4 pattern)
- Eliminated hsl(var(--x)) double-indirection across 17 component files
- Replaced hsl() inline styles with var(--color-*) theme references
- Cleaned up redundant rdp-* utility blocks
- Fixed @custom-variant dark syntax to use :where()
- Added sidebar/glass/shadow vars as OKLCH in :root

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-07 22:10:44 -05:00

401 lines
13 KiB
TypeScript

import { useState, useCallback, useEffect } from 'react'
import {
HelpCircle,
Zap,
CheckCircle,
Play,
Check,
X,
Copy,
Trash2,
GripVertical,
AlertCircle,
AlertTriangle,
ChevronDown,
ChevronRight,
ChevronsDownUp,
ChevronsUpDown,
} from 'lucide-react'
import { useTreeEditorStore } from '@/store/treeEditorStore'
import { NodeFormDecision } from './NodeFormDecision'
import { NodeFormAction } from './NodeFormAction'
import { NodeFormResolution } from './NodeFormResolution'
import type { TreeStructure } from '@/types'
import { cn } from '@/lib/utils'
interface TreeCanvasNodeProps {
node: TreeStructure
depth: number
fromOption?: string
isExpanded: boolean
isNew: boolean
hasChildren?: boolean
isSubtreeCollapsed?: boolean
onToggleExpand: () => void
onToggleSubtreeCollapse?: () => void
onSave: (nodeId: string, updates: Partial<TreeStructure>) => void
onCancelNew: (nodeId: string) => void
onDelete: (nodeId: string) => void
onDuplicate: (nodeId: string) => void
onDragStart: (e: React.DragEvent, nodeId: string) => void
onDragOver: (e: React.DragEvent) => void
onDrop: (e: React.DragEvent) => void
}
/** Clone a node without its children (for local draft state) */
function cloneNodeWithoutChildren(node: TreeStructure): TreeStructure {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { children, ...rest } = node
return structuredClone(rest) as TreeStructure
}
const NODE_TYPE_CONFIG = {
decision: {
icon: HelpCircle,
label: 'Decision',
borderClass: 'border-l-4 border-l-blue-500',
badgeClass: 'bg-blue-500/20 text-blue-400',
},
action: {
icon: Zap,
label: 'Action',
borderClass: 'border-l-4 border-l-yellow-500',
badgeClass: 'bg-yellow-500/20 text-yellow-400',
},
solution: {
icon: CheckCircle,
label: 'Solution',
borderClass: 'border-l-4 border-l-green-500',
badgeClass: 'bg-green-500/20 text-green-400',
},
} as const
export function TreeCanvasNode({
node,
fromOption,
isExpanded,
isNew,
hasChildren = false,
isSubtreeCollapsed = false,
onToggleExpand,
onToggleSubtreeCollapse,
onSave,
onCancelNew,
onDelete,
onDuplicate,
onDragStart,
onDragOver,
onDrop,
}: TreeCanvasNodeProps) {
const { validationErrors, selectedNodeId, selectNode } = useTreeEditorStore()
const isRoot = node.id === 'root'
const isSelected = selectedNodeId === node.id
const nodeErrors = validationErrors.filter(
(e) => e.nodeId === node.id && e.severity === 'error'
)
const nodeWarnings = validationErrors.filter(
(e) => e.nodeId === node.id && e.severity === 'warning'
)
const hasError = nodeErrors.length > 0
const hasWarning = nodeWarnings.length > 0
// Local draft state for inline editing
const [draft, setDraft] = useState<TreeStructure>(() =>
cloneNodeWithoutChildren(node)
)
// Reset draft if node ID changes (e.g. navigating between nodes)
const [lastNodeId, setLastNodeId] = useState(node.id)
if (node.id !== lastNodeId) {
setDraft(cloneNodeWithoutChildren(node))
setLastNodeId(node.id)
}
// Re-sync draft from store whenever the card is opened, so stale next_node_id
// values (written back after stub creation) don't cause duplicate stubs on re-save
useEffect(() => {
if (isExpanded) {
setDraft(cloneNodeWithoutChildren(node))
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isExpanded])
const handleDraftUpdate = useCallback((updates: Partial<TreeStructure>) => {
setDraft((prev) => ({ ...prev, ...updates }))
}, [])
const handleSave = (e: React.MouseEvent) => {
e.stopPropagation()
// Strip children from draft before passing to onSave
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { children, ...draftWithoutChildren } = draft
onSave(node.id, draftWithoutChildren)
}
const handleCancel = (e: React.MouseEvent) => {
e.stopPropagation()
if (isNew) {
onCancelNew(node.id)
} else {
// Discard draft changes and collapse
setDraft(cloneNodeWithoutChildren(node))
onToggleExpand()
}
}
const handleCardClick = () => {
selectNode(node.id)
onToggleExpand()
}
const config = node.type in NODE_TYPE_CONFIG
? NODE_TYPE_CONFIG[node.type as keyof typeof NODE_TYPE_CONFIG]
: NODE_TYPE_CONFIG.decision // fallback for 'answer' (rendered by AnswerStubCard)
const TypeIcon = config.icon
const getTitle = () => {
if (node.type === 'decision') return node.question || 'Untitled Question'
return node.title || `Untitled ${node.type}`
}
const getOptionsSummary = () => {
if (node.type !== 'decision' || !node.options?.length) return null
const count = node.options.length
return `${count} option${count !== 1 ? 's' : ''}`
}
return (
<div
className={cn(
'relative rounded-xl border border-border bg-card shadow-xs transition-all duration-150',
config.borderClass,
isExpanded && 'ring-1 ring-primary shadow-md',
isSelected && !isExpanded && 'ring-1 ring-primary/50',
hasError && 'ring-1 ring-destructive',
hasWarning && !hasError && 'ring-1 ring-yellow-500/70',
isNew && 'ring-1 ring-yellow-400/60',
'min-w-[240px] max-w-[340px]'
)}
onDragOver={onDragOver}
onDrop={onDrop}
>
{/* Card Header */}
<div
className={cn(
'flex items-center gap-2 px-3 py-2.5',
!isExpanded && 'cursor-pointer hover:bg-accent/50 rounded-t-xl',
!isExpanded && 'rounded-xl',
isExpanded && 'sticky top-0 z-10 bg-card rounded-t-xl'
)}
onClick={!isExpanded ? handleCardClick : undefined}
>
{/* Drag handle (hide for root) */}
{!isRoot && (
<span
className="cursor-grab shrink-0"
draggable
onDragStart={(e) => {
e.stopPropagation()
onDragStart(e, node.id)
}}
>
<GripVertical className="h-4 w-4 text-muted-foreground/50" />
</span>
)}
{/* Node type badge */}
{isRoot ? (
<span className="flex items-center gap-1 rounded px-1.5 py-0.5 text-xs font-semibold bg-blue-500/30 text-blue-400 font-label shrink-0">
<Play className="h-3 w-3" />
START
</span>
) : (
<span
className={cn(
'flex items-center gap-1 rounded px-1.5 py-0.5 text-xs font-label shrink-0',
config.badgeClass
)}
>
<TypeIcon className="h-3 w-3" />
{config.label}
</span>
)}
{/* From-option label */}
{fromOption && (
<span className="rounded bg-muted px-1.5 py-0.5 text-[10px] text-muted-foreground truncate max-w-[80px]">
{fromOption}
</span>
)}
{/* Title text (compact mode) */}
{!isExpanded && (
<span className="flex-1 truncate text-sm font-heading font-medium text-foreground">
{getTitle()}
</span>
)}
{/* Options count badge */}
{!isExpanded && getOptionsSummary() && (
<span className="text-[10px] text-muted-foreground shrink-0 font-label">
{getOptionsSummary()}
</span>
)}
{/* Validation badges (compact mode) */}
{!isExpanded && hasError && (
<span
className="flex items-center gap-0.5 rounded bg-destructive/20 px-1.5 py-0.5 text-[10px] text-destructive shrink-0"
title={nodeErrors.map((e) => e.message).join('\n')}
>
<AlertCircle className="h-2.5 w-2.5" />
{nodeErrors.length}
</span>
)}
{!isExpanded && !hasError && hasWarning && (
<span
className="flex items-center gap-0.5 rounded bg-yellow-500/20 px-1.5 py-0.5 text-[10px] text-yellow-500 shrink-0"
title={nodeWarnings.map((e) => e.message).join('\n')}
>
<AlertTriangle className="h-2.5 w-2.5" />
{nodeWarnings.length}
</span>
)}
{/* Unsaved badge */}
{!isExpanded && isNew && (
<span className="rounded bg-yellow-500/20 px-1.5 py-0.5 text-[10px] text-yellow-500 font-label shrink-0">
Unsaved
</span>
)}
{/* Subtree collapse toggle — only in compact mode when node has children */}
{!isExpanded && hasChildren && onToggleSubtreeCollapse && (
<button
type="button"
onClick={(e) => { e.stopPropagation(); onToggleSubtreeCollapse() }}
title={isSubtreeCollapsed ? 'Expand subtree' : 'Collapse subtree'}
className="rounded p-0.5 text-muted-foreground/50 hover:bg-accent hover:text-foreground shrink-0"
>
{isSubtreeCollapsed
? <ChevronsUpDown className="h-3.5 w-3.5" />
: <ChevronsDownUp className="h-3.5 w-3.5" />
}
</button>
)}
{/* Expand/collapse chevron */}
{!isExpanded ? (
<ChevronRight className="h-3.5 w-3.5 text-muted-foreground shrink-0" />
) : (
<ChevronDown className="h-3.5 w-3.5 text-muted-foreground shrink-0" />
)}
{/* Editing action buttons (expanded state) */}
{isExpanded && (
<div className="ml-auto flex items-center gap-1 shrink-0">
{/* New badge */}
{isNew && (
<span className="rounded bg-yellow-500/20 px-1.5 py-0.5 text-[10px] text-yellow-500 font-label">
Unsaved
</span>
)}
{/* Duplicate (hide for root) */}
{!isRoot && (
<button
type="button"
onClick={(e) => {
e.stopPropagation()
onDuplicate(node.id)
}}
title="Duplicate node"
className="rounded p-1 text-muted-foreground hover:bg-accent hover:text-foreground"
>
<Copy className="h-3.5 w-3.5" />
</button>
)}
{/* Delete (hide for root) */}
{!isRoot && (
<button
type="button"
onClick={(e) => {
e.stopPropagation()
onDelete(node.id)
}}
title="Delete node"
className="rounded p-1 text-muted-foreground hover:bg-destructive/20 hover:text-destructive"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
)}
{/* Cancel */}
<button
type="button"
onClick={handleCancel}
title={isNew ? 'Cancel (deletes this node)' : 'Cancel changes'}
className="rounded p-1 text-muted-foreground hover:bg-accent hover:text-foreground"
>
<X className="h-3.5 w-3.5" />
</button>
{/* Save */}
<button
type="button"
onClick={handleSave}
title="Save changes"
className="rounded p-1 bg-gradient-brand text-white hover:opacity-90"
>
<Check className="h-3.5 w-3.5" />
</button>
</div>
)}
</div>
{/* Expanded editing area */}
{isExpanded && (
<div className="border-t border-border px-3 pb-3 pt-3 max-h-[70vh] overflow-y-auto">
{/* Validation errors */}
{(hasError || hasWarning) && (
<div className="mb-3 space-y-1">
{nodeErrors.map((error, i) => (
<div
key={i}
className="rounded-md bg-red-400/10 px-3 py-2 text-xs text-red-400"
>
{error.message}
</div>
))}
{!hasError &&
nodeWarnings.map((warning, i) => (
<div
key={i}
className="rounded-md bg-yellow-400/10 px-3 py-2 text-xs text-yellow-400"
>
{warning.message}
</div>
))}
</div>
)}
{/* Type-specific form — uses draft, not live node */}
{draft.type === 'decision' && (
<NodeFormDecision node={draft} onUpdate={handleDraftUpdate} />
)}
{draft.type === 'action' && (
<NodeFormAction node={draft} onUpdate={handleDraftUpdate} />
)}
{draft.type === 'solution' && (
<NodeFormResolution node={draft} onUpdate={handleDraftUpdate} />
)}
</div>
)}
</div>
)
}
export default TreeCanvasNode