- Upgraded tailwindcss v3 → v4.2.1, postcss plugin to @tailwindcss/postcss - Deleted tailwind.config.js, migrated theme to CSS @theme block in index.css - Replaced @tailwind directives with @import 'tailwindcss' - Added @custom-variant dark, @utility blocks for custom utilities - Updated class names across 128 files (shadow-sm → shadow-xs, etc.) - Removed autoprefixer (built into v4) - Added migration plan doc Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
294 lines
12 KiB
TypeScript
294 lines
12 KiB
TypeScript
import { useRef, useEffect } from 'react'
|
|
import { Play, Link2 } from 'lucide-react'
|
|
import { DynamicArrayField } from './DynamicArrayField'
|
|
import { useTreeEditorStore, collectAllNodesFlat } from '@/store/treeEditorStore'
|
|
import type { TreeStructure, TreeOption } from '@/types'
|
|
import { cn } from '@/lib/utils'
|
|
import { InfoTip } from '@/components/common/InfoTip'
|
|
|
|
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'
|
|
// Track input elements by index so we can focus the newly added one
|
|
const inputRefs = useRef<Map<number, HTMLInputElement>>(new Map())
|
|
const shouldFocusLast = useRef(false)
|
|
|
|
const questionError = validationErrors.find(
|
|
e => e.nodeId === node.id && e.field === 'question'
|
|
)
|
|
|
|
const optionsError = validationErrors.find(
|
|
e => e.nodeId === node.id && e.field === 'options'
|
|
)
|
|
|
|
// After options array grows (due to keyboard-triggered add), focus the last input
|
|
useEffect(() => {
|
|
if (shouldFocusLast.current) {
|
|
shouldFocusLast.current = false
|
|
const lastIndex = (node.options?.length ?? 1) - 1
|
|
inputRefs.current.get(lastIndex)?.focus()
|
|
}
|
|
}, [node.options?.length])
|
|
|
|
const handleAddOption = () => {
|
|
const newOption: TreeOption = {
|
|
id: crypto.randomUUID(),
|
|
label: '',
|
|
next_node_id: ''
|
|
}
|
|
onUpdate({
|
|
options: [...(node.options || []), newOption]
|
|
})
|
|
}
|
|
|
|
// Add a new option and focus it (used by keyboard shortcut)
|
|
const handleAddOptionAndFocus = () => {
|
|
shouldFocusLast.current = true
|
|
handleAddOption()
|
|
}
|
|
|
|
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="flex items-center gap-2 rounded-lg border border-blue-500/30 bg-blue-500/10 px-3 py-2">
|
|
<Play className="h-4 w-4 text-blue-500 shrink-0" />
|
|
<span className="text-sm font-medium text-blue-400">Starting Question</span>
|
|
<InfoTip text="This is the first question users will see. Each option creates a different troubleshooting path." />
|
|
</div>
|
|
)}
|
|
|
|
{/* Question */}
|
|
<div>
|
|
<label className="block text-sm font-medium text-foreground">
|
|
{isRootNode ? 'Starting Question' : 'Question'} <span className="text-red-400">*</span>
|
|
</label>
|
|
<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-hidden 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-hidden focus:ring-1 focus:ring-primary'
|
|
)}
|
|
/>
|
|
</div>
|
|
|
|
{/* Options */}
|
|
<div>
|
|
<label className="flex items-center gap-1.5 text-sm font-medium text-foreground">
|
|
{isRootNode ? 'Answer Options (Branches)' : 'Options'} <span className="text-red-400">*</span>
|
|
<InfoTip text={isRootNode
|
|
? "Add as many options as needed (A, B, C, D...). Each option leads to a different troubleshooting path."
|
|
: "Each option can branch to a different next step."} />
|
|
</label>
|
|
<p className="text-xs text-muted-foreground mt-1">Options become answer placeholders you can fill in later.</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 letter = indexToLetter(index)
|
|
const isLastOption = index === (node.options?.length ?? 1) - 1
|
|
|
|
return (
|
|
<div className="flex items-center gap-2">
|
|
<span className={cn(
|
|
'flex h-6 w-6 shrink-0 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>
|
|
<div className="flex-1">
|
|
<input
|
|
ref={(el) => {
|
|
if (el) inputRefs.current.set(index, el)
|
|
else inputRefs.current.delete(index)
|
|
}}
|
|
type="text"
|
|
value={option.label}
|
|
onChange={(e) => handleUpdateOption(index, { label: e.target.value })}
|
|
placeholder={isRootNode
|
|
? `Branch ${letter}: e.g., "Network Issues"...`
|
|
: `Option ${letter} label`}
|
|
onKeyDown={(e) => {
|
|
if ((e.key === 'Tab' || e.key === 'Enter') && isLastOption && option.label.trim()) {
|
|
e.preventDefault()
|
|
handleAddOptionAndFocus()
|
|
}
|
|
}}
|
|
className={cn(
|
|
'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-hidden focus:ring-1 focus:ring-primary',
|
|
optionLabelError ? 'border-red-400' : 'border-border'
|
|
)}
|
|
/>
|
|
{optionLabelError && (
|
|
<p className="mt-1 text-xs text-red-400">{optionLabelError.message}</p>
|
|
)}
|
|
</div>
|
|
{/* Cross-reference link indicator */}
|
|
{option.next_node_id && (() => {
|
|
const treeStructure = useTreeEditorStore.getState().treeStructure
|
|
const childIds = new Set(node.children?.map(c => c.id) ?? [])
|
|
// Only show if it's a cross-reference (points outside children)
|
|
if (childIds.has(option.next_node_id)) return null
|
|
const allNodes = collectAllNodesFlat(treeStructure)
|
|
const target = allNodes.find(n => n.id === option.next_node_id)
|
|
if (!target) return null
|
|
return (
|
|
<div className="flex items-center gap-1 text-xs text-primary" title={`Links to: ${target.label}`}>
|
|
<Link2 className="h-3 w-3" />
|
|
</div>
|
|
)
|
|
})()}
|
|
</div>
|
|
)
|
|
}}
|
|
/>
|
|
</div>
|
|
|
|
{/* Quick-link: assign an option to an existing node */}
|
|
<details className="mt-2">
|
|
<summary className="cursor-pointer text-xs text-muted-foreground hover:text-foreground">
|
|
<Link2 className="inline h-3 w-3 mr-1" />
|
|
Link an option to an existing node (cross-reference)
|
|
</summary>
|
|
<div className="mt-2 space-y-2 rounded-md border border-border bg-accent/30 p-3">
|
|
<p className="text-xs text-muted-foreground">
|
|
Select an option, then pick a target node. This creates a loop-back or cross-reference.
|
|
</p>
|
|
<div className="flex gap-2">
|
|
<select
|
|
id="xref-option-select"
|
|
className={cn(
|
|
'flex-1 rounded-md border border-border px-2 py-1.5 text-xs',
|
|
'bg-card text-foreground'
|
|
)}
|
|
defaultValue=""
|
|
>
|
|
<option value="">Select option...</option>
|
|
{(node.options || []).map((opt, i) => (
|
|
<option key={opt.id} value={i}>
|
|
{indexToLetter(i)}: {opt.label || '(empty)'}
|
|
</option>
|
|
))}
|
|
</select>
|
|
<select
|
|
id="xref-target-select"
|
|
className={cn(
|
|
'flex-1 rounded-md border border-border px-2 py-1.5 text-xs',
|
|
'bg-card text-foreground'
|
|
)}
|
|
defaultValue=""
|
|
onChange={(e) => {
|
|
const optSelect = document.getElementById('xref-option-select') as HTMLSelectElement
|
|
const optIndex = parseInt(optSelect?.value, 10)
|
|
const targetId = e.target.value
|
|
if (!isNaN(optIndex) && targetId) {
|
|
handleUpdateOption(optIndex, { next_node_id: targetId })
|
|
// Reset selects
|
|
optSelect.value = ''
|
|
e.target.value = ''
|
|
}
|
|
}}
|
|
>
|
|
<option value="">Select target node...</option>
|
|
{(() => {
|
|
const treeStructure = useTreeEditorStore.getState().treeStructure
|
|
const allNodes = collectAllNodesFlat(treeStructure)
|
|
return allNodes
|
|
.filter(n => n.id !== node.id && n.type !== 'answer')
|
|
.map(n => (
|
|
<option key={n.id} value={n.id}>
|
|
{' '.repeat(n.depth)}{n.type === 'decision' ? '?' : n.type === 'action' ? '>' : '*'} {n.label}
|
|
</option>
|
|
))
|
|
})()}
|
|
</select>
|
|
</div>
|
|
</div>
|
|
</details>
|
|
|
|
{/* 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
|