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:
220
frontend/src/components/tree-editor/NodeFormDecision.tsx
Normal file
220
frontend/src/components/tree-editor/NodeFormDecision.tsx
Normal 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
|
||||
Reference in New Issue
Block a user