Files
resolutionflow/frontend/src/components/tree-editor/NodeFormDecision.tsx
chihlasm 79bf051666 refactor: Update forms for inline safety, add MetadataSidePanel, update layout
- NodeFormDecision: option reorder via onUpdate (no premature store writes)
- NodePicker: add allowCreate prop (default true) to hide Create New options
  during inline canvas editing, preventing side-effect node creation
- MetadataSidePanel: 320px right slide-in overlay wrapping TreeMetadataForm,
  closes on backdrop click, close button, and Escape key
- TreeEditorLayout: Flow mode now renders full-width TreeCanvas + MetadataSidePanel
  overlay; Code mode unchanged (Monaco + preview split)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-17 22:45:35 -05:00

226 lines
8.7 KiB
TypeScript

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 { 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) => {
// Mutate local draft via onUpdate (backward-compatible: modal path relays to store,
// canvas path updates local draft without writing to store early)
const newOptions = [...(node.options || [])]
const [moved] = newOptions.splice(fromIndex, 1)
newOptions.splice(toIndex, 0, moved)
onUpdate({ options: newOptions })
}
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-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-red-400">*</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-card text-foreground placeholder:text-muted-foreground',
'focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary/20',
questionError ? 'border-red-400' : 'border-border'
)}
/>
{questionError && (
<p className="mt-1 text-xs text-red-400">{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-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'
)}
/>
</div>
{/* Options */}
<div>
<label className="block text-sm font-medium text-foreground">
{isRootNode ? 'Answer Options (Branches)' : 'Options'} <span className="text-red-400">*</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-red-400">{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-border bg-accent/50 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-400'
: 'bg-accent 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-red-400' : 'border-border'
)}
/>
</div>
{optionLabelError && (
<p className="mb-2 text-xs text-red-400">{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-border bg-accent/50 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