AI-generated trees use descriptive IDs (e.g., "verify-account-exists") instead of "root", causing the root node to be falsely flagged as orphaned. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1102 lines
34 KiB
TypeScript
1102 lines
34 KiB
TypeScript
import { create } from 'zustand'
|
|
import { temporal } from 'zundo'
|
|
import { shallow } from 'zustand/shallow'
|
|
import { immer } from 'zustand/middleware/immer'
|
|
import type { Tree, TreeStructure, TreeCreate, TreeUpdate, NodeType, TreeMarkdownValidationError, TreeMarkdownValidation } from '@/types'
|
|
import { treeStructureToMarkdownPreview } from '@/lib/treeMarkdownSync'
|
|
import { safeGetItem, safeSetItem, safeRemoveItem } from '@/lib/utils'
|
|
|
|
// Throttle helper: captures first call immediately, then throttles subsequent calls
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
function throttle<T extends (...args: any[]) => void>(fn: T, ms: number): T {
|
|
let lastCall = 0
|
|
let timeout: ReturnType<typeof setTimeout> | null = null
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
return ((...args: any[]) => {
|
|
const now = Date.now()
|
|
if (now - lastCall >= ms) {
|
|
lastCall = now
|
|
fn(...args)
|
|
} else {
|
|
// Schedule a trailing call to capture the final state
|
|
if (timeout) clearTimeout(timeout)
|
|
timeout = setTimeout(() => {
|
|
lastCall = Date.now()
|
|
fn(...args)
|
|
}, ms - (now - lastCall))
|
|
}
|
|
}) as T
|
|
}
|
|
|
|
// 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'
|
|
|
|
// Check if a tree is effectively empty (just initialized, no real content)
|
|
const isEmptyTree = (tree: TreeStructure | null): boolean => {
|
|
if (!tree) return true
|
|
if (tree.question && tree.question.trim()) return false
|
|
if (tree.children && tree.children.length > 0) return false
|
|
return true
|
|
}
|
|
|
|
// Starter template for new trees in Code Mode — all @refs are valid within the template
|
|
const CODE_MODE_STARTER_TEMPLATE = `---
|
|
name: My New Tree
|
|
description: A troubleshooting decision tree
|
|
---
|
|
|
|
---
|
|
id: root
|
|
type: decision
|
|
---
|
|
# What type of issue is the user experiencing?
|
|
|
|
> Select the category that best matches the reported problem
|
|
|
|
- [A] Option A \u2192 @option_a_action
|
|
- [B] Option B \u2192 @option_b_action
|
|
|
|
---
|
|
id: option_a_action
|
|
type: action
|
|
parent: root
|
|
---
|
|
## Investigate Option A
|
|
|
|
Describe the investigation steps here.
|
|
|
|
\`\`\`commands
|
|
example-command --flag
|
|
\`\`\`
|
|
|
|
**Expected:** Describe expected results here
|
|
|
|
\u2192 @resolution
|
|
|
|
---
|
|
id: option_b_action
|
|
type: action
|
|
parent: root
|
|
---
|
|
## Investigate Option B
|
|
|
|
Describe the investigation steps here.
|
|
|
|
\u2192 @resolution
|
|
|
|
---
|
|
id: resolution
|
|
type: solution
|
|
parent: root
|
|
---
|
|
## Resolution
|
|
|
|
1. Document findings
|
|
2. Apply the fix
|
|
3. Verify the issue is resolved
|
|
`
|
|
|
|
// Helper to generate unique IDs
|
|
const generateId = () => crypto.randomUUID()
|
|
|
|
// Helper to find a node in the tree structure (exported for drag validation)
|
|
export 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
|
|
}
|
|
|
|
/** Collect all nodes in the tree as a flat list with depth info. */
|
|
export function collectAllNodesFlat(
|
|
root: TreeStructure | null
|
|
): Array<{ id: string; label: string; type: string; depth: number }> {
|
|
if (!root) return []
|
|
const result: Array<{ id: string; label: string; type: string; depth: number }> = []
|
|
|
|
function walk(node: TreeStructure, depth: number) {
|
|
const label = node.type === 'decision'
|
|
? (node.question || 'Untitled Decision')
|
|
: (node.title || `Untitled ${node.type}`)
|
|
result.push({ id: node.id, label, type: node.type, depth })
|
|
node.children?.forEach(child => walk(child, depth + 1))
|
|
}
|
|
|
|
walk(root, 0)
|
|
return result
|
|
}
|
|
|
|
// 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
|
|
categoryId: string | null
|
|
tags: string[]
|
|
isPublic: boolean
|
|
treeStructure: TreeStructure | null
|
|
originalTree: Tree | null // For comparison in edit mode
|
|
|
|
// UI state
|
|
selectedNodeId: string | null
|
|
isDirty: boolean
|
|
isLoading: boolean
|
|
isSaving: boolean
|
|
validationErrors: ValidationError[]
|
|
|
|
// Code Mode state
|
|
editorMode: 'form' | 'code'
|
|
markdownSource: string | null
|
|
markdownValidationErrors: TreeMarkdownValidationError[]
|
|
isMarkdownValid: boolean
|
|
isValidating: boolean
|
|
lastValidTreeFromMarkdown: TreeStructure | null
|
|
|
|
// 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
|
|
setCategoryId: (categoryId: string | null) => void
|
|
setTags: (tags: string[]) => void
|
|
addTag: (tag: string) => void
|
|
removeTag: (tag: string) => void
|
|
setIsPublic: (isPublic: boolean) => 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
|
|
moveNode: (nodeId: string, targetParentId: string, targetIndex: 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 - Code Mode
|
|
setEditorMode: (mode: 'form' | 'code') => void
|
|
setMarkdownSource: (markdown: string) => void
|
|
setMarkdownValidationResult: (result: TreeMarkdownValidation) => void
|
|
syncMarkdownToTree: () => void
|
|
syncTreeToMarkdown: () => void
|
|
|
|
// 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: '',
|
|
categoryId: null,
|
|
tags: [],
|
|
isPublic: false,
|
|
treeStructure: null,
|
|
originalTree: null,
|
|
selectedNodeId: null,
|
|
isDirty: false,
|
|
isLoading: false,
|
|
isSaving: false,
|
|
validationErrors: [],
|
|
editorMode: 'form',
|
|
markdownSource: null,
|
|
markdownValidationErrors: [],
|
|
isMarkdownValid: true,
|
|
isValidating: false,
|
|
lastValidTreeFromMarkdown: null,
|
|
lastSavedAt: null,
|
|
draftSavedAt: null,
|
|
hasDraft: false,
|
|
|
|
// Check for existing draft on init
|
|
initNewTree: () => {
|
|
const hasDraft = safeGetItem(DRAFT_STORAGE_KEY) !== null
|
|
set((state) => {
|
|
state.treeId = null
|
|
state.name = ''
|
|
state.description = ''
|
|
state.category = ''
|
|
state.categoryId = null
|
|
state.tags = []
|
|
state.isPublic = false
|
|
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.editorMode = 'form'
|
|
state.markdownSource = null
|
|
state.markdownValidationErrors = []
|
|
state.isMarkdownValid = true
|
|
state.isValidating = false
|
|
state.lastValidTreeFromMarkdown = null
|
|
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.categoryId = tree.category_id || null
|
|
state.tags = tree.tags || []
|
|
state.isPublic = tree.is_public || false
|
|
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 = safeGetItem(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.categoryId = draft.categoryId || null
|
|
state.tags = draft.tags || []
|
|
state.isPublic = draft.isPublic || false
|
|
state.treeStructure = draft.treeStructure || null
|
|
state.isDirty = true
|
|
state.draftSavedAt = draft.savedAt ? new Date(draft.savedAt) : null
|
|
state.hasDraft = false
|
|
})
|
|
return true
|
|
} catch {
|
|
safeRemoveItem(DRAFT_STORAGE_KEY)
|
|
return false
|
|
}
|
|
},
|
|
|
|
discardDraft: () => {
|
|
safeRemoveItem(DRAFT_STORAGE_KEY)
|
|
set((state) => {
|
|
state.hasDraft = false
|
|
})
|
|
},
|
|
|
|
reset: () => {
|
|
set((state) => {
|
|
state.treeId = null
|
|
state.name = ''
|
|
state.description = ''
|
|
state.category = ''
|
|
state.categoryId = null
|
|
state.tags = []
|
|
state.isPublic = false
|
|
state.treeStructure = null
|
|
state.originalTree = null
|
|
state.selectedNodeId = null
|
|
state.isDirty = false
|
|
state.isLoading = false
|
|
state.isSaving = false
|
|
state.validationErrors = []
|
|
state.editorMode = 'form'
|
|
state.markdownSource = null
|
|
state.markdownValidationErrors = []
|
|
state.isMarkdownValid = true
|
|
state.isValidating = false
|
|
state.lastValidTreeFromMarkdown = null
|
|
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()
|
|
},
|
|
|
|
setCategoryId: (categoryId: string | null) => {
|
|
set((state) => {
|
|
state.categoryId = categoryId
|
|
state.isDirty = true
|
|
})
|
|
get().autoSaveDraft()
|
|
},
|
|
|
|
setTags: (tags: string[]) => {
|
|
set((state) => {
|
|
state.tags = tags
|
|
state.isDirty = true
|
|
})
|
|
get().autoSaveDraft()
|
|
},
|
|
|
|
addTag: (tag: string) => {
|
|
set((state) => {
|
|
if (!state.tags.includes(tag)) {
|
|
state.tags.push(tag)
|
|
state.isDirty = true
|
|
}
|
|
})
|
|
get().autoSaveDraft()
|
|
},
|
|
|
|
removeTag: (tag: string) => {
|
|
set((state) => {
|
|
state.tags = state.tags.filter(t => t !== tag)
|
|
state.isDirty = true
|
|
})
|
|
get().autoSaveDraft()
|
|
},
|
|
|
|
setIsPublic: (isPublic: boolean) => {
|
|
set((state) => {
|
|
state.isPublic = isPublic
|
|
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()
|
|
},
|
|
|
|
moveNode: (nodeId: string, targetParentId: string, targetIndex: number) => {
|
|
set((state) => {
|
|
// Find and remove from current parent
|
|
const currentParent = findParentNode(nodeId, state.treeStructure)
|
|
if (!currentParent?.children) return
|
|
|
|
const sourceIndex = currentParent.children.findIndex(c => c.id === nodeId)
|
|
if (sourceIndex === -1) return
|
|
|
|
const [movedNode] = currentParent.children.splice(sourceIndex, 1)
|
|
|
|
// Find target parent and insert
|
|
const targetParent = findNodeInTree(targetParentId, state.treeStructure)
|
|
if (!targetParent) return
|
|
|
|
if (!targetParent.children) {
|
|
targetParent.children = []
|
|
}
|
|
|
|
// Adjust index if moving within same parent and source was before target
|
|
let adjustedIndex = targetIndex
|
|
if (currentParent.id === targetParent.id && sourceIndex < targetIndex) {
|
|
adjustedIndex = targetIndex - 1
|
|
}
|
|
|
|
targetParent.children.splice(adjustedIndex, 0, movedNode)
|
|
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'
|
|
})
|
|
}
|
|
// Decision nodes with children must have at least 2 branches
|
|
if (node.children && node.children.length > 0 && node.children.length < 2) {
|
|
errors.push({
|
|
nodeId: node.id,
|
|
field: 'children',
|
|
message: `Decision node "${node.id}" must have at least 2 branches`,
|
|
severity: 'error'
|
|
})
|
|
}
|
|
if (node.options && node.options.length > 0) {
|
|
// 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 circular references in next_node_id chains
|
|
const detectCircularRefs = (startId: string, visited: Set<string> = new Set()): boolean => {
|
|
if (visited.has(startId)) return true
|
|
visited.add(startId)
|
|
|
|
const node = findNodeInTree(startId, state.treeStructure)
|
|
if (!node) return false
|
|
|
|
// Check options
|
|
if (node.options) {
|
|
for (const opt of node.options) {
|
|
if (opt.next_node_id && detectCircularRefs(opt.next_node_id, new Set(visited))) {
|
|
errors.push({
|
|
nodeId: node.id,
|
|
message: `This path loops back to an earlier node via "${opt.label}"`,
|
|
severity: 'warning'
|
|
})
|
|
return true
|
|
}
|
|
}
|
|
}
|
|
|
|
// Check next_node_id
|
|
if (node.next_node_id && detectCircularRefs(node.next_node_id, new Set(visited))) {
|
|
errors.push({
|
|
nodeId: node.id,
|
|
message: `This node loops back to an earlier node ("${node.title || node.id}")`,
|
|
severity: 'warning'
|
|
})
|
|
return true
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
// Run from root
|
|
detectCircularRefs('root')
|
|
|
|
// 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 !== state.treeStructure?.id && !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,
|
|
categoryId: state.categoryId,
|
|
tags: state.tags,
|
|
isPublic: state.isPublic,
|
|
treeStructure: state.treeStructure,
|
|
savedAt: new Date().toISOString()
|
|
}
|
|
safeSetItem(DRAFT_STORAGE_KEY, JSON.stringify(draft))
|
|
set((s) => { s.draftSavedAt = new Date() })
|
|
},
|
|
|
|
markSaved: () => {
|
|
safeRemoveItem(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,
|
|
category_id: state.categoryId || undefined,
|
|
tags: state.tags.length > 0 ? state.tags : undefined,
|
|
is_public: state.isPublic,
|
|
tree_structure: state.treeStructure!
|
|
}
|
|
},
|
|
|
|
// Code Mode actions
|
|
setEditorMode: (mode: 'form' | 'code') => {
|
|
const current = get()
|
|
if (mode === current.editorMode) return
|
|
|
|
if (mode === 'code') {
|
|
// Form → Code: generate markdown from tree structure (synchronous)
|
|
const { treeStructure, name, description, category, tags } = current
|
|
if (isEmptyTree(treeStructure) && !name.trim()) {
|
|
// New empty tree: use starter template
|
|
set((state) => {
|
|
state.markdownSource = CODE_MODE_STARTER_TEMPLATE
|
|
state.markdownValidationErrors = []
|
|
state.isMarkdownValid = false // needs validation
|
|
state.isValidating = false
|
|
state.editorMode = mode
|
|
})
|
|
} else if (treeStructure) {
|
|
const metadata = { name, description, category, tags }
|
|
const md = treeStructureToMarkdownPreview(treeStructure, metadata)
|
|
set((state) => {
|
|
state.markdownSource = md
|
|
state.markdownValidationErrors = []
|
|
state.isMarkdownValid = true
|
|
state.isValidating = false
|
|
state.editorMode = mode
|
|
})
|
|
} else {
|
|
set((state) => { state.editorMode = mode })
|
|
}
|
|
} else {
|
|
// Code → Form: apply last valid parse to tree structure
|
|
get().syncMarkdownToTree()
|
|
set((state) => { state.editorMode = mode })
|
|
}
|
|
},
|
|
|
|
setMarkdownSource: (markdown: string) => {
|
|
set((state) => {
|
|
state.markdownSource = markdown
|
|
state.isDirty = true
|
|
state.isValidating = true
|
|
})
|
|
},
|
|
|
|
setMarkdownValidationResult: (result: TreeMarkdownValidation) => {
|
|
set((state) => {
|
|
state.markdownValidationErrors = result.errors
|
|
state.isMarkdownValid = result.valid
|
|
state.isValidating = false
|
|
if (result.valid && result.tree_structure) {
|
|
state.lastValidTreeFromMarkdown = result.tree_structure as TreeStructure
|
|
// Live sync: apply valid parsed tree to treeStructure immediately
|
|
state.treeStructure = result.tree_structure as TreeStructure
|
|
}
|
|
// Apply metadata from parsed markdown (bidirectional sync)
|
|
if (result.metadata) {
|
|
if (result.metadata.name !== undefined) state.name = result.metadata.name
|
|
if (result.metadata.description !== undefined) state.description = result.metadata.description
|
|
if (result.metadata.category !== undefined) state.category = result.metadata.category
|
|
if (result.metadata.tags !== undefined) state.tags = result.metadata.tags
|
|
}
|
|
})
|
|
},
|
|
|
|
syncMarkdownToTree: () => {
|
|
const { lastValidTreeFromMarkdown, isMarkdownValid } = get()
|
|
if (isMarkdownValid && lastValidTreeFromMarkdown) {
|
|
set((state) => {
|
|
state.treeStructure = lastValidTreeFromMarkdown
|
|
state.markdownSource = null
|
|
state.isDirty = true
|
|
})
|
|
}
|
|
},
|
|
|
|
syncTreeToMarkdown: () => {
|
|
const { treeStructure, name, description, category, tags } = get()
|
|
if (treeStructure) {
|
|
const metadata = { name, description, category, tags }
|
|
const md = treeStructureToMarkdownPreview(treeStructure, metadata)
|
|
set((state) => {
|
|
state.markdownSource = md
|
|
state.markdownValidationErrors = []
|
|
state.isMarkdownValid = true
|
|
state.isValidating = false
|
|
})
|
|
}
|
|
},
|
|
|
|
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 type: NodeType = 'decision'
|
|
let title = ''
|
|
|
|
if (node) {
|
|
type = node.type
|
|
if (node.question) title = node.question.slice(0, 50)
|
|
else if (node.title) title = node.title.slice(0, 50)
|
|
}
|
|
|
|
// Use short ID format, but 'root' stays as-is
|
|
const shortId = id === 'root' ? 'root' : id.slice(0, 8) + '...'
|
|
|
|
// Format: "Title (shortId)" or just "(shortId)" if no title
|
|
// For root without a question, show "Root Question (root)"
|
|
let label: string
|
|
if (id === 'root') {
|
|
label = title ? `${title} (root)` : 'Root Question (root)'
|
|
} else if (title) {
|
|
label = `${title} (${shortId})`
|
|
} else {
|
|
// No title yet - show placeholder with ID
|
|
const typeName = type === 'decision' ? 'Untitled Question' : `Untitled ${type}`
|
|
label = `${typeName} (${shortId})`
|
|
}
|
|
|
|
return { id, label, 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,
|
|
categoryId: state.categoryId,
|
|
tags: state.tags,
|
|
isPublic: state.isPublic,
|
|
treeStructure: state.treeStructure
|
|
}),
|
|
// Skip no-op entries where partialized fields haven't changed
|
|
equality: (pastState, currentState) => shallow(pastState, currentState),
|
|
// Throttle history captures: collapse rapid changes (typing, validation) into ~one entry per 3s
|
|
// This makes Flow Mode undo revert meaningful chunks (whole field edits) instead of single characters
|
|
handleSet: (handleSet) =>
|
|
throttle<typeof handleSet>((state) => {
|
|
handleSet(state)
|
|
}, 3000),
|
|
}
|
|
)
|
|
)
|
|
|
|
// Export temporal store for undo/redo access
|
|
// Use with: useStore(useTreeEditorStore.temporal, selector)
|
|
export const useTreeEditorTemporal = useTreeEditorStore.temporal
|
|
|
|
export default useTreeEditorStore
|