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 { HelpCircle, Zap, CheckCircle, Trash2 } from 'lucide-react'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import type { TreeStructure } from '@/types'
|
import type { TreeStructure } from '@/types'
|
||||||
@@ -13,10 +13,24 @@ interface AnswerStubCardProps {
|
|||||||
export function AnswerStubCard({ node, fromOption, onSelectType, onDelete }: AnswerStubCardProps) {
|
export function AnswerStubCard({ node, fromOption, onSelectType, onDelete }: AnswerStubCardProps) {
|
||||||
const [picking, setPicking] = useState(false)
|
const [picking, setPicking] = useState(false)
|
||||||
const [confirming, setConfirming] = useState(false)
|
const [confirming, setConfirming] = useState(false)
|
||||||
|
const cardRef = useRef<HTMLDivElement>(null)
|
||||||
const label = fromOption || node.title || 'Answer'
|
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 (
|
return (
|
||||||
<div
|
<div
|
||||||
|
ref={cardRef}
|
||||||
className={cn(
|
className={cn(
|
||||||
'relative min-w-[180px] max-w-[280px] rounded-xl border-2 border-dashed border-border bg-card/50',
|
'relative min-w-[180px] max-w-[280px] rounded-xl border-2 border-dashed border-border bg-card/50',
|
||||||
'transition-all duration-150',
|
'transition-all duration-150',
|
||||||
|
|||||||
@@ -163,6 +163,7 @@ export function TreeCanvas() {
|
|||||||
// ── Local canvas state ──
|
// ── Local canvas state ──
|
||||||
const [expandedNodeId, setExpandedNodeId] = useState<string | null>(null)
|
const [expandedNodeId, setExpandedNodeId] = useState<string | null>(null)
|
||||||
const [newNodeIds, setNewNodeIds] = useState<Set<string>>(new Set())
|
const [newNodeIds, setNewNodeIds] = useState<Set<string>>(new Set())
|
||||||
|
const [collapsedNodeIds, setCollapsedNodeIds] = useState<Set<string>>(new Set())
|
||||||
const [pendingAddKey, setPendingAddKey] = useState<string | null>(null)
|
const [pendingAddKey, setPendingAddKey] = useState<string | null>(null)
|
||||||
const [pendingLinks, setPendingLinks] = useState<Map<string, PendingLink>>(
|
const [pendingLinks, setPendingLinks] = useState<Map<string, PendingLink>>(
|
||||||
new Map()
|
new Map()
|
||||||
@@ -204,26 +205,26 @@ export function TreeCanvas() {
|
|||||||
(nodeId: string, updates: Partial<TreeStructure>) => {
|
(nodeId: string, updates: Partial<TreeStructure>) => {
|
||||||
updateNode(nodeId, updates)
|
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) {
|
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 }> = []
|
const stubsToCreate: Array<{ opt: typeof options[number]; stubId: string }> = []
|
||||||
|
|
||||||
options.forEach((opt) => {
|
options.forEach((opt) => {
|
||||||
if (!opt.next_node_id && opt.label.trim()) {
|
if (!opt.next_node_id) {
|
||||||
const stubId = addNode(nodeId, 'answer')
|
const stubId = addNode(nodeId, 'answer')
|
||||||
updateNode(stubId, { title: opt.label })
|
updateNode(stubId, { title: opt.label })
|
||||||
stubsToCreate.push({ opt, stubId })
|
stubsToCreate.push({ opt, stubId })
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
if (stubsToCreate.length > 0) {
|
// Write back: filtered options + any newly assigned next_node_ids
|
||||||
const updatedOptions = options.map((o) => {
|
const updatedOptions = options.map((o) => {
|
||||||
const stub = stubsToCreate.find((s) => s.opt.id === o.id)
|
const stub = stubsToCreate.find((s) => s.opt.id === o.id)
|
||||||
return stub ? { ...o, next_node_id: stub.stubId } : o
|
return stub ? { ...o, next_node_id: stub.stubId } : o
|
||||||
})
|
})
|
||||||
updateNode(nodeId, { options: updatedOptions })
|
updateNode(nodeId, { options: updatedOptions })
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Resolve pending link for new nodes
|
// Resolve pending link for new nodes
|
||||||
@@ -301,6 +302,16 @@ export function TreeCanvas() {
|
|||||||
[duplicateNode]
|
[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 ──
|
// ── Convert answer stub to a real node type ──
|
||||||
const handleSelectAnswerType = useCallback(
|
const handleSelectAnswerType = useCallback(
|
||||||
(nodeId: string, type: 'decision' | 'action' | 'solution') => {
|
(nodeId: string, type: 'decision' | 'action' | 'solution') => {
|
||||||
@@ -416,6 +427,7 @@ export function TreeCanvas() {
|
|||||||
): React.ReactNode => {
|
): React.ReactNode => {
|
||||||
const isExpanded = expandedNodeId === node.id
|
const isExpanded = expandedNodeId === node.id
|
||||||
const isNew = newNodeIds.has(node.id)
|
const isNew = newNodeIds.has(node.id)
|
||||||
|
const isSubtreeCollapsed = collapsedNodeIds.has(node.id)
|
||||||
const nodeChildren = node.children || []
|
const nodeChildren = node.children || []
|
||||||
|
|
||||||
// For decision nodes, order children by option link order
|
// For decision nodes, order children by option link order
|
||||||
@@ -467,8 +479,9 @@ export function TreeCanvas() {
|
|||||||
node.type === 'decision' && node.options
|
node.type === 'decision' && node.options
|
||||||
? node.options.filter(
|
? node.options.filter(
|
||||||
(opt) =>
|
(opt) =>
|
||||||
!opt.next_node_id ||
|
opt.label.trim() &&
|
||||||
!nodeChildren.find((c) => c.id === opt.next_node_id)
|
(!opt.next_node_id ||
|
||||||
|
!nodeChildren.find((c) => c.id === opt.next_node_id))
|
||||||
)
|
)
|
||||||
: []
|
: []
|
||||||
|
|
||||||
@@ -512,7 +525,10 @@ export function TreeCanvas() {
|
|||||||
fromOption={optionLabel}
|
fromOption={optionLabel}
|
||||||
isExpanded={isExpanded}
|
isExpanded={isExpanded}
|
||||||
isNew={isNew}
|
isNew={isNew}
|
||||||
|
hasChildren={nodeChildren.length > 0}
|
||||||
|
isSubtreeCollapsed={isSubtreeCollapsed}
|
||||||
onToggleExpand={() => handleToggleExpand(node.id)}
|
onToggleExpand={() => handleToggleExpand(node.id)}
|
||||||
|
onToggleSubtreeCollapse={() => handleToggleSubtreeCollapse(node.id)}
|
||||||
onSave={handleSave}
|
onSave={handleSave}
|
||||||
onCancelNew={handleCancelNew}
|
onCancelNew={handleCancelNew}
|
||||||
onDelete={handleDelete}
|
onDelete={handleDelete}
|
||||||
@@ -571,8 +587,22 @@ export function TreeCanvas() {
|
|||||||
</div>
|
</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 */}
|
{/* Connector + Children */}
|
||||||
{hasChildren && !isExpanded && (
|
{hasChildren && !isExpanded && !isSubtreeCollapsed && (
|
||||||
<div className="mt-3 flex flex-col items-center w-full">
|
<div className="mt-3 flex flex-col items-center w-full">
|
||||||
{/* Trunk line from card down */}
|
{/* Trunk line from card down */}
|
||||||
<div className="h-4 w-px bg-border" />
|
<div className="h-4 w-px bg-border" />
|
||||||
@@ -622,8 +652,10 @@ export function TreeCanvas() {
|
|||||||
[
|
[
|
||||||
expandedNodeId,
|
expandedNodeId,
|
||||||
newNodeIds,
|
newNodeIds,
|
||||||
|
collapsedNodeIds,
|
||||||
dragOverTarget,
|
dragOverTarget,
|
||||||
handleToggleExpand,
|
handleToggleExpand,
|
||||||
|
handleToggleSubtreeCollapse,
|
||||||
handleSave,
|
handleSave,
|
||||||
handleCancelNew,
|
handleCancelNew,
|
||||||
handleDelete,
|
handleDelete,
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ import {
|
|||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
|
ChevronsDownUp,
|
||||||
|
ChevronsUpDown,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { useTreeEditorStore } from '@/store/treeEditorStore'
|
import { useTreeEditorStore } from '@/store/treeEditorStore'
|
||||||
import { NodeFormDecision } from './NodeFormDecision'
|
import { NodeFormDecision } from './NodeFormDecision'
|
||||||
@@ -27,7 +29,10 @@ interface TreeCanvasNodeProps {
|
|||||||
fromOption?: string
|
fromOption?: string
|
||||||
isExpanded: boolean
|
isExpanded: boolean
|
||||||
isNew: boolean
|
isNew: boolean
|
||||||
|
hasChildren?: boolean
|
||||||
|
isSubtreeCollapsed?: boolean
|
||||||
onToggleExpand: () => void
|
onToggleExpand: () => void
|
||||||
|
onToggleSubtreeCollapse?: () => void
|
||||||
onSave: (nodeId: string, updates: Partial<TreeStructure>) => void
|
onSave: (nodeId: string, updates: Partial<TreeStructure>) => void
|
||||||
onCancelNew: (nodeId: string) => void
|
onCancelNew: (nodeId: string) => void
|
||||||
onDelete: (nodeId: string) => void
|
onDelete: (nodeId: string) => void
|
||||||
@@ -70,7 +75,10 @@ export function TreeCanvasNode({
|
|||||||
fromOption,
|
fromOption,
|
||||||
isExpanded,
|
isExpanded,
|
||||||
isNew,
|
isNew,
|
||||||
|
hasChildren = false,
|
||||||
|
isSubtreeCollapsed = false,
|
||||||
onToggleExpand,
|
onToggleExpand,
|
||||||
|
onToggleSubtreeCollapse,
|
||||||
onSave,
|
onSave,
|
||||||
onCancelNew,
|
onCancelNew,
|
||||||
onDelete,
|
onDelete,
|
||||||
@@ -262,6 +270,21 @@ export function TreeCanvasNode({
|
|||||||
</span>
|
</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 */}
|
{/* Expand/collapse chevron */}
|
||||||
{!isExpanded ? (
|
{!isExpanded ? (
|
||||||
<ChevronRight className="h-3.5 w-3.5 text-muted-foreground shrink-0" />
|
<ChevronRight className="h-3.5 w-3.5 text-muted-foreground shrink-0" />
|
||||||
|
|||||||
Reference in New Issue
Block a user