fix+feat: blank options, stub card dismiss, collapsible subtrees
- TreeCanvas: strip blank-label options on save so they don't generate stubs; also filter them from the unlinked-option add-button list - AnswerStubCard: collapse type-picker when clicking outside the card - TreeCanvasNode: add subtree collapse toggle button (ChevronsDownUp icon) visible in compact mode when the node has children - TreeCanvas: track collapsedNodeIds; hide subtree behind a clickable "N nodes hidden" pill when collapsed Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { useState } from 'react'
|
||||
import { useState, useRef, useEffect } from 'react'
|
||||
import { HelpCircle, Zap, CheckCircle, Trash2 } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { TreeStructure } from '@/types'
|
||||
@@ -13,10 +13,24 @@ interface AnswerStubCardProps {
|
||||
export function AnswerStubCard({ node, fromOption, onSelectType, onDelete }: AnswerStubCardProps) {
|
||||
const [picking, setPicking] = useState(false)
|
||||
const [confirming, setConfirming] = useState(false)
|
||||
const cardRef = useRef<HTMLDivElement>(null)
|
||||
const label = fromOption || node.title || 'Answer'
|
||||
|
||||
// Collapse picker when clicking outside the card
|
||||
useEffect(() => {
|
||||
if (!picking) return
|
||||
const handleOutsideClick = (e: MouseEvent) => {
|
||||
if (cardRef.current && !cardRef.current.contains(e.target as Node)) {
|
||||
setPicking(false)
|
||||
}
|
||||
}
|
||||
document.addEventListener('mousedown', handleOutsideClick)
|
||||
return () => document.removeEventListener('mousedown', handleOutsideClick)
|
||||
}, [picking])
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={cardRef}
|
||||
className={cn(
|
||||
'relative min-w-[180px] max-w-[280px] rounded-xl border-2 border-dashed border-border bg-card/50',
|
||||
'transition-all duration-150',
|
||||
|
||||
@@ -163,6 +163,7 @@ export function TreeCanvas() {
|
||||
// ── Local canvas state ──
|
||||
const [expandedNodeId, setExpandedNodeId] = useState<string | null>(null)
|
||||
const [newNodeIds, setNewNodeIds] = useState<Set<string>>(new Set())
|
||||
const [collapsedNodeIds, setCollapsedNodeIds] = useState<Set<string>>(new Set())
|
||||
const [pendingAddKey, setPendingAddKey] = useState<string | null>(null)
|
||||
const [pendingLinks, setPendingLinks] = useState<Map<string, PendingLink>>(
|
||||
new Map()
|
||||
@@ -204,26 +205,26 @@ export function TreeCanvas() {
|
||||
(nodeId: string, updates: Partial<TreeStructure>) => {
|
||||
updateNode(nodeId, updates)
|
||||
|
||||
// For decision nodes: create answer stubs for any option without a next_node_id
|
||||
// For decision nodes: strip blank options, then create answer stubs for any
|
||||
// labelled option that doesn't yet have a linked child
|
||||
if (updates.options) {
|
||||
const options = updates.options
|
||||
const options = updates.options.filter((o) => o.label.trim())
|
||||
const stubsToCreate: Array<{ opt: typeof options[number]; stubId: string }> = []
|
||||
|
||||
options.forEach((opt) => {
|
||||
if (!opt.next_node_id && opt.label.trim()) {
|
||||
if (!opt.next_node_id) {
|
||||
const stubId = addNode(nodeId, 'answer')
|
||||
updateNode(stubId, { title: opt.label })
|
||||
stubsToCreate.push({ opt, stubId })
|
||||
}
|
||||
})
|
||||
|
||||
if (stubsToCreate.length > 0) {
|
||||
const updatedOptions = options.map((o) => {
|
||||
const stub = stubsToCreate.find((s) => s.opt.id === o.id)
|
||||
return stub ? { ...o, next_node_id: stub.stubId } : o
|
||||
})
|
||||
updateNode(nodeId, { options: updatedOptions })
|
||||
}
|
||||
// Write back: filtered options + any newly assigned next_node_ids
|
||||
const updatedOptions = options.map((o) => {
|
||||
const stub = stubsToCreate.find((s) => s.opt.id === o.id)
|
||||
return stub ? { ...o, next_node_id: stub.stubId } : o
|
||||
})
|
||||
updateNode(nodeId, { options: updatedOptions })
|
||||
}
|
||||
|
||||
// Resolve pending link for new nodes
|
||||
@@ -301,6 +302,16 @@ export function TreeCanvas() {
|
||||
[duplicateNode]
|
||||
)
|
||||
|
||||
// ── Subtree collapse toggle ──
|
||||
const handleToggleSubtreeCollapse = useCallback((nodeId: string) => {
|
||||
setCollapsedNodeIds((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(nodeId)) next.delete(nodeId)
|
||||
else next.add(nodeId)
|
||||
return next
|
||||
})
|
||||
}, [])
|
||||
|
||||
// ── Convert answer stub to a real node type ──
|
||||
const handleSelectAnswerType = useCallback(
|
||||
(nodeId: string, type: 'decision' | 'action' | 'solution') => {
|
||||
@@ -416,6 +427,7 @@ export function TreeCanvas() {
|
||||
): React.ReactNode => {
|
||||
const isExpanded = expandedNodeId === node.id
|
||||
const isNew = newNodeIds.has(node.id)
|
||||
const isSubtreeCollapsed = collapsedNodeIds.has(node.id)
|
||||
const nodeChildren = node.children || []
|
||||
|
||||
// For decision nodes, order children by option link order
|
||||
@@ -467,8 +479,9 @@ export function TreeCanvas() {
|
||||
node.type === 'decision' && node.options
|
||||
? node.options.filter(
|
||||
(opt) =>
|
||||
!opt.next_node_id ||
|
||||
!nodeChildren.find((c) => c.id === opt.next_node_id)
|
||||
opt.label.trim() &&
|
||||
(!opt.next_node_id ||
|
||||
!nodeChildren.find((c) => c.id === opt.next_node_id))
|
||||
)
|
||||
: []
|
||||
|
||||
@@ -512,7 +525,10 @@ export function TreeCanvas() {
|
||||
fromOption={optionLabel}
|
||||
isExpanded={isExpanded}
|
||||
isNew={isNew}
|
||||
hasChildren={nodeChildren.length > 0}
|
||||
isSubtreeCollapsed={isSubtreeCollapsed}
|
||||
onToggleExpand={() => handleToggleExpand(node.id)}
|
||||
onToggleSubtreeCollapse={() => handleToggleSubtreeCollapse(node.id)}
|
||||
onSave={handleSave}
|
||||
onCancelNew={handleCancelNew}
|
||||
onDelete={handleDelete}
|
||||
@@ -571,8 +587,22 @@ export function TreeCanvas() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Collapsed subtree pill */}
|
||||
{hasChildren && !isExpanded && isSubtreeCollapsed && (
|
||||
<div className="mt-3 flex flex-col items-center">
|
||||
<div className="h-4 w-px bg-border" />
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleToggleSubtreeCollapse(node.id)}
|
||||
className="rounded-full border border-dashed border-border bg-card px-3 py-1 text-[10px] text-muted-foreground font-label hover:border-primary/40 hover:text-foreground transition-colors"
|
||||
>
|
||||
{orderedChildren.length} node{orderedChildren.length !== 1 ? 's' : ''} hidden — click to expand
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Connector + Children */}
|
||||
{hasChildren && !isExpanded && (
|
||||
{hasChildren && !isExpanded && !isSubtreeCollapsed && (
|
||||
<div className="mt-3 flex flex-col items-center w-full">
|
||||
{/* Trunk line from card down */}
|
||||
<div className="h-4 w-px bg-border" />
|
||||
@@ -622,8 +652,10 @@ export function TreeCanvas() {
|
||||
[
|
||||
expandedNodeId,
|
||||
newNodeIds,
|
||||
collapsedNodeIds,
|
||||
dragOverTarget,
|
||||
handleToggleExpand,
|
||||
handleToggleSubtreeCollapse,
|
||||
handleSave,
|
||||
handleCancelNew,
|
||||
handleDelete,
|
||||
|
||||
@@ -13,6 +13,8 @@ import {
|
||||
AlertTriangle,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
ChevronsDownUp,
|
||||
ChevronsUpDown,
|
||||
} from 'lucide-react'
|
||||
import { useTreeEditorStore } from '@/store/treeEditorStore'
|
||||
import { NodeFormDecision } from './NodeFormDecision'
|
||||
@@ -27,7 +29,10 @@ interface TreeCanvasNodeProps {
|
||||
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
|
||||
@@ -70,7 +75,10 @@ export function TreeCanvasNode({
|
||||
fromOption,
|
||||
isExpanded,
|
||||
isNew,
|
||||
hasChildren = false,
|
||||
isSubtreeCollapsed = false,
|
||||
onToggleExpand,
|
||||
onToggleSubtreeCollapse,
|
||||
onSave,
|
||||
onCancelNew,
|
||||
onDelete,
|
||||
@@ -262,6 +270,21 @@ export function TreeCanvasNode({
|
||||
</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" />
|
||||
|
||||
Reference in New Issue
Block a user