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:
112
frontend/src/components/tree-editor/DynamicArrayField.tsx
Normal file
112
frontend/src/components/tree-editor/DynamicArrayField.tsx
Normal 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
|
||||
85
frontend/src/components/tree-editor/NodeEditorModal.tsx
Normal file
85
frontend/src/components/tree-editor/NodeEditorModal.tsx
Normal 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
|
||||
152
frontend/src/components/tree-editor/NodeFormAction.tsx
Normal file
152
frontend/src/components/tree-editor/NodeFormAction.tsx
Normal 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
|
||||
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
|
||||
129
frontend/src/components/tree-editor/NodeFormResolution.tsx
Normal file
129
frontend/src/components/tree-editor/NodeFormResolution.tsx
Normal 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
|
||||
499
frontend/src/components/tree-editor/NodeList.tsx
Normal file
499
frontend/src/components/tree-editor/NodeList.tsx
Normal 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' : ''
|
||||
)}
|
||||
>
|
||||
|
||||
</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
|
||||
156
frontend/src/components/tree-editor/NodePicker.tsx
Normal file
156
frontend/src/components/tree-editor/NodePicker.tsx
Normal 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
|
||||
44
frontend/src/components/tree-editor/TreeEditorLayout.tsx
Normal file
44
frontend/src/components/tree-editor/TreeEditorLayout.tsx
Normal 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
|
||||
132
frontend/src/components/tree-editor/TreeMetadataForm.tsx
Normal file
132
frontend/src/components/tree-editor/TreeMetadataForm.tsx
Normal 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
|
||||
9
frontend/src/components/tree-editor/index.ts
Normal file
9
frontend/src/components/tree-editor/index.ts
Normal 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'
|
||||
Reference in New Issue
Block a user