Implement Tree Editor with visual preview and documentation updates

Tree Editor Features:
- Zustand store with immer middleware and zundo for undo/redo
- Form-based node editing (Decision, Action, Solution types)
- Visual tree preview with solution connection indicators
- NodePicker with type-grouped dropdown (Decisions/Actions/Solutions)
- SharedLinksMap for detecting nodes with multiple sources
- Modal component with scrollable body, fixed header/footer

New Components:
- TreeEditorLayout, TreeMetadataForm, NodeList, NodeEditorModal
- NodeFormDecision, NodeFormAction, NodeFormResolution
- DynamicArrayField, NodePicker
- TreePreviewPanel, TreePreviewNode

Documentation:
- Updated README.md status to Phase 2
- Added Tree Editor details to CURRENT-STATE.md
- Added modal/Zustand lessons to LESSONS-LEARNED.md
- Updated file structure in CLAUDE-SETUP.md
- Added Tree Editor progress to PROGRESS.md

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Michael Chihlas
2026-01-28 03:00:00 -05:00
parent 088333174c
commit 4cee013733
26 changed files with 4073 additions and 91 deletions

View File

@@ -11,11 +11,13 @@
"axios": "^1.13.4",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"immer": "^11.1.3",
"lucide-react": "^0.563.0",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-router-dom": "^7.13.0",
"tailwind-merge": "^3.4.0",
"zundo": "^2.3.0",
"zustand": "^5.0.10"
},
"devDependencies": {
@@ -3039,6 +3041,16 @@
"node": ">= 4"
}
},
"node_modules/immer": {
"version": "11.1.3",
"resolved": "https://registry.npmjs.org/immer/-/immer-11.1.3.tgz",
"integrity": "sha512-6jQTc5z0KJFtr1UgFpIL3N9XSC3saRaI9PwWtzM2pSqkNGtiNkYY2OSwkOGDK2XcTRcLb1pi/aNkKZz0nxVH4Q==",
"license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/immer"
}
},
"node_modules/import-fresh": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
@@ -4790,6 +4802,24 @@
"zod": "^3.25.0 || ^4.0.0"
}
},
"node_modules/zundo": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/zundo/-/zundo-2.3.0.tgz",
"integrity": "sha512-4GXYxXA17SIKYhVbWHdSEU04P697IMyVGXrC2TnzoyohEAWytFNOKqOp5gTGvaW93F/PM5Y0evbGtOPF0PWQwQ==",
"license": "MIT",
"funding": {
"type": "individual",
"url": "https://github.com/sponsors/charkour"
},
"peerDependencies": {
"zustand": "^4.3.0 || ^5.0.0"
},
"peerDependenciesMeta": {
"zustand": {
"optional": false
}
}
},
"node_modules/zustand": {
"version": "5.0.10",
"resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.10.tgz",

View File

@@ -13,11 +13,13 @@
"axios": "^1.13.4",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"immer": "^11.1.3",
"lucide-react": "^0.563.0",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-router-dom": "^7.13.0",
"tailwind-merge": "^3.4.0",
"zundo": "^2.3.0",
"zustand": "^5.0.10"
},
"devDependencies": {

View File

@@ -0,0 +1,101 @@
import { useEffect, useCallback, type ReactNode } from 'react'
import { X } from 'lucide-react'
import { cn } from '@/lib/utils'
interface ModalProps {
isOpen: boolean
onClose: () => void
title: string
children: ReactNode
/** Optional footer content that stays fixed at bottom (doesn't scroll) */
footer?: ReactNode
size?: 'sm' | 'md' | 'lg' | 'xl'
}
export function Modal({ isOpen, onClose, title, children, footer, size = 'md' }: ModalProps) {
// Close on Escape key
const handleKeyDown = useCallback(
(e: KeyboardEvent) => {
if (e.key === 'Escape') {
onClose()
}
},
[onClose]
)
useEffect(() => {
if (isOpen) {
document.addEventListener('keydown', handleKeyDown)
document.body.style.overflow = 'hidden'
}
return () => {
document.removeEventListener('keydown', handleKeyDown)
document.body.style.overflow = ''
}
}, [isOpen, handleKeyDown])
if (!isOpen) return null
const sizeClasses = {
sm: 'max-w-sm',
md: 'max-w-md',
lg: 'max-w-lg',
xl: 'max-w-4xl',
}
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center p-4"
role="dialog"
aria-modal="true"
aria-labelledby="modal-title"
>
{/* Backdrop */}
<div
className="absolute inset-0 bg-background/80 backdrop-blur-sm"
onClick={onClose}
aria-hidden="true"
/>
{/* Modal Content */}
<div
className={cn(
'relative flex max-h-[85vh] w-full flex-col rounded-lg border border-border bg-card shadow-lg',
sizeClasses[size]
)}
>
{/* Header - Fixed at top */}
<div className="flex flex-shrink-0 items-center justify-between border-b border-border px-6 py-4">
<h2 id="modal-title" className="text-lg font-semibold text-card-foreground">
{title}
</h2>
<button
onClick={onClose}
className={cn(
'rounded-md p-1 text-muted-foreground transition-colors',
'hover:bg-accent hover:text-accent-foreground',
'focus:outline-none focus:ring-2 focus:ring-ring'
)}
aria-label="Close modal"
>
<X className="h-5 w-5" />
</button>
</div>
{/* Body - Scrollable */}
<div className="flex-1 overflow-y-auto px-6 py-4">
{children}
</div>
{/* Footer - Fixed at bottom */}
{footer && (
<div className="flex-shrink-0 border-t border-border px-6 py-4">
{footer}
</div>
)}
</div>
</div>
)
}
export default Modal

View File

@@ -0,0 +1,112 @@
import type { ReactNode } from 'react'
import { Plus, Trash2, ChevronUp, ChevronDown } from 'lucide-react'
import { cn } from '@/lib/utils'
interface DynamicArrayFieldProps<T> {
items: T[]
onAdd: () => void
onRemove: (index: number) => void
onReorder?: (fromIndex: number, toIndex: number) => void
renderItem: (item: T, index: number) => ReactNode
addLabel?: string
maxItems?: number
minItems?: number
className?: string
}
export function DynamicArrayField<T>({
items,
onAdd,
onRemove,
onReorder,
renderItem,
addLabel = 'Add Item',
maxItems,
minItems = 0,
className
}: DynamicArrayFieldProps<T>) {
const canAdd = maxItems === undefined || items.length < maxItems
const canRemove = items.length > minItems
const handleMoveUp = (index: number) => {
if (onReorder && index > 0) {
onReorder(index, index - 1)
}
}
const handleMoveDown = (index: number) => {
if (onReorder && index < items.length - 1) {
onReorder(index, index + 1)
}
}
return (
<div className={cn('space-y-2', className)}>
{items.map((item, index) => (
<div key={index} className="group flex items-start gap-2">
{/* Reorder buttons */}
{onReorder && items.length > 1 && (
<div className="flex flex-col gap-0.5 opacity-0 group-hover:opacity-100">
<button
type="button"
onClick={() => handleMoveUp(index)}
disabled={index === 0}
className="rounded p-0.5 text-muted-foreground hover:bg-accent hover:text-accent-foreground disabled:opacity-30"
title="Move up"
>
<ChevronUp className="h-3 w-3" />
</button>
<button
type="button"
onClick={() => handleMoveDown(index)}
disabled={index === items.length - 1}
className="rounded p-0.5 text-muted-foreground hover:bg-accent hover:text-accent-foreground disabled:opacity-30"
title="Move down"
>
<ChevronDown className="h-3 w-3" />
</button>
</div>
)}
{/* Item content */}
<div className="flex-1">{renderItem(item, index)}</div>
{/* Remove button */}
{canRemove && (
<button
type="button"
onClick={() => onRemove(index)}
className="mt-1 rounded p-1 text-muted-foreground hover:bg-destructive/20 hover:text-destructive"
title="Remove"
>
<Trash2 className="h-4 w-4" />
</button>
)}
</div>
))}
{/* Add button */}
{canAdd && (
<button
type="button"
onClick={onAdd}
className={cn(
'flex w-full items-center justify-center gap-1 rounded-md border border-dashed border-input',
'px-3 py-2 text-sm text-muted-foreground',
'hover:border-primary hover:text-primary'
)}
>
<Plus className="h-4 w-4" />
{addLabel}
</button>
)}
{/* Empty state */}
{items.length === 0 && !canAdd && (
<p className="text-center text-sm text-muted-foreground">No items</p>
)}
</div>
)
}
export default DynamicArrayField

View File

@@ -0,0 +1,85 @@
import { Modal } from '@/components/common/Modal'
import { useTreeEditorStore } from '@/store/treeEditorStore'
import { NodeFormDecision } from './NodeFormDecision'
import { NodeFormAction } from './NodeFormAction'
import { NodeFormResolution } from './NodeFormResolution'
import type { TreeStructure } from '@/types'
interface NodeEditorModalProps {
node: TreeStructure
onClose: () => void
}
export function NodeEditorModal({ node, onClose }: NodeEditorModalProps) {
const { updateNode, validationErrors } = useTreeEditorStore()
const nodeErrors = validationErrors.filter(e => e.nodeId === node.id)
const handleUpdate = (updates: Partial<TreeStructure>) => {
updateNode(node.id, updates)
}
const getTitle = () => {
switch (node.type) {
case 'decision':
return 'Edit Decision Node'
case 'action':
return 'Edit Action Node'
case 'solution':
return 'Edit Solution Node'
default:
return 'Edit Node'
}
}
const footerContent = (
<div className="flex justify-end">
<button
type="button"
onClick={onClose}
className="rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90"
>
Done
</button>
</div>
)
return (
<Modal isOpen={true} onClose={onClose} title={getTitle()} size="lg" footer={footerContent}>
{/* Node ID display */}
<div className="mb-4 text-xs text-muted-foreground">
Node ID: <code className="rounded bg-muted px-1 py-0.5">{node.id}</code>
</div>
{/* Validation errors */}
{nodeErrors.length > 0 && (
<div className="mb-4 space-y-1">
{nodeErrors.map((error, i) => (
<div
key={i}
className={`rounded-md px-3 py-2 text-sm ${
error.severity === 'error'
? 'bg-destructive/10 text-destructive'
: 'bg-yellow-500/10 text-yellow-600 dark:text-yellow-400'
}`}
>
{error.message}
</div>
))}
</div>
)}
{/* Type-specific form */}
{node.type === 'decision' && (
<NodeFormDecision node={node} onUpdate={handleUpdate} />
)}
{node.type === 'action' && (
<NodeFormAction node={node} onUpdate={handleUpdate} />
)}
{node.type === 'solution' && (
<NodeFormResolution node={node} onUpdate={handleUpdate} />
)}
</Modal>
)
}
export default NodeEditorModal

View File

@@ -0,0 +1,152 @@
import { DynamicArrayField } from './DynamicArrayField'
import { NodePicker } from './NodePicker'
import { useTreeEditorStore } from '@/store/treeEditorStore'
import type { TreeStructure } from '@/types'
import { cn } from '@/lib/utils'
interface NodeFormActionProps {
node: TreeStructure
onUpdate: (updates: Partial<TreeStructure>) => void
}
export function NodeFormAction({ node, onUpdate }: NodeFormActionProps) {
const { validationErrors } = useTreeEditorStore()
const titleError = validationErrors.find(
e => e.nodeId === node.id && e.field === 'title'
)
const nextNodeError = validationErrors.find(
e => e.nodeId === node.id && e.field === 'next_node_id'
)
const handleAddCommand = () => {
onUpdate({
commands: [...(node.commands || []), '']
})
}
const handleRemoveCommand = (index: number) => {
const newCommands = [...(node.commands || [])]
newCommands.splice(index, 1)
onUpdate({ commands: newCommands })
}
const handleUpdateCommand = (index: number, value: string) => {
const newCommands = [...(node.commands || [])]
newCommands[index] = value
onUpdate({ commands: newCommands })
}
const handleReorderCommands = (fromIndex: number, toIndex: number) => {
const newCommands = [...(node.commands || [])]
const [moved] = newCommands.splice(fromIndex, 1)
newCommands.splice(toIndex, 0, moved)
onUpdate({ commands: newCommands })
}
return (
<div className="space-y-4">
{/* Title */}
<div>
<label className="block text-sm font-medium text-foreground">
Title <span className="text-destructive">*</span>
</label>
<input
type="text"
value={node.title || ''}
onChange={(e) => onUpdate({ title: e.target.value })}
placeholder="e.g., Restart the Service"
className={cn(
'mt-1 block w-full rounded-md border px-3 py-2 text-sm',
'bg-background text-foreground placeholder:text-muted-foreground',
'focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary',
titleError ? 'border-destructive' : 'border-input'
)}
/>
{titleError && (
<p className="mt-1 text-xs text-destructive">{titleError.message}</p>
)}
</div>
{/* Description */}
<div>
<label className="block text-sm font-medium text-foreground">
Description
</label>
<textarea
value={node.description || ''}
onChange={(e) => onUpdate({ description: e.target.value })}
placeholder="Detailed instructions for this action..."
rows={3}
className={cn(
'mt-1 block w-full rounded-md border border-input px-3 py-2 text-sm',
'bg-background text-foreground placeholder:text-muted-foreground',
'focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary'
)}
/>
</div>
{/* Commands */}
<div>
<label className="block text-sm font-medium text-foreground">
Commands
</label>
<p className="mb-2 text-xs text-muted-foreground">
PowerShell or CLI commands to execute
</p>
<DynamicArrayField
items={node.commands || []}
onAdd={handleAddCommand}
onRemove={handleRemoveCommand}
onReorder={handleReorderCommands}
addLabel="Add Command"
renderItem={(command, index) => (
<input
type="text"
value={command}
onChange={(e) => handleUpdateCommand(index, e.target.value)}
placeholder="e.g., Get-Service BrokerAgent"
className={cn(
'block w-full rounded-md border border-input px-3 py-2 font-mono text-sm',
'bg-background text-foreground placeholder:text-muted-foreground',
'focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary'
)}
/>
)}
/>
</div>
{/* Expected Outcome */}
<div>
<label className="block text-sm font-medium text-foreground">
Expected Outcome
</label>
<input
type="text"
value={node.expected_outcome || ''}
onChange={(e) => onUpdate({ expected_outcome: e.target.value })}
placeholder="e.g., Service should show as Running"
className={cn(
'mt-1 block w-full rounded-md border border-input px-3 py-2 text-sm',
'bg-background text-foreground placeholder:text-muted-foreground',
'focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary'
)}
/>
</div>
{/* Next Node */}
<NodePicker
value={node.next_node_id || ''}
onChange={(nodeId) => onUpdate({ next_node_id: nodeId })}
parentNodeId={node.id}
excludeNodeId={node.id}
label="Next Node (after action)"
placeholder="Select or create next node..."
error={nextNodeError?.message}
/>
</div>
)
}
export default NodeFormAction

View File

@@ -0,0 +1,220 @@
import { Play } from 'lucide-react'
import { DynamicArrayField } from './DynamicArrayField'
import { NodePicker } from './NodePicker'
import { useTreeEditorStore } from '@/store/treeEditorStore'
import type { TreeStructure, TreeOption } from '@/types'
import { cn } from '@/lib/utils'
interface NodeFormDecisionProps {
node: TreeStructure
onUpdate: (updates: Partial<TreeStructure>) => void
}
// Convert index to letter (0=A, 1=B, 2=C, etc.)
const indexToLetter = (index: number): string => {
return String.fromCharCode(65 + index) // 65 is ASCII for 'A'
}
export function NodeFormDecision({ node, onUpdate }: NodeFormDecisionProps) {
const { reorderOptions, validationErrors } = useTreeEditorStore()
const isRootNode = node.id === 'root'
const questionError = validationErrors.find(
e => e.nodeId === node.id && e.field === 'question'
)
const optionsError = validationErrors.find(
e => e.nodeId === node.id && e.field === 'options'
)
const handleAddOption = () => {
const newOption: TreeOption = {
id: crypto.randomUUID(),
label: '',
next_node_id: ''
}
onUpdate({
options: [...(node.options || []), newOption]
})
}
const handleRemoveOption = (index: number) => {
const newOptions = [...(node.options || [])]
newOptions.splice(index, 1)
onUpdate({ options: newOptions })
}
const handleUpdateOption = (index: number, updates: Partial<TreeOption>) => {
const newOptions = [...(node.options || [])]
newOptions[index] = { ...newOptions[index], ...updates }
onUpdate({ options: newOptions })
}
const handleReorderOptions = (fromIndex: number, toIndex: number) => {
reorderOptions(node.id, fromIndex, toIndex)
}
return (
<div className="space-y-4">
{/* Root node banner */}
{isRootNode && (
<div className="rounded-lg border-2 border-blue-500/30 bg-blue-500/10 p-4">
<div className="flex items-start gap-3">
<div className="rounded-full bg-blue-500/20 p-2">
<Play className="h-5 w-5 text-blue-500" />
</div>
<div>
<h3 className="font-semibold text-blue-600 dark:text-blue-400">
Starting Question
</h3>
<p className="mt-1 text-sm text-muted-foreground">
This is the first question users will see when they start this troubleshooting tree.
Each option below creates a different troubleshooting path.
</p>
</div>
</div>
</div>
)}
{/* Question */}
<div>
<label className="block text-sm font-medium text-foreground">
{isRootNode ? 'Starting Question' : 'Question'} <span className="text-destructive">*</span>
</label>
{isRootNode && (
<p className="mt-0.5 text-xs text-muted-foreground">
What's the main question to diagnose the issue?
</p>
)}
<input
type="text"
value={node.question || ''}
onChange={(e) => onUpdate({ question: e.target.value })}
placeholder={isRootNode
? "e.g., What type of issue are you experiencing?"
: "e.g., Can you ping the server?"}
className={cn(
'mt-1 block w-full rounded-md border px-3 py-2 text-sm',
'bg-background text-foreground placeholder:text-muted-foreground',
'focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary',
questionError ? 'border-destructive' : 'border-input'
)}
/>
{questionError && (
<p className="mt-1 text-xs text-destructive">{questionError.message}</p>
)}
</div>
{/* Help Text */}
<div>
<label className="block text-sm font-medium text-foreground">
Help Text
</label>
<textarea
value={node.help_text || ''}
onChange={(e) => onUpdate({ help_text: e.target.value })}
placeholder="Additional context or instructions for this decision..."
rows={2}
className={cn(
'mt-1 block w-full rounded-md border border-input px-3 py-2 text-sm',
'bg-background text-foreground placeholder:text-muted-foreground',
'focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary'
)}
/>
</div>
{/* Options */}
<div>
<label className="block text-sm font-medium text-foreground">
{isRootNode ? 'Answer Options (Branches)' : 'Options'} <span className="text-destructive">*</span>
</label>
{isRootNode ? (
<p className="mt-0.5 text-xs text-muted-foreground">
Add as many options as needed (A, B, C, D...). Each option leads to a completely different troubleshooting path.
</p>
) : (
<p className="mt-0.5 text-xs text-muted-foreground">
Each option can branch to a different next step.
</p>
)}
{optionsError && (
<p className="mt-1 text-xs text-destructive">{optionsError.message}</p>
)}
<div className="mt-2">
<DynamicArrayField
items={node.options || []}
onAdd={handleAddOption}
onRemove={handleRemoveOption}
onReorder={handleReorderOptions}
addLabel={isRootNode ? "Add Another Branch" : "Add Option"}
minItems={1}
renderItem={(option, index) => {
const optionLabelError = validationErrors.find(
e => e.nodeId === node.id && e.field === `options[${index}].label`
)
const optionNextError = validationErrors.find(
e => e.nodeId === node.id && e.field === `options[${index}].next_node_id`
)
const letter = indexToLetter(index)
return (
<div className="rounded-md border border-input bg-muted/30 p-3">
<div className="mb-2 flex items-center gap-2">
{/* Letter badge */}
<span className={cn(
'flex h-6 w-6 items-center justify-center rounded-full text-xs font-bold',
isRootNode
? 'bg-blue-500/20 text-blue-600 dark:text-blue-400'
: 'bg-muted text-muted-foreground'
)}>
{letter}
</span>
<input
type="text"
value={option.label}
onChange={(e) => handleUpdateOption(index, { label: e.target.value })}
placeholder={isRootNode
? `Branch ${letter}: e.g., "Network Issues", "Application Errors"...`
: `Option ${letter} label`}
className={cn(
'block flex-1 rounded-md border px-3 py-2 text-sm',
'bg-background text-foreground placeholder:text-muted-foreground',
'focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary',
optionLabelError ? 'border-destructive' : 'border-input'
)}
/>
</div>
{optionLabelError && (
<p className="mb-2 text-xs text-destructive">{optionLabelError.message}</p>
)}
<div className="pl-8">
<NodePicker
value={option.next_node_id}
onChange={(nodeId) => handleUpdateOption(index, { next_node_id: nodeId })}
parentNodeId={node.id}
excludeNodeId={node.id}
placeholder={isRootNode
? `What happens when user selects "${option.label || `Branch ${letter}`}"?`
: "Select or create next node..."}
error={optionNextError?.message}
/>
</div>
</div>
)
}}
/>
</div>
{/* Example hint for root node */}
{isRootNode && (node.options?.length || 0) < 2 && (
<div className="mt-3 rounded-md border border-dashed border-muted-foreground/30 bg-muted/20 p-3 text-xs text-muted-foreground">
<strong>Tip:</strong> Most troubleshooting trees start with 2-5 main branches.
For example: "Connection Issues", "Performance Problems", "Error Messages", "Other".
</div>
)}
</div>
</div>
)
}
export default NodeFormDecision

View File

@@ -0,0 +1,129 @@
import { DynamicArrayField } from './DynamicArrayField'
import { useTreeEditorStore } from '@/store/treeEditorStore'
import type { TreeStructure } from '@/types'
import { cn } from '@/lib/utils'
interface NodeFormResolutionProps {
node: TreeStructure
onUpdate: (updates: Partial<TreeStructure>) => void
}
export function NodeFormResolution({ node, onUpdate }: NodeFormResolutionProps) {
const { validationErrors } = useTreeEditorStore()
const titleError = validationErrors.find(
e => e.nodeId === node.id && e.field === 'title'
)
const handleAddStep = () => {
onUpdate({
resolution_steps: [...(node.resolution_steps || []), '']
})
}
const handleRemoveStep = (index: number) => {
const newSteps = [...(node.resolution_steps || [])]
newSteps.splice(index, 1)
onUpdate({ resolution_steps: newSteps })
}
const handleUpdateStep = (index: number, value: string) => {
const newSteps = [...(node.resolution_steps || [])]
newSteps[index] = value
onUpdate({ resolution_steps: newSteps })
}
const handleReorderSteps = (fromIndex: number, toIndex: number) => {
const newSteps = [...(node.resolution_steps || [])]
const [moved] = newSteps.splice(fromIndex, 1)
newSteps.splice(toIndex, 0, moved)
onUpdate({ resolution_steps: newSteps })
}
return (
<div className="space-y-4">
{/* Title */}
<div>
<label className="block text-sm font-medium text-foreground">
Title <span className="text-destructive">*</span>
</label>
<input
type="text"
value={node.title || ''}
onChange={(e) => onUpdate({ title: e.target.value })}
placeholder="e.g., VDA Successfully Registered"
className={cn(
'mt-1 block w-full rounded-md border px-3 py-2 text-sm',
'bg-background text-foreground placeholder:text-muted-foreground',
'focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary',
titleError ? 'border-destructive' : 'border-input'
)}
/>
{titleError && (
<p className="mt-1 text-xs text-destructive">{titleError.message}</p>
)}
</div>
{/* Description */}
<div>
<label className="block text-sm font-medium text-foreground">
Description
</label>
<textarea
value={node.description || ''}
onChange={(e) => onUpdate({ description: e.target.value })}
placeholder="Summary of the resolution and any follow-up recommendations..."
rows={3}
className={cn(
'mt-1 block w-full rounded-md border border-input px-3 py-2 text-sm',
'bg-background text-foreground placeholder:text-muted-foreground',
'focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary'
)}
/>
</div>
{/* Resolution Steps */}
<div>
<label className="block text-sm font-medium text-foreground">
Resolution Steps
</label>
<p className="mb-2 text-xs text-muted-foreground">
Step-by-step instructions for resolving the issue
</p>
<DynamicArrayField
items={node.resolution_steps || []}
onAdd={handleAddStep}
onRemove={handleRemoveStep}
onReorder={handleReorderSteps}
addLabel="Add Step"
renderItem={(step, index) => (
<div className="flex items-start gap-2">
<span className="mt-2 flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-primary/10 text-xs font-medium text-primary">
{index + 1}
</span>
<input
type="text"
value={step}
onChange={(e) => handleUpdateStep(index, e.target.value)}
placeholder={`Step ${index + 1}`}
className={cn(
'block w-full rounded-md border border-input px-3 py-2 text-sm',
'bg-background text-foreground placeholder:text-muted-foreground',
'focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary'
)}
/>
</div>
)}
/>
</div>
{/* Note about terminal node */}
<div className="rounded-md bg-green-500/10 p-3 text-sm text-green-600 dark:text-green-400">
<strong>Note:</strong> Solution nodes are terminal - they end the troubleshooting flow.
The session will be marked complete when the user reaches this node.
</div>
</div>
)
}
export default NodeFormResolution

View File

@@ -0,0 +1,499 @@
import { useState } from 'react'
import {
Plus,
Pencil,
Trash2,
Copy,
GripVertical,
HelpCircle,
Zap,
CheckCircle,
ChevronDown,
ChevronRight,
Play
} from 'lucide-react'
import { useTreeEditorStore } from '@/store/treeEditorStore'
import { NodeEditorModal } from './NodeEditorModal'
import type { TreeStructure, NodeType } from '@/types'
import { cn } from '@/lib/utils'
interface NodeListItemProps {
node: TreeStructure
depth: number
parentId: string | null
index: number
isLast: boolean
/** Which option label led to this node (from parent decision) */
fromOption?: string
onEdit: (node: TreeStructure) => void
onDelete: (nodeId: string) => void
onDuplicate: (nodeId: string) => void
onAddChild: (parentId: string) => void
onDragStart: (e: React.DragEvent, nodeId: string, parentId: string | null, index: number) => void
onDragOver: (e: React.DragEvent, parentId: string | null, index: number) => void
onDrop: (e: React.DragEvent, parentId: string | null, index: number) => void
dragOverTarget: { parentId: string | null; index: number } | null
/** Array of booleans indicating which ancestor levels should show continuing lines */
ancestorLines?: boolean[]
}
function NodeListItem({
node,
depth,
parentId,
index,
isLast,
fromOption,
onEdit,
onDelete,
onDuplicate,
onAddChild,
onDragStart,
onDragOver,
onDrop,
dragOverTarget,
ancestorLines = []
}: NodeListItemProps) {
const { selectedNodeId, selectNode, validationErrors } = useTreeEditorStore()
const [isCollapsed, setIsCollapsed] = useState(false)
const isSelected = selectedNodeId === node.id
const isRootNode = node.id === 'root'
const hasError = validationErrors.some(e => e.nodeId === node.id && e.severity === 'error')
const hasWarning = validationErrors.some(e => e.nodeId === node.id && e.severity === 'warning')
const hasChildren = node.children && node.children.length > 0
const isDragTarget =
dragOverTarget?.parentId === parentId && dragOverTarget?.index === index
const nodeTypeIcons: Record<NodeType, React.ReactNode> = {
decision: <HelpCircle className="h-4 w-4" />,
action: <Zap className="h-4 w-4" />,
solution: <CheckCircle className="h-4 w-4" />
}
const nodeTypeColors: Record<NodeType, string> = {
decision: 'bg-blue-500/20 text-blue-600 dark:text-blue-400',
action: 'bg-yellow-500/20 text-yellow-600 dark:text-yellow-400',
solution: 'bg-green-500/20 text-green-600 dark:text-green-400'
}
const getNodeLabel = () => {
if (node.type === 'decision') return node.question || 'Untitled Question'
return node.title || `Untitled ${node.type}`
}
// Find which option label leads to each child node
const getOptionLabelForChild = (childId: string): string | undefined => {
if (node.type === 'decision' && node.options) {
const option = node.options.find(opt => opt.next_node_id === childId)
return option?.label
}
return undefined
}
const toggleCollapse = (e: React.MouseEvent) => {
e.stopPropagation()
setIsCollapsed(!isCollapsed)
}
// Build tree line prefix for proper hierarchy visualization
const renderTreeLines = () => {
if (depth === 0) return null
return (
<div className="flex items-center">
{/* Render continuing lines from ancestors */}
{ancestorLines.map((showLine, i) => (
<span
key={i}
className={cn(
'inline-block w-5 text-center text-muted-foreground/50',
showLine ? 'border-l border-muted-foreground/30' : ''
)}
>
&nbsp;
</span>
))}
{/* Render current level connector */}
<span className="inline-block w-5 text-center text-muted-foreground/50 font-mono text-xs">
{isLast ? '└' : '├'}
</span>
<span className="inline-block w-3 text-muted-foreground/50 font-mono text-xs"></span>
</div>
)
}
return (
<>
{/* Drop indicator above */}
{isDragTarget && (
<div className="h-1 bg-primary rounded-full mx-2" style={{ marginLeft: `${depth * 20 + 8}px` }} />
)}
<div
draggable={node.id !== 'root'}
onDragStart={(e) => onDragStart(e, node.id, parentId, index)}
onDragOver={(e) => onDragOver(e, parentId, index)}
onDrop={(e) => onDrop(e, parentId, index)}
onClick={() => selectNode(node.id)}
className={cn(
'group flex items-center gap-1 rounded-md px-2 py-1.5 text-sm transition-colors cursor-pointer',
isRootNode
? isSelected
? 'bg-blue-500/20 ring-2 ring-blue-500 shadow-sm'
: 'bg-blue-500/10 border border-blue-500/30 hover:bg-blue-500/15'
: isSelected
? 'bg-primary/10 ring-1 ring-primary'
: 'hover:bg-accent',
hasError && 'ring-1 ring-destructive',
hasWarning && !hasError && 'ring-1 ring-yellow-500'
)}
>
{/* Tree lines */}
{renderTreeLines()}
{/* Collapse toggle for nodes with children */}
{hasChildren ? (
<button
type="button"
onClick={toggleCollapse}
className="rounded p-0.5 hover:bg-muted"
>
{isCollapsed ? (
<ChevronRight className="h-3.5 w-3.5 text-muted-foreground" />
) : (
<ChevronDown className="h-3.5 w-3.5 text-muted-foreground" />
)}
</button>
) : (
<div className="w-4" />
)}
{/* Drag handle */}
{node.id !== 'root' && (
<GripVertical className="h-4 w-4 cursor-grab text-muted-foreground opacity-0 group-hover:opacity-100" />
)}
{node.id === 'root' && <div className="w-4" />}
{/* Node type icon - special treatment for root */}
{isRootNode ? (
<span className="flex items-center gap-1 rounded px-1.5 py-0.5 text-xs bg-blue-500/30 text-blue-600 dark:text-blue-400 font-semibold">
<Play className="h-4 w-4" />
<span className="hidden sm:inline">START</span>
</span>
) : (
<span className={cn('flex items-center gap-1 rounded px-1.5 py-0.5 text-xs', nodeTypeColors[node.type])}>
{nodeTypeIcons[node.type]}
<span className="hidden sm:inline">{node.type}</span>
</span>
)}
{/* From option label */}
{fromOption && (
<span className="rounded bg-muted px-1.5 py-0.5 text-[10px] text-muted-foreground">
{fromOption}
</span>
)}
{/* Node label */}
<span className="flex-1 truncate text-foreground">
{getNodeLabel()}
</span>
{/* Node ID */}
<span
className="hidden text-xs text-muted-foreground sm:inline cursor-help"
title={`Full ID: ${node.id}`}
>
{node.id === 'root' ? 'root' : node.id.slice(0, 8) + '...'}
</span>
{/* Actions */}
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100">
{node.type === 'decision' && (
<button
type="button"
onClick={(e) => {
e.stopPropagation()
onAddChild(node.id)
}}
title="Add child node"
className="rounded p-1 text-muted-foreground hover:bg-accent hover:text-accent-foreground"
>
<Plus className="h-3 w-3" />
</button>
)}
<button
type="button"
onClick={(e) => {
e.stopPropagation()
onEdit(node)
}}
title="Edit node"
className="rounded p-1 text-muted-foreground hover:bg-accent hover:text-accent-foreground"
>
<Pencil className="h-3 w-3" />
</button>
{node.id !== 'root' && (
<>
<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-accent-foreground"
>
<Copy className="h-3 w-3" />
</button>
<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 w-3" />
</button>
</>
)}
</div>
</div>
{/* Collapsed indicator */}
{hasChildren && isCollapsed && (
<div
className="text-xs text-muted-foreground py-1"
style={{ marginLeft: `${(depth + 1) * 20 + 32}px` }}
>
<span className="rounded bg-muted px-2 py-0.5">
{node.children!.length} hidden
</span>
</div>
)}
{/* Render children */}
{!isCollapsed && node.children?.map((child, childIndex) => {
const optionLabel = getOptionLabelForChild(child.id)
const isLastChild = childIndex === node.children!.length - 1
// Build ancestor lines array for children
const childAncestorLines = depth > 0 ? [...ancestorLines, !isLast] : []
return (
<NodeListItem
key={child.id}
node={child}
depth={depth + 1}
parentId={node.id}
index={childIndex}
isLast={isLastChild}
fromOption={optionLabel}
onEdit={onEdit}
onDelete={onDelete}
onDuplicate={onDuplicate}
onAddChild={onAddChild}
onDragStart={onDragStart}
onDragOver={onDragOver}
onDrop={onDrop}
dragOverTarget={dragOverTarget}
ancestorLines={childAncestorLines}
/>
)
})}
</>
)
}
export function NodeList() {
const { treeStructure, addNode, deleteNode, duplicateNode, reorderNodes, findNode } = useTreeEditorStore()
const [editingNodeId, setEditingNodeId] = useState<string | null>(null)
const [addingToParent, setAddingToParent] = useState<string | null>(null)
// Get the current node from store (will update when store changes)
const editingNode = editingNodeId ? findNode(editingNodeId) : null
const [dragState, setDragState] = useState<{
nodeId: string
parentId: string | null
index: number
} | null>(null)
const [dragOverTarget, setDragOverTarget] = useState<{
parentId: string | null
index: number
} | null>(null)
const handleAddNode = (type: NodeType) => {
const newId = addNode(addingToParent, type)
setAddingToParent(null)
// Open editor for the new node
setEditingNodeId(newId)
}
const handleDragStart = (
e: React.DragEvent,
nodeId: string,
parentId: string | null,
index: number
) => {
e.dataTransfer.effectAllowed = 'move'
setDragState({ nodeId, parentId, index })
}
const handleDragOver = (
e: React.DragEvent,
parentId: string | null,
index: number
) => {
e.preventDefault()
if (!dragState) return
// Don't allow dropping on itself or its descendants
if (dragState.nodeId === parentId) return
setDragOverTarget({ parentId, index })
}
const handleDrop = (
e: React.DragEvent,
targetParentId: string | null,
targetIndex: number
) => {
e.preventDefault()
if (!dragState) return
const { parentId: sourceParentId, index: sourceIndex } = dragState
// Only handle reordering within same parent for now
if (sourceParentId === targetParentId && sourceParentId) {
const adjustedIndex = sourceIndex < targetIndex ? targetIndex - 1 : targetIndex
if (sourceIndex !== adjustedIndex) {
reorderNodes(sourceParentId, sourceIndex, adjustedIndex)
}
}
setDragState(null)
setDragOverTarget(null)
}
const handleDragEnd = () => {
setDragState(null)
setDragOverTarget(null)
}
if (!treeStructure) {
return (
<div className="rounded-lg border border-border bg-card p-4 text-center text-sm text-muted-foreground">
No tree structure. Add a root node to get started.
</div>
)
}
return (
<div className="rounded-lg border border-border bg-card" onDragEnd={handleDragEnd}>
<div className="flex items-center justify-between border-b border-border p-3">
<h2 className="text-sm font-semibold text-card-foreground">Nodes</h2>
<button
type="button"
onClick={() => setAddingToParent(treeStructure.id)}
className={cn(
'flex items-center gap-1 rounded-md px-2 py-1 text-xs font-medium',
'bg-primary text-primary-foreground hover:bg-primary/90'
)}
>
<Plus className="h-3 w-3" />
Add Node
</button>
</div>
<div className="max-h-[500px] space-y-0.5 overflow-y-auto p-2">
<NodeListItem
node={treeStructure}
depth={0}
parentId={null}
index={0}
isLast={true}
onEdit={(node) => setEditingNodeId(node.id)}
onDelete={deleteNode}
onDuplicate={duplicateNode}
onAddChild={setAddingToParent}
onDragStart={handleDragStart}
onDragOver={handleDragOver}
onDrop={handleDrop}
dragOverTarget={dragOverTarget}
/>
</div>
{/* Add Node Type Selector */}
{addingToParent && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-background/80 backdrop-blur-sm">
<div className="w-full max-w-xs rounded-lg border border-border bg-card p-4 shadow-lg">
<h3 className="mb-3 text-sm font-semibold">Select Node Type</h3>
<div className="space-y-2">
<button
type="button"
onClick={() => handleAddNode('decision')}
className={cn(
'flex w-full items-center gap-2 rounded-md px-3 py-2 text-left text-sm',
'border border-blue-500/30 bg-blue-500/10 hover:bg-blue-500/20'
)}
>
<HelpCircle className="h-4 w-4 text-blue-500" />
<div>
<div className="font-medium">Decision</div>
<div className="text-xs text-muted-foreground">Question with options</div>
</div>
</button>
<button
type="button"
onClick={() => handleAddNode('action')}
className={cn(
'flex w-full items-center gap-2 rounded-md px-3 py-2 text-left text-sm',
'border border-yellow-500/30 bg-yellow-500/10 hover:bg-yellow-500/20'
)}
>
<Zap className="h-4 w-4 text-yellow-500" />
<div>
<div className="font-medium">Action</div>
<div className="text-xs text-muted-foreground">Task to perform</div>
</div>
</button>
<button
type="button"
onClick={() => handleAddNode('solution')}
className={cn(
'flex w-full items-center gap-2 rounded-md px-3 py-2 text-left text-sm',
'border border-green-500/30 bg-green-500/10 hover:bg-green-500/20'
)}
>
<CheckCircle className="h-4 w-4 text-green-500" />
<div>
<div className="font-medium">Solution</div>
<div className="text-xs text-muted-foreground">Resolution endpoint</div>
</div>
</button>
</div>
<button
type="button"
onClick={() => setAddingToParent(null)}
className="mt-3 w-full rounded-md border border-input px-3 py-2 text-sm hover:bg-accent"
>
Cancel
</button>
</div>
</div>
)}
{/* Node Editor Modal */}
{editingNode && (
<NodeEditorModal
node={editingNode}
onClose={() => setEditingNodeId(null)}
/>
)}
</div>
)
}
export default NodeList

View File

@@ -0,0 +1,156 @@
import { useMemo } from 'react'
import { useTreeEditorStore } from '@/store/treeEditorStore'
import type { NodeType } from '@/types'
import { cn } from '@/lib/utils'
// Special values for creating new nodes
const CREATE_PREFIX = '__create_'
const CREATE_DECISION = `${CREATE_PREFIX}decision__`
const CREATE_ACTION = `${CREATE_PREFIX}action__`
const CREATE_SOLUTION = `${CREATE_PREFIX}solution__`
// Unicode symbols for node types (works in select options)
const NODE_TYPE_SYMBOLS: Record<NodeType, string> = {
decision: 'ⓘ', // Information/question symbol
action: '⚡', // Lightning bolt for action
solution: '✓' // Checkmark for solution
}
interface NodePickerProps {
value: string
onChange: (nodeId: string) => void
/** The parent node ID - new nodes will be added as children of this node */
parentNodeId: string
excludeNodeId?: string
placeholder?: string
className?: string
label?: string
error?: string
/** Callback when a new node is created (receives the new node ID) */
onNodeCreated?: (nodeId: string) => void
}
export function NodePicker({
value,
onChange,
parentNodeId,
excludeNodeId,
placeholder = 'Select a node...',
className,
label,
error,
onNodeCreated
}: NodePickerProps) {
const { getAvailableTargetNodes, addNode } = useTreeEditorStore()
const availableNodes = getAvailableTargetNodes(excludeNodeId)
// Group nodes by type
const groupedNodes = useMemo(() => {
const decisions = availableNodes.filter(n => n.type === 'decision')
const actions = availableNodes.filter(n => n.type === 'action')
const solutions = availableNodes.filter(n => n.type === 'solution')
return { decisions, actions, solutions }
}, [availableNodes])
const handleChange = (selectedValue: string) => {
// Check if it's a "create new" option
if (selectedValue.startsWith(CREATE_PREFIX)) {
let nodeType: NodeType
if (selectedValue === CREATE_DECISION) {
nodeType = 'decision'
} else if (selectedValue === CREATE_ACTION) {
nodeType = 'action'
} else if (selectedValue === CREATE_SOLUTION) {
nodeType = 'solution'
} else {
return
}
// Create the new node as a child of the parent
const newNodeId = addNode(parentNodeId, nodeType)
// Set this new node as the selected value
onChange(newNodeId)
// Notify parent if callback provided
onNodeCreated?.(newNodeId)
} else {
// Normal selection
onChange(selectedValue)
}
}
// Find the label for the currently selected node
const selectedNode = availableNodes.find(n => n.id === value)
return (
<div className={className}>
{label && (
<label className="mb-1 block text-sm font-medium text-foreground">
{label}
</label>
)}
<select
value={value || ''}
onChange={(e) => handleChange(e.target.value)}
className={cn(
'block w-full rounded-md border px-3 py-2 text-sm',
'bg-background text-foreground',
'focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary',
error ? 'border-destructive' : 'border-input'
)}
>
<option value="">{placeholder}</option>
{/* Create new options */}
<optgroup label="Create New Node">
<option value={CREATE_DECISION}>+ New Decision (question)</option>
<option value={CREATE_ACTION}>+ New Action (task)</option>
<option value={CREATE_SOLUTION}>+ New Solution (endpoint)</option>
</optgroup>
{/* Existing nodes grouped by type */}
{groupedNodes.decisions.length > 0 && (
<optgroup label="── Decisions ──">
{groupedNodes.decisions.map((node) => (
<option key={node.id} value={node.id}>
{NODE_TYPE_SYMBOLS.decision} {node.label}
</option>
))}
</optgroup>
)}
{groupedNodes.actions.length > 0 && (
<optgroup label="── Actions ──">
{groupedNodes.actions.map((node) => (
<option key={node.id} value={node.id}>
{NODE_TYPE_SYMBOLS.action} {node.label}
</option>
))}
</optgroup>
)}
{groupedNodes.solutions.length > 0 && (
<optgroup label="── Solutions ──">
{groupedNodes.solutions.map((node) => (
<option key={node.id} value={node.id}>
{NODE_TYPE_SYMBOLS.solution} {node.label}
</option>
))}
</optgroup>
)}
</select>
{/* Show what's selected */}
{value && selectedNode && (
<p className="mt-1 text-xs text-muted-foreground">
{selectedNode.label}
</p>
)}
{error && <p className="mt-1 text-xs text-destructive">{error}</p>}
</div>
)
}
export default NodePicker

View File

@@ -0,0 +1,44 @@
import { TreeMetadataForm } from './TreeMetadataForm'
import { NodeList } from './NodeList'
import { TreePreviewPanel } from '@/components/tree-preview/TreePreviewPanel'
import { cn } from '@/lib/utils'
interface TreeEditorLayoutProps {
isMobile?: boolean
}
export function TreeEditorLayout({ isMobile = false }: TreeEditorLayoutProps) {
return (
<div
className={cn(
'flex flex-1 overflow-hidden',
isMobile ? 'flex-col' : 'flex-row'
)}
>
{/* Left Panel - Form Editor */}
<div
className={cn(
'flex flex-col overflow-y-auto border-border bg-background',
isMobile ? 'h-full w-full border-b' : 'w-3/5 border-r'
)}
>
<div className="space-y-4 p-4">
<TreeMetadataForm />
<NodeList />
</div>
</div>
{/* Right Panel - Preview */}
<div
className={cn(
'flex-1 overflow-hidden bg-muted/30',
isMobile ? 'hidden' : 'block'
)}
>
<TreePreviewPanel />
</div>
</div>
)
}
export default TreeEditorLayout

View File

@@ -0,0 +1,132 @@
import { useEffect, useState } from 'react'
import { treesApi } from '@/api'
import { useTreeEditorStore } from '@/store/treeEditorStore'
import { cn } from '@/lib/utils'
export function TreeMetadataForm() {
const { name, description, category, setName, setDescription, setCategory, validationErrors } =
useTreeEditorStore()
const [categories, setCategories] = useState<string[]>([])
const [customCategory, setCustomCategory] = useState(false)
// Load existing categories
useEffect(() => {
treesApi.categories().then(setCategories).catch(console.error)
}, [])
const handleCategoryChange = (value: string) => {
if (value === '__custom__') {
setCustomCategory(true)
setCategory('')
} else {
setCustomCategory(false)
setCategory(value)
}
}
const nameError = validationErrors.find(
(e) => !e.nodeId && e.message.toLowerCase().includes('name')
)
return (
<div className="space-y-4 rounded-lg border border-border bg-card p-4">
<h2 className="text-sm font-semibold text-card-foreground">Tree Details</h2>
{/* Name */}
<div>
<label htmlFor="tree-name" className="block text-sm font-medium text-foreground">
Name <span className="text-destructive">*</span>
</label>
<input
id="tree-name"
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="e.g., VDA Registration Troubleshooting"
className={cn(
'mt-1 block w-full rounded-md border px-3 py-2 text-sm',
'bg-background text-foreground placeholder:text-muted-foreground',
'focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary',
nameError ? 'border-destructive' : 'border-input'
)}
/>
{nameError && (
<p className="mt-1 text-xs text-destructive">{nameError.message}</p>
)}
</div>
{/* Description */}
<div>
<label htmlFor="tree-description" className="block text-sm font-medium text-foreground">
Description
</label>
<textarea
id="tree-description"
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Brief description of what this tree troubleshoots..."
rows={2}
className={cn(
'mt-1 block w-full rounded-md border border-input px-3 py-2 text-sm',
'bg-background text-foreground placeholder:text-muted-foreground',
'focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary'
)}
/>
</div>
{/* Category */}
<div>
<label htmlFor="tree-category" className="block text-sm font-medium text-foreground">
Category
</label>
{!customCategory ? (
<select
id="tree-category"
value={category || ''}
onChange={(e) => handleCategoryChange(e.target.value)}
className={cn(
'mt-1 block w-full rounded-md border border-input px-3 py-2 text-sm',
'bg-background text-foreground',
'focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary'
)}
>
<option value="">No category</option>
{categories.map((cat) => (
<option key={cat} value={cat}>
{cat}
</option>
))}
<option value="__custom__">+ Add custom category</option>
</select>
) : (
<div className="mt-1 flex gap-2">
<input
type="text"
value={category}
onChange={(e) => setCategory(e.target.value)}
placeholder="Enter new category"
className={cn(
'block flex-1 rounded-md border border-input px-3 py-2 text-sm',
'bg-background text-foreground placeholder:text-muted-foreground',
'focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary'
)}
autoFocus
/>
<button
onClick={() => {
setCustomCategory(false)
setCategory('')
}}
className="rounded-md border border-input px-3 py-2 text-sm hover:bg-accent"
>
Cancel
</button>
</div>
)}
</div>
</div>
)
}
export default TreeMetadataForm

View File

@@ -0,0 +1,9 @@
export { TreeEditorLayout } from './TreeEditorLayout'
export { TreeMetadataForm } from './TreeMetadataForm'
export { NodeList } from './NodeList'
export { NodeEditorModal } from './NodeEditorModal'
export { NodeFormDecision } from './NodeFormDecision'
export { NodeFormAction } from './NodeFormAction'
export { NodeFormResolution } from './NodeFormResolution'
export { DynamicArrayField } from './DynamicArrayField'
export { NodePicker } from './NodePicker'

View File

@@ -0,0 +1,390 @@
import { useState, useMemo } from 'react'
import { HelpCircle, Zap, CheckCircle, ChevronDown, ChevronRight, Copy, Check, Play, Users } from 'lucide-react'
import type { TreeStructure, NodeType } from '@/types'
import { cn } from '@/lib/utils'
import type { SharedLinksMap } from './TreePreviewPanel'
type FindNodeFn = (nodeId: string) => TreeStructure | null
/**
* Recursively check if a node's subtree contains any solution nodes
* Also follows next_node_id references using the findNode function
* @param visited - Set to track visited nodes and prevent infinite loops
*/
function hasSolutionInSubtree(
node: TreeStructure,
findNode: FindNodeFn,
visited: Set<string> = new Set()
): boolean {
// Prevent infinite loops from circular references
if (visited.has(node.id)) return false
visited.add(node.id)
// This node is a solution
if (node.type === 'solution') {
return true
}
// Check children array
if (node.children && node.children.length > 0) {
if (node.children.some(child => hasSolutionInSubtree(child, findNode, visited))) {
return true
}
}
// Check next_node_id reference (for action nodes)
if (node.next_node_id) {
const nextNode = findNode(node.next_node_id)
if (nextNode && hasSolutionInSubtree(nextNode, findNode, visited)) {
return true
}
}
return false
}
interface TreePreviewNodeProps {
node: TreeStructure
selectedNodeId: string | null
onSelect: (nodeId: string) => void
depth: number
/** Optional label showing which option led to this node */
fromOption?: string
/** Callback when hovering over a node reference */
onHoverNodeId?: (nodeId: string | null) => void
/** Currently hovered node ID */
hoveredNodeId?: string | null
/** Function to look up any node by ID (for following next_node_id references) */
findNode: FindNodeFn
/** Map of targetNodeId -> sources that link to it (for showing shared connections) */
sharedLinksMap: SharedLinksMap
}
export function TreePreviewNode({
node,
selectedNodeId,
onSelect,
depth,
fromOption,
onHoverNodeId,
hoveredNodeId,
findNode,
sharedLinksMap
}: TreePreviewNodeProps) {
const [isCollapsed, setIsCollapsed] = useState(false)
const [copiedId, setCopiedId] = useState(false)
const isSelected = selectedNodeId === node.id
const isHovered = hoveredNodeId === node.id
const isRootNode = node.id === 'root'
// Check if this node (or its children/next_node_id) leads to a solution
const leadsTosolution = useMemo(() => {
// Don't show indicator on solution nodes themselves
if (node.type === 'solution') return false
return hasSolutionInSubtree(node, findNode)
}, [node, findNode])
const nodeTypeColors: Record<NodeType, string> = {
decision: 'border-blue-500/50 bg-blue-500/10',
action: 'border-yellow-500/50 bg-yellow-500/10',
solution: 'border-green-500/50 bg-green-500/10'
}
const nodeTypeSelectedColors: Record<NodeType, string> = {
decision: 'border-blue-500 bg-blue-500/20 ring-2 ring-blue-500/50 shadow-lg shadow-blue-500/20',
action: 'border-yellow-500 bg-yellow-500/20 ring-2 ring-yellow-500/50 shadow-lg shadow-yellow-500/20',
solution: 'border-green-500 bg-green-500/20 ring-2 ring-green-500/50 shadow-lg shadow-green-500/20'
}
const nodeTypeHoveredColors: Record<NodeType, string> = {
decision: 'border-blue-400 bg-blue-500/15 ring-1 ring-blue-400/50',
action: 'border-yellow-400 bg-yellow-500/15 ring-1 ring-yellow-400/50',
solution: 'border-green-400 bg-green-500/15 ring-1 ring-green-400/50'
}
const nodeTypeIcons: Record<NodeType, React.ReactNode> = {
decision: <HelpCircle className="h-4 w-4 text-blue-500" />,
action: <Zap className="h-4 w-4 text-yellow-500" />,
solution: <CheckCircle className="h-4 w-4 text-green-500" />
}
const getNodeLabel = () => {
if (node.type === 'decision') return node.question || 'Untitled Question'
return node.title || `Untitled ${node.type}`
}
const hasChildren = node.children && node.children.length > 0
const handleCopyId = (e: React.MouseEvent) => {
e.stopPropagation()
navigator.clipboard.writeText(node.id)
setCopiedId(true)
setTimeout(() => setCopiedId(false), 2000)
}
const toggleCollapse = (e: React.MouseEvent) => {
e.stopPropagation()
setIsCollapsed(!isCollapsed)
}
// Find which option label leads to each child node
const getOptionLabelForChild = (childId: string): string | undefined => {
if (node.type === 'decision' && node.options) {
const option = node.options.find(opt => opt.next_node_id === childId)
return option?.label
}
return undefined
}
// Check if a specific option/next_node_id leads to a solution
const nodeLeadsToSolution = (nextNodeId: string | undefined): boolean => {
if (!nextNodeId) return false
// First try to find in children
const childNode = node.children?.find(child => child.id === nextNodeId)
if (childNode) {
return hasSolutionInSubtree(childNode, findNode)
}
// Otherwise look up using findNode (for shared/external node references)
const targetNode = findNode(nextNodeId)
if (!targetNode) return false
return hasSolutionInSubtree(targetNode, findNode)
}
return (
<div className="relative">
{/* From option label */}
{fromOption && (
<div className="mb-1 text-xs font-medium text-muted-foreground">
<span className="rounded bg-muted px-1.5 py-0.5">{fromOption}</span>
</div>
)}
{/* Node card */}
<div
onClick={() => onSelect(node.id)}
className={cn(
'relative cursor-pointer rounded-lg border-2 p-3 transition-all',
isRootNode
? isSelected
? 'border-blue-500 bg-blue-500/20 ring-2 ring-blue-500/50 shadow-lg shadow-blue-500/20'
: isHovered
? 'border-blue-400 bg-blue-500/15 ring-1 ring-blue-400/50'
: 'border-blue-500/50 bg-blue-500/10'
: isSelected
? nodeTypeSelectedColors[node.type]
: isHovered
? nodeTypeHoveredColors[node.type]
: nodeTypeColors[node.type],
'hover:shadow-md',
isRootNode ? 'min-w-[260px] max-w-[360px]' : 'min-w-[220px] max-w-[320px]'
)}
>
{/* Solution path indicator - shows when this branch leads to a solution */}
{leadsTosolution && (
<div
className="absolute -top-1.5 -right-1.5 flex items-center justify-center rounded-full bg-green-500/90 p-0.5 shadow-sm"
title="This branch leads to a solution"
>
<CheckCircle className="h-3 w-3 text-white" />
</div>
)}
{/* Root node START header */}
{isRootNode && (
<div className="flex items-center gap-2 mb-2 pb-2 border-b border-blue-500/30">
<div className="rounded-full bg-blue-500/30 p-1.5">
<Play className="h-4 w-4 text-blue-500" />
</div>
<span className="text-xs font-bold uppercase tracking-wide text-blue-600 dark:text-blue-400">
Starting Question
</span>
</div>
)}
<div className="flex items-start gap-2">
{/* Collapse toggle for nodes with children */}
{hasChildren ? (
<button
type="button"
onClick={toggleCollapse}
className="mt-0.5 rounded p-0.5 hover:bg-muted"
>
{isCollapsed ? (
<ChevronRight className="h-4 w-4 text-muted-foreground" />
) : (
<ChevronDown className="h-4 w-4 text-muted-foreground" />
)}
</button>
) : (
<div className="w-5" /> // Spacer for alignment
)}
{!isRootNode && nodeTypeIcons[node.type]}
{isRootNode && <HelpCircle className="h-4 w-4 text-blue-500" />}
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-foreground leading-tight">
{getNodeLabel()}
</p>
{/* Node ID with copy button */}
<div className="flex items-center gap-1 mt-1">
<span
className="text-xs text-muted-foreground cursor-help"
title={`Full ID: ${node.id}`}
>
{node.id === 'root' ? 'root' : node.id.slice(0, 8) + '...'}
</span>
<button
type="button"
onClick={handleCopyId}
className="rounded p-0.5 text-muted-foreground hover:bg-muted hover:text-foreground"
title="Copy full ID"
>
{copiedId ? (
<Check className="h-3 w-3 text-green-500" />
) : (
<Copy className="h-3 w-3" />
)}
</button>
</div>
</div>
</div>
{/* Show options for decision nodes */}
{node.type === 'decision' && node.options && node.options.length > 0 && (
<div className="mt-2 space-y-1 border-t border-border/50 pt-2">
<p className="text-[10px] uppercase tracking-wide text-muted-foreground">Options:</p>
{node.options.map((opt, i) => {
const leadsToSolution = nodeLeadsToSolution(opt.next_node_id)
return (
<div
key={opt.id}
className={cn(
'flex items-center gap-1 text-xs rounded px-1 py-0.5 -mx-1',
opt.next_node_id && 'hover:bg-muted cursor-pointer'
)}
onMouseEnter={() => opt.next_node_id && onHoverNodeId?.(opt.next_node_id)}
onMouseLeave={() => onHoverNodeId?.(null)}
>
<span className="inline-flex h-4 w-4 items-center justify-center rounded bg-muted text-[10px] font-medium">
{i + 1}
</span>
<span className="truncate text-foreground">{opt.label || 'Untitled'}</span>
<span className="ml-auto flex items-center gap-1">
{leadsToSolution && (
<span title="Leads to solution">
<CheckCircle className="h-3 w-3 text-green-500" />
</span>
)}
{opt.next_node_id ? (
<span className="text-blue-500"></span>
) : (
<span className="text-muted-foreground/50 text-[10px]">(no link)</span>
)}
</span>
</div>
)
})}
</div>
)}
{/* Show next_node_id for action nodes */}
{node.type === 'action' && node.next_node_id && (() => {
const nextNode = findNode(node.next_node_id!)
const nextNodeLeadsToSolution = nodeLeadsToSolution(node.next_node_id)
const nextNodeLabel = nextNode
? (nextNode.type === 'decision' ? nextNode.question : nextNode.title) || 'Untitled'
: node.next_node_id!.slice(0, 8) + '...'
// Check if this target is shared by multiple sources
const sourcesLinkingToTarget = sharedLinksMap.get(node.next_node_id!) || []
const otherSources = sourcesLinkingToTarget.filter(s => s.id !== node.id)
const isSharedTarget = otherSources.length > 0
// Build tooltip for shared connection
const sharedTooltip = isSharedTarget
? `Shared endpoint - also connected from: ${otherSources.map(s => s.label).join(', ')}`
: undefined
return (
<div
className="mt-2 text-xs border-t border-border/50 pt-2 hover:bg-muted/50 cursor-pointer rounded px-1 -mx-1"
onMouseEnter={() => onHoverNodeId?.(node.next_node_id!)}
onMouseLeave={() => onHoverNodeId?.(null)}
>
<div className="flex items-center gap-1.5">
<span className="text-muted-foreground">Next:</span>
{isSharedTarget && (
<span title={sharedTooltip} className="flex items-center">
<Users className="h-3 w-3 text-purple-500" />
</span>
)}
<span className={cn(
'truncate',
nextNode?.type === 'solution' ? 'text-green-500 font-medium' : 'text-foreground'
)}>
{nextNodeLabel.slice(0, 30)}{nextNodeLabel.length > 30 ? '...' : ''}
</span>
<span className="ml-auto flex items-center gap-1">
{(nextNodeLeadsToSolution || nextNode?.type === 'solution') && (
<span title={nextNode?.type === 'solution' ? 'Solution' : 'Leads to solution'}>
<CheckCircle className="h-3 w-3 text-green-500" />
</span>
)}
<span className="text-yellow-500"></span>
</span>
</div>
{/* Show shared sources count */}
{isSharedTarget && (
<div className="mt-1 text-[10px] text-purple-500/80 pl-4">
Shared by {sourcesLinkingToTarget.length} nodes
</div>
)}
</div>
)
})()}
</div>
{/* Children - show as branches */}
{hasChildren && !isCollapsed && (
<div className="relative mt-3 ml-6 pl-6 border-l-2 border-border">
<div className="space-y-4">
{node.children!.map((child) => {
const optionLabel = getOptionLabelForChild(child.id)
return (
<div key={child.id} className="relative">
{/* Horizontal connector line */}
<div className="absolute -left-6 top-6 h-0.5 w-6 bg-border" />
<TreePreviewNode
node={child}
selectedNodeId={selectedNodeId}
onSelect={onSelect}
depth={depth + 1}
fromOption={optionLabel}
onHoverNodeId={onHoverNodeId}
hoveredNodeId={hoveredNodeId}
findNode={findNode}
sharedLinksMap={sharedLinksMap}
/>
</div>
)
})}
</div>
</div>
)}
{/* Show collapsed indicator */}
{hasChildren && isCollapsed && (
<div className="mt-2 ml-6 text-xs text-muted-foreground">
<span className="rounded bg-muted px-2 py-1">
{node.children!.length} child node{node.children!.length !== 1 ? 's' : ''} hidden
</span>
</div>
)}
</div>
)
}
export default TreePreviewNode

View File

@@ -0,0 +1,114 @@
import { useState, useMemo } from 'react'
import { useTreeEditorStore } from '@/store/treeEditorStore'
import { TreePreviewNode } from './TreePreviewNode'
import type { TreeStructure } from '@/types'
/** Map of targetNodeId -> array of {sourceNodeId, sourceNodeLabel} that link to it */
export type SharedLinksMap = Map<string, Array<{ id: string; label: string }>>
/**
* Build a map of which nodes link to which targets
* This helps identify shared nodes (multiple sources linking to same target)
*/
function buildSharedLinksMap(
node: TreeStructure,
map: SharedLinksMap = new Map()
): SharedLinksMap {
const nodeLabel = node.type === 'decision' ? node.question : node.title
// Check decision options
if (node.type === 'decision' && node.options) {
for (const opt of node.options) {
if (opt.next_node_id) {
const existing = map.get(opt.next_node_id) || []
existing.push({ id: node.id, label: nodeLabel || 'Untitled' })
map.set(opt.next_node_id, existing)
}
}
}
// Check action next_node_id
if (node.type === 'action' && node.next_node_id) {
const existing = map.get(node.next_node_id) || []
existing.push({ id: node.id, label: nodeLabel || 'Untitled' })
map.set(node.next_node_id, existing)
}
// Recurse into children
if (node.children) {
for (const child of node.children) {
buildSharedLinksMap(child, map)
}
}
return map
}
export function TreePreviewPanel() {
const { treeStructure, name, selectedNodeId, selectNode, findNode } = useTreeEditorStore()
const [hoveredNodeId, setHoveredNodeId] = useState<string | null>(null)
// Build map of shared links (which nodes link to which targets)
const sharedLinksMap = useMemo(() => {
if (!treeStructure) return new Map()
return buildSharedLinksMap(treeStructure)
}, [treeStructure])
if (!treeStructure) {
return (
<div className="flex h-full items-center justify-center p-4 text-sm text-muted-foreground">
No tree structure to preview
</div>
)
}
return (
<div className="flex h-full flex-col">
{/* Header */}
<div className="border-b border-border bg-background px-4 py-2">
<h3 className="text-sm font-semibold text-foreground">
Preview: {name || 'Untitled Tree'}
</h3>
<p className="text-xs text-muted-foreground">
Click a node to select Hover options to highlight targets
</p>
</div>
{/* Tree Visualization */}
<div className="flex-1 overflow-auto p-4">
<div className="inline-block min-w-full">
<TreePreviewNode
node={treeStructure}
selectedNodeId={selectedNodeId}
onSelect={selectNode}
depth={0}
onHoverNodeId={setHoveredNodeId}
hoveredNodeId={hoveredNodeId}
findNode={findNode}
sharedLinksMap={sharedLinksMap}
/>
</div>
</div>
{/* Legend */}
<div className="border-t border-border bg-background px-4 py-2">
<div className="flex flex-wrap gap-4 text-xs text-muted-foreground">
<div className="flex items-center gap-1">
<div className="h-3 w-3 rounded bg-blue-500/50" />
<span>Decision</span>
</div>
<div className="flex items-center gap-1">
<div className="h-3 w-3 rounded bg-yellow-500/50" />
<span>Action</span>
</div>
<div className="flex items-center gap-1">
<div className="h-3 w-3 rounded bg-green-500/50" />
<span>Solution</span>
</div>
</div>
</div>
</div>
)
}
export default TreePreviewPanel

View File

@@ -0,0 +1,2 @@
export { TreePreviewPanel } from './TreePreviewPanel'
export { TreePreviewNode } from './TreePreviewNode'

View File

@@ -0,0 +1,344 @@
import { useEffect, useState, useCallback } from 'react'
import { useParams, useNavigate, useBlocker } from 'react-router-dom'
import { useStore } from 'zustand'
import { Undo2, Redo2, Save } from 'lucide-react'
import { treesApi } from '@/api'
import type { TreeCreate, TreeUpdate } from '@/types'
import { useTreeEditorStore, useTreeEditorTemporal } from '@/store/treeEditorStore'
import { TreeEditorLayout } from '@/components/tree-editor/TreeEditorLayout'
import { useKeyboardShortcuts } from '@/hooks/useKeyboardShortcuts'
import { cn } from '@/lib/utils'
export function TreeEditorPage() {
const { id } = useParams<{ id: string }>()
const navigate = useNavigate()
const isEditMode = !!id
const {
name,
isDirty,
isLoading,
isSaving,
validationErrors,
initNewTree,
loadTree,
loadDraft,
discardDraft,
reset,
validate,
getTreeForSave,
markSaved,
setLoading,
setSaving
} = useTreeEditorStore()
// Access undo/redo from temporal store
const { undo, redo, pastStates, futureStates } = useStore(useTreeEditorTemporal)
const [showDraftPrompt, setShowDraftPrompt] = useState(false)
const [saveError, setSaveError] = useState<string | null>(null)
// Block navigation if there are unsaved changes
const blocker = useBlocker(
({ currentLocation, nextLocation }) =>
isDirty && currentLocation.pathname !== nextLocation.pathname
)
// Keyboard shortcuts for undo/redo/save
useKeyboardShortcuts([
{
key: 'z',
ctrl: true,
handler: () => {
if (pastStates.length > 0) undo()
}
},
{
key: 'z',
ctrl: true,
shift: true,
handler: () => {
if (futureStates.length > 0) redo()
}
},
{
key: 's',
ctrl: true,
handler: () => {
handleSave()
}
}
])
// Initialize or load tree
useEffect(() => {
const initialize = async () => {
if (isEditMode) {
setLoading(true)
try {
const tree = await treesApi.get(id)
loadTree(tree)
} catch (err) {
console.error('Failed to load tree:', err)
navigate('/trees')
}
} else {
initNewTree()
// Check for draft after initializing
const draftExists = localStorage.getItem('tree-editor-draft') !== null
if (draftExists) {
setShowDraftPrompt(true)
}
}
}
initialize()
return () => {
reset()
}
}, [id, isEditMode])
// Handle unsaved changes warning
useEffect(() => {
const handleBeforeUnload = (e: BeforeUnloadEvent) => {
if (isDirty) {
e.preventDefault()
e.returnValue = ''
}
}
window.addEventListener('beforeunload', handleBeforeUnload)
return () => window.removeEventListener('beforeunload', handleBeforeUnload)
}, [isDirty])
const handleRestoreDraft = () => {
loadDraft()
setShowDraftPrompt(false)
}
const handleDiscardDraft = () => {
discardDraft()
setShowDraftPrompt(false)
}
const handleSave = useCallback(async () => {
setSaveError(null)
// Validate first
const errors = validate()
const hasErrors = errors.some(e => e.severity === 'error')
if (hasErrors) {
setSaveError('Please fix validation errors before saving')
return
}
setSaving(true)
try {
const treeData = getTreeForSave()
if (isEditMode) {
await treesApi.update(id!, treeData as TreeUpdate)
} else {
const newTree = await treesApi.create(treeData as TreeCreate)
// Navigate to edit mode with the new ID
navigate(`/trees/${newTree.id}/edit`, { replace: true })
}
markSaved()
} catch (err) {
console.error('Failed to save tree:', err)
setSaveError('Failed to save tree. Please try again.')
} finally {
setSaving(false)
}
}, [isEditMode, id, validate, getTreeForSave, markSaved, navigate])
// Handle blocker
const handleBlockerProceed = () => {
if (blocker.state === 'blocked') {
blocker.proceed()
}
}
const handleBlockerReset = () => {
if (blocker.state === 'blocked') {
blocker.reset()
}
}
if (isLoading) {
return (
<div className="flex h-64 items-center justify-center">
<div className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent" />
</div>
)
}
// Mobile warning
const isMobile = typeof window !== 'undefined' && window.innerWidth < 768
return (
<div className="flex h-[calc(100vh-4rem)] flex-col">
{/* Mobile Warning */}
{isMobile && (
<div className="bg-yellow-100 px-4 py-2 text-center text-sm text-yellow-800 dark:bg-yellow-900/20 dark:text-yellow-200">
Desktop recommended for tree editing. Viewing mode only on mobile.
</div>
)}
{/* Draft Restore Prompt */}
{showDraftPrompt && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-background/80 backdrop-blur-sm">
<div className="w-full max-w-md rounded-lg border border-border bg-card p-6 shadow-lg">
<h2 className="mb-2 text-lg font-semibold">Restore Draft?</h2>
<p className="mb-4 text-sm text-muted-foreground">
You have an unsaved draft from a previous session. Would you like to restore it?
</p>
<div className="flex gap-2">
<button
onClick={handleRestoreDraft}
className={cn(
'flex-1 rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground',
'hover:bg-primary/90'
)}
>
Restore Draft
</button>
<button
onClick={handleDiscardDraft}
className={cn(
'flex-1 rounded-md border border-input bg-background px-4 py-2 text-sm font-medium',
'hover:bg-accent'
)}
>
Start Fresh
</button>
</div>
</div>
</div>
)}
{/* Unsaved Changes Dialog */}
{blocker.state === 'blocked' && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-background/80 backdrop-blur-sm">
<div className="w-full max-w-md rounded-lg border border-border bg-card p-6 shadow-lg">
<h2 className="mb-2 text-lg font-semibold">Unsaved Changes</h2>
<p className="mb-4 text-sm text-muted-foreground">
You have unsaved changes. Are you sure you want to leave?
</p>
<div className="flex gap-2">
<button
onClick={handleBlockerReset}
className={cn(
'flex-1 rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground',
'hover:bg-primary/90'
)}
>
Stay
</button>
<button
onClick={handleBlockerProceed}
className={cn(
'flex-1 rounded-md border border-input bg-background px-4 py-2 text-sm font-medium text-destructive',
'hover:bg-accent'
)}
>
Leave Without Saving
</button>
</div>
</div>
</div>
)}
{/* Toolbar */}
<div className="flex items-center justify-between border-b border-border bg-card px-4 py-2">
<div className="flex items-center gap-4">
<button
onClick={() => navigate('/trees')}
className="text-sm text-muted-foreground hover:text-foreground"
>
Back to Library
</button>
<h1 className="text-lg font-semibold">
{isEditMode ? 'Edit Tree' : 'Create New Tree'}
{name && <span className="ml-2 text-muted-foreground">- {name}</span>}
</h1>
{isDirty && (
<span className="rounded-full bg-yellow-500/20 px-2 py-0.5 text-xs text-yellow-600 dark:text-yellow-400">
Unsaved
</span>
)}
</div>
<div className="flex items-center gap-2">
{/* Undo/Redo */}
<div className="flex items-center rounded-md border border-border">
<button
type="button"
onClick={() => undo()}
disabled={pastStates.length === 0}
title={pastStates.length > 0 ? `Undo (Ctrl+Z) - ${pastStates.length} step${pastStates.length !== 1 ? 's' : ''} available` : 'Nothing to undo'}
className={cn(
'rounded-l-md p-2 transition-colors',
pastStates.length > 0
? 'text-foreground hover:bg-accent'
: 'text-muted-foreground/40 cursor-not-allowed'
)}
>
<Undo2 className="h-4 w-4" />
</button>
<div className="h-6 w-px bg-border" />
<button
type="button"
onClick={() => redo()}
disabled={futureStates.length === 0}
title={futureStates.length > 0 ? `Redo (Ctrl+Shift+Z) - ${futureStates.length} step${futureStates.length !== 1 ? 's' : ''} available` : 'Nothing to redo'}
className={cn(
'rounded-r-md p-2 transition-colors',
futureStates.length > 0
? 'text-foreground hover:bg-accent'
: 'text-muted-foreground/40 cursor-not-allowed'
)}
>
<Redo2 className="h-4 w-4" />
</button>
</div>
<div className="mx-2 h-6 w-px bg-border" />
{/* Save */}
<button
onClick={handleSave}
disabled={isSaving || !isDirty}
className={cn(
'flex items-center gap-2 rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground',
'hover:bg-primary/90 disabled:opacity-50'
)}
>
<Save className="h-4 w-4" />
{isSaving ? 'Saving...' : 'Save'}
</button>
</div>
</div>
{/* Error Display */}
{saveError && (
<div className="bg-destructive/10 px-4 py-2 text-sm text-destructive">
{saveError}
</div>
)}
{/* Validation Errors Summary */}
{validationErrors.filter(e => e.severity === 'error').length > 0 && (
<div className="bg-destructive/10 px-4 py-2 text-sm text-destructive">
{validationErrors.filter(e => e.severity === 'error').length} validation error(s) found.
Please fix them before saving.
</div>
)}
{/* Main Editor */}
<TreeEditorLayout isMobile={isMobile} />
</div>
)
}
export default TreeEditorPage

View File

@@ -1,5 +1,6 @@
import { useEffect, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { useNavigate, Link } from 'react-router-dom'
import { Plus, Pencil } from 'lucide-react'
import { treesApi } from '@/api'
import type { TreeListItem } from '@/types'
import { cn } from '@/lib/utils'
@@ -59,11 +60,23 @@ export function TreeLibraryPage() {
return (
<div className="container mx-auto px-4 py-8">
<div className="mb-8">
<h1 className="text-3xl font-bold text-foreground">Decision Trees</h1>
<p className="mt-2 text-muted-foreground">
Select a troubleshooting tree to start a new session
</p>
<div className="mb-8 flex items-start justify-between">
<div>
<h1 className="text-3xl font-bold text-foreground">Decision Trees</h1>
<p className="mt-2 text-muted-foreground">
Select a troubleshooting tree to start a new session
</p>
</div>
<Link
to="/trees/new"
className={cn(
'flex items-center gap-2 rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground',
'hover:bg-primary/90'
)}
>
<Plus className="h-4 w-4" />
Create Tree
</Link>
</div>
{/* Search and Filter */}
@@ -148,15 +161,28 @@ export function TreeLibraryPage() {
<span className="text-xs text-muted-foreground">
v{tree.version} · {tree.usage_count} uses
</span>
<button
onClick={() => handleStartSession(tree.id)}
className={cn(
'rounded-md bg-primary px-3 py-1.5 text-sm font-medium text-primary-foreground',
'hover:bg-primary/90'
)}
>
Start Session
</button>
<div className="flex items-center gap-2">
<Link
to={`/trees/${tree.id}/edit`}
className={cn(
'rounded-md border border-input p-1.5 text-muted-foreground',
'hover:bg-accent hover:text-accent-foreground'
)}
title="Edit tree"
>
<Pencil className="h-4 w-4" />
</Link>
<button
type="button"
onClick={() => handleStartSession(tree.id)}
className={cn(
'rounded-md bg-primary px-3 py-1.5 text-sm font-medium text-primary-foreground',
'hover:bg-primary/90'
)}
>
Start Session
</button>
</div>
</div>
</div>
))}

View File

@@ -2,5 +2,6 @@ export { default as LoginPage } from './LoginPage'
export { default as RegisterPage } from './RegisterPage'
export { default as TreeLibraryPage } from './TreeLibraryPage'
export { default as TreeNavigationPage } from './TreeNavigationPage'
export { default as TreeEditorPage } from './TreeEditorPage'
export { default as SessionHistoryPage } from './SessionHistoryPage'
export { default as SessionDetailPage } from './SessionDetailPage'

View File

@@ -6,6 +6,7 @@ import {
RegisterPage,
TreeLibraryPage,
TreeNavigationPage,
TreeEditorPage,
SessionHistoryPage,
SessionDetailPage,
} from '@/pages'
@@ -38,6 +39,14 @@ export const router = createBrowserRouter([
path: 'trees',
element: <TreeLibraryPage />,
},
{
path: 'trees/new',
element: <TreeEditorPage />,
},
{
path: 'trees/:id/edit',
element: <TreeEditorPage />,
},
{
path: 'trees/:id/navigate',
element: <TreeNavigationPage />,

View File

@@ -0,0 +1,691 @@
import { create } from 'zustand'
import { temporal } from 'zundo'
import { immer } from 'zustand/middleware/immer'
import type { Tree, TreeStructure, TreeCreate, TreeUpdate, NodeType } from '@/types'
// Validation error interface
export interface ValidationError {
nodeId?: string
field?: string
message: string
severity: 'error' | 'warning'
}
// Draft storage key
const DRAFT_STORAGE_KEY = 'tree-editor-draft'
// Helper to generate unique IDs
const generateId = () => crypto.randomUUID()
// Helper to find a node in the tree structure
const findNodeInTree = (
nodeId: string,
structure: TreeStructure | null
): TreeStructure | null => {
if (!structure) return null
if (structure.id === nodeId) return structure
if (structure.children) {
for (const child of structure.children) {
const found = findNodeInTree(nodeId, child)
if (found) return found
}
}
return null
}
// Helper to find parent of a node
const findParentNode = (
nodeId: string,
structure: TreeStructure | null,
parent: TreeStructure | null = null
): TreeStructure | null => {
if (!structure) return null
if (structure.id === nodeId) return parent
if (structure.children) {
for (const child of structure.children) {
const found = findParentNode(nodeId, child, structure)
if (found) return found
}
}
return null
}
// Helper to get all node IDs
const getAllNodeIds = (structure: TreeStructure | null): string[] => {
if (!structure) return []
const ids = [structure.id]
if (structure.children) {
for (const child of structure.children) {
ids.push(...getAllNodeIds(child))
}
}
return ids
}
// Helper to deep clone a node
const deepCloneNode = (node: TreeStructure): TreeStructure => {
const clone: TreeStructure = { ...node, id: generateId() }
// Update title/question to indicate it's a copy
if (clone.question) {
clone.question = `${clone.question} (Copy)`
} else if (clone.title) {
clone.title = `${clone.title} (Copy)`
}
// Clone options with new IDs
if (clone.options) {
clone.options = clone.options.map(opt => ({
...opt,
id: generateId(),
next_node_id: '' // Clear references - user must reassign
}))
}
// Clear next_node_id - user must reassign
if (clone.next_node_id) {
clone.next_node_id = ''
}
// Clone children recursively
if (clone.children) {
clone.children = clone.children.map(child => deepCloneNode(child))
}
return clone
}
interface TreeEditorState {
// Tree data
treeId: string | null // null for new tree
name: string
description: string
category: string
treeStructure: TreeStructure | null
originalTree: Tree | null // For comparison in edit mode
// UI state
selectedNodeId: string | null
isDirty: boolean
isLoading: boolean
isSaving: boolean
validationErrors: ValidationError[]
// Auto-save state
lastSavedAt: Date | null
draftSavedAt: Date | null
hasDraft: boolean
// Actions - Initialization
initNewTree: () => void
loadTree: (tree: Tree) => void
loadDraft: () => boolean
discardDraft: () => void
reset: () => void
// Actions - Metadata
setName: (name: string) => void
setDescription: (description: string) => void
setCategory: (category: string) => void
// Actions - Node CRUD
addNode: (parentId: string | null, type: NodeType, insertIndex?: number) => string
updateNode: (nodeId: string, updates: Partial<TreeStructure>) => void
deleteNode: (nodeId: string) => void
duplicateNode: (nodeId: string) => string | null
// Actions - Node ordering
reorderNodes: (parentId: string, fromIndex: number, toIndex: number) => void
reorderOptions: (nodeId: string, fromIndex: number, toIndex: number) => void
// Actions - Selection
selectNode: (nodeId: string | null) => void
// Actions - Validation
validate: () => ValidationError[]
clearValidation: () => void
// Actions - Save/Draft
autoSaveDraft: () => void
markSaved: () => void
getTreeForSave: () => TreeCreate | TreeUpdate
// Actions - State
setLoading: (loading: boolean) => void
setSaving: (saving: boolean) => void
// Helpers
findNode: (nodeId: string) => TreeStructure | null
getAllNodeIds: () => string[]
getAvailableTargetNodes: (excludeNodeId?: string) => Array<{ id: string; label: string; type: NodeType }>
}
// Create store with immer and temporal (undo/redo) middleware
export const useTreeEditorStore = create<TreeEditorState>()(
temporal(
immer((set, get) => ({
// Initial state
treeId: null,
name: '',
description: '',
category: '',
treeStructure: null,
originalTree: null,
selectedNodeId: null,
isDirty: false,
isLoading: false,
isSaving: false,
validationErrors: [],
lastSavedAt: null,
draftSavedAt: null,
hasDraft: false,
// Check for existing draft on init
initNewTree: () => {
const hasDraft = localStorage.getItem(DRAFT_STORAGE_KEY) !== null
set((state) => {
state.treeId = null
state.name = ''
state.description = ''
state.category = ''
state.treeStructure = {
id: 'root',
type: 'decision',
question: '',
options: [],
children: []
}
state.originalTree = null
state.selectedNodeId = 'root'
state.isDirty = false
state.isLoading = false
state.isSaving = false
state.validationErrors = []
state.lastSavedAt = null
state.draftSavedAt = null
state.hasDraft = hasDraft
})
},
loadTree: (tree: Tree) => {
set((state) => {
state.treeId = tree.id
state.name = tree.name
state.description = tree.description || ''
state.category = tree.category || ''
state.treeStructure = tree.tree_structure
state.originalTree = tree
state.selectedNodeId = tree.tree_structure?.id || null
state.isDirty = false
state.isLoading = false
state.validationErrors = []
state.lastSavedAt = new Date()
state.draftSavedAt = null
state.hasDraft = false
})
},
loadDraft: () => {
const draftJson = localStorage.getItem(DRAFT_STORAGE_KEY)
if (!draftJson) return false
try {
const draft = JSON.parse(draftJson)
set((state) => {
state.treeId = draft.treeId || null
state.name = draft.name || ''
state.description = draft.description || ''
state.category = draft.category || ''
state.treeStructure = draft.treeStructure || null
state.isDirty = true
state.draftSavedAt = draft.savedAt ? new Date(draft.savedAt) : null
state.hasDraft = false
})
return true
} catch {
localStorage.removeItem(DRAFT_STORAGE_KEY)
return false
}
},
discardDraft: () => {
localStorage.removeItem(DRAFT_STORAGE_KEY)
set((state) => {
state.hasDraft = false
})
},
reset: () => {
set((state) => {
state.treeId = null
state.name = ''
state.description = ''
state.category = ''
state.treeStructure = null
state.originalTree = null
state.selectedNodeId = null
state.isDirty = false
state.isLoading = false
state.isSaving = false
state.validationErrors = []
state.lastSavedAt = null
state.draftSavedAt = null
state.hasDraft = false
})
},
// Metadata actions
setName: (name: string) => {
set((state) => {
state.name = name
state.isDirty = true
})
get().autoSaveDraft()
},
setDescription: (description: string) => {
set((state) => {
state.description = description
state.isDirty = true
})
get().autoSaveDraft()
},
setCategory: (category: string) => {
set((state) => {
state.category = category
state.isDirty = true
})
get().autoSaveDraft()
},
// Node CRUD
addNode: (parentId: string | null, type: NodeType, insertIndex?: number) => {
const newId = generateId()
const newNode: TreeStructure = {
id: newId,
type,
...(type === 'decision' && {
question: '',
options: [],
children: []
}),
...(type === 'action' && {
title: '',
description: ''
}),
...(type === 'solution' && {
title: '',
description: ''
})
}
set((state) => {
if (!parentId) {
// Adding as root
state.treeStructure = newNode
} else {
// Find parent and add to children
const parent = findNodeInTree(parentId, state.treeStructure)
if (parent) {
if (!parent.children) {
parent.children = []
}
if (insertIndex !== undefined && insertIndex >= 0) {
parent.children.splice(insertIndex, 0, newNode)
} else {
parent.children.push(newNode)
}
}
}
state.selectedNodeId = newId
state.isDirty = true
})
get().autoSaveDraft()
return newId
},
updateNode: (nodeId: string, updates: Partial<TreeStructure>) => {
set((state) => {
const node = findNodeInTree(nodeId, state.treeStructure)
if (node) {
Object.assign(node, updates)
state.isDirty = true
}
})
get().autoSaveDraft()
},
deleteNode: (nodeId: string) => {
if (nodeId === 'root') {
// Don't allow deleting root, just clear it
set((state) => {
if (state.treeStructure) {
state.treeStructure.question = ''
state.treeStructure.options = []
state.treeStructure.children = []
}
state.isDirty = true
})
get().autoSaveDraft()
return
}
set((state) => {
const parent = findParentNode(nodeId, state.treeStructure)
if (parent && parent.children) {
const index = parent.children.findIndex(c => c.id === nodeId)
if (index !== -1) {
parent.children.splice(index, 1)
}
}
// Clear selection if deleted node was selected
if (state.selectedNodeId === nodeId) {
state.selectedNodeId = parent?.id || 'root'
}
state.isDirty = true
})
get().autoSaveDraft()
},
duplicateNode: (nodeId: string) => {
const state = get()
const node = findNodeInTree(nodeId, state.treeStructure)
if (!node) return null
const clonedNode = deepCloneNode(node)
// Find parent and add cloned node as sibling
const parent = findParentNode(nodeId, state.treeStructure)
set((s) => {
if (parent && parent.children) {
const index = parent.children.findIndex(c => c.id === nodeId)
parent.children.splice(index + 1, 0, clonedNode)
} else if (nodeId === 'root') {
// Can't duplicate root - just select it
return
}
s.selectedNodeId = clonedNode.id
s.isDirty = true
})
get().autoSaveDraft()
return clonedNode.id
},
// Reordering
reorderNodes: (parentId: string, fromIndex: number, toIndex: number) => {
set((state) => {
const parent = findNodeInTree(parentId, state.treeStructure)
if (parent && parent.children && parent.children.length > 0) {
const [moved] = parent.children.splice(fromIndex, 1)
parent.children.splice(toIndex, 0, moved)
state.isDirty = true
}
})
get().autoSaveDraft()
},
reorderOptions: (nodeId: string, fromIndex: number, toIndex: number) => {
set((state) => {
const node = findNodeInTree(nodeId, state.treeStructure)
if (node && node.options && node.options.length > 0) {
const [moved] = node.options.splice(fromIndex, 1)
node.options.splice(toIndex, 0, moved)
state.isDirty = true
}
})
get().autoSaveDraft()
},
// Selection
selectNode: (nodeId: string | null) => {
set((state) => {
state.selectedNodeId = nodeId
})
},
// Validation
validate: () => {
const state = get()
const errors: ValidationError[] = []
// Check tree name
if (!state.name.trim()) {
errors.push({ message: 'Tree name is required', severity: 'error' })
}
// Check tree structure exists
if (!state.treeStructure) {
errors.push({ message: 'Tree must have at least one node', severity: 'error' })
set((s) => { s.validationErrors = errors })
return errors
}
const allNodeIds = getAllNodeIds(state.treeStructure)
const referencedIds = new Set<string>()
let hasSolution = false
// Traverse and validate all nodes
const validateNode = (node: TreeStructure) => {
// Check type-specific required fields
if (node.type === 'decision') {
if (!node.question?.trim()) {
errors.push({
nodeId: node.id,
field: 'question',
message: `Decision node "${node.id}" requires a question`,
severity: 'error'
})
}
if (!node.options || node.options.length === 0) {
errors.push({
nodeId: node.id,
field: 'options',
message: `Decision node "${node.id}" requires at least one option`,
severity: 'error'
})
} else {
// Validate options
node.options.forEach((opt, i) => {
if (!opt.label?.trim()) {
errors.push({
nodeId: node.id,
field: `options[${i}].label`,
message: `Option ${i + 1} in "${node.id}" requires a label`,
severity: 'error'
})
}
if (opt.next_node_id) {
referencedIds.add(opt.next_node_id)
if (!allNodeIds.includes(opt.next_node_id)) {
errors.push({
nodeId: node.id,
field: `options[${i}].next_node_id`,
message: `Option "${opt.label}" references non-existent node "${opt.next_node_id}"`,
severity: 'error'
})
}
}
})
}
}
if (node.type === 'action') {
if (!node.title?.trim()) {
errors.push({
nodeId: node.id,
field: 'title',
message: `Action node "${node.id}" requires a title`,
severity: 'error'
})
}
if (node.next_node_id) {
referencedIds.add(node.next_node_id)
if (!allNodeIds.includes(node.next_node_id)) {
errors.push({
nodeId: node.id,
field: 'next_node_id',
message: `Action "${node.title}" references non-existent node "${node.next_node_id}"`,
severity: 'error'
})
}
}
}
if (node.type === 'solution') {
hasSolution = true
if (!node.title?.trim()) {
errors.push({
nodeId: node.id,
field: 'title',
message: `Solution node "${node.id}" requires a title`,
severity: 'error'
})
}
}
// Validate children
if (node.children) {
node.children.forEach(child => validateNode(child))
}
}
validateNode(state.treeStructure)
// Check for at least one solution
if (!hasSolution) {
errors.push({
message: 'Tree must have at least one solution (terminal) node',
severity: 'error'
})
}
// Check for orphaned nodes (not root and not referenced)
allNodeIds.forEach(id => {
if (id !== 'root' && !referencedIds.has(id)) {
// Check if it's a direct child of another node (via children array)
let isChildOfAny = false
const checkIfChild = (node: TreeStructure) => {
if (node.children?.some(c => c.id === id)) {
isChildOfAny = true
}
node.children?.forEach(checkIfChild)
}
checkIfChild(state.treeStructure!)
if (!isChildOfAny) {
errors.push({
nodeId: id,
message: `Node "${id}" is orphaned (not reachable from root)`,
severity: 'warning'
})
}
}
})
set((s) => { s.validationErrors = errors })
return errors
},
clearValidation: () => {
set((state) => { state.validationErrors = [] })
},
// Auto-save draft (debounced externally, called after each change)
autoSaveDraft: () => {
const state = get()
const draft = {
treeId: state.treeId,
name: state.name,
description: state.description,
category: state.category,
treeStructure: state.treeStructure,
savedAt: new Date().toISOString()
}
localStorage.setItem(DRAFT_STORAGE_KEY, JSON.stringify(draft))
set((s) => { s.draftSavedAt = new Date() })
},
markSaved: () => {
localStorage.removeItem(DRAFT_STORAGE_KEY)
set((state) => {
state.isDirty = false
state.lastSavedAt = new Date()
state.draftSavedAt = null
state.hasDraft = false
})
},
getTreeForSave: (): TreeCreate | TreeUpdate => {
const state = get()
return {
name: state.name,
description: state.description || undefined,
category: state.category || undefined,
tree_structure: state.treeStructure!
}
},
setLoading: (loading: boolean) => {
set((state) => { state.isLoading = loading })
},
setSaving: (saving: boolean) => {
set((state) => { state.isSaving = saving })
},
// Helpers
findNode: (nodeId: string) => {
return findNodeInTree(nodeId, get().treeStructure)
},
getAllNodeIds: () => {
return getAllNodeIds(get().treeStructure)
},
getAvailableTargetNodes: (excludeNodeId?: string) => {
const state = get()
const allIds = getAllNodeIds(state.treeStructure)
return allIds
.filter(id => id !== excludeNodeId)
.map(id => {
const node = findNodeInTree(id, state.treeStructure)
let label = id
let type: NodeType = 'decision'
if (node) {
type = node.type
if (node.question) label = node.question.slice(0, 50)
else if (node.title) label = node.title.slice(0, 50)
}
// Use short ID format, but 'root' stays as-is
const shortId = id === 'root' ? 'root' : id.slice(0, 8) + '...'
return { id, label: `${label} (${shortId})`, type }
})
}
})),
{
// Zundo options for undo/redo
limit: 50, // Keep last 50 states
partialize: (state) => ({
// Only track these fields in history
name: state.name,
description: state.description,
category: state.category,
treeStructure: state.treeStructure
})
}
)
)
// Export temporal store for undo/redo access
// Use with: useStore(useTreeEditorStore.temporal, selector)
export const useTreeEditorTemporal = useTreeEditorStore.temporal
export default useTreeEditorStore