Frontend features: - My Trees personal dashboard with fork tracking (Issue #15) - Tree sharing UI with token generation and copy (Issue #16) - Draft tree badges and validation UI (Issue #25) - Save session as tree modal (Issue #17) - Rate/review modal with localStorage tracking (Issue #19) - Admin category management with drag-and-drop (Issue #18) - Bundle size optimization with code splitting (Issue #31) Components created: - MyTreesPage: Personal tree organization - AdminCategoriesPage: Category CRUD with @dnd-kit - ShareTreeModal: Tree sharing interface - SaveSessionAsTreeModal: Session conversion UI - StepRatingModal: Post-session rating with stars - StarRating: Reusable rating component - PageLoader: Loading fallback for lazy routes - CreateCategoryModal, EditCategoryModal: Admin modals Bundle optimization: - Reduced from 892 KB to 221 KB (75% reduction) - Dynamic imports for 9 heavy pages - Vendor chunk splitting for optimal caching - 6 separate vendor chunks (react, markdown, utils, dnd, icons, state) Dependencies added: - @dnd-kit/core, @dnd-kit/sortable, @dnd-kit/utilities API clients: - stepCategories: Full CRUD for admin - Enhanced sessions: saveAsTree endpoint - Enhanced trees: share, fork, canPublish endpoints Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
178 lines
5.4 KiB
TypeScript
178 lines
5.4 KiB
TypeScript
import { useMemo } from 'react'
|
|
import type { TreeStructure, ValidationError } from '@/types'
|
|
|
|
interface ValidationResult {
|
|
canPublish: boolean
|
|
errors: ValidationError[]
|
|
warnings: ValidationError[]
|
|
}
|
|
|
|
/**
|
|
* Client-side tree validation hook
|
|
* Validates tree structure before allowing publish
|
|
*/
|
|
export function useTreeValidation(
|
|
name: string,
|
|
description: string | null,
|
|
treeStructure: TreeStructure | null
|
|
): ValidationResult {
|
|
return useMemo(() => {
|
|
const errors: ValidationError[] = []
|
|
const warnings: ValidationError[] = []
|
|
|
|
// Validate name
|
|
if (!name || name.trim().length === 0) {
|
|
errors.push({
|
|
field: 'name',
|
|
message: 'Tree name is required',
|
|
})
|
|
} else if (name.length > 255) {
|
|
errors.push({
|
|
field: 'name',
|
|
message: 'Tree name must be 255 characters or less',
|
|
})
|
|
}
|
|
|
|
// Validate tree structure exists
|
|
if (!treeStructure) {
|
|
errors.push({
|
|
field: 'tree_structure',
|
|
message: 'Tree structure is required',
|
|
})
|
|
// Can't validate further without a tree structure
|
|
return {
|
|
canPublish: errors.length === 0,
|
|
errors,
|
|
warnings,
|
|
}
|
|
}
|
|
|
|
// Validate root node
|
|
if (!treeStructure.type) {
|
|
errors.push({
|
|
field: 'tree_structure.type',
|
|
message: 'Root node must have a type',
|
|
})
|
|
}
|
|
|
|
// Validate root node content based on type
|
|
if (treeStructure.type === 'decision') {
|
|
if (!treeStructure.question || treeStructure.question.trim().length === 0) {
|
|
errors.push({
|
|
field: 'tree_structure.question',
|
|
message: 'Decision node must have a question',
|
|
})
|
|
}
|
|
if (!treeStructure.options || treeStructure.options.length === 0) {
|
|
errors.push({
|
|
field: 'tree_structure.options',
|
|
message: 'Decision node must have at least one option',
|
|
})
|
|
} else {
|
|
// Validate each option
|
|
treeStructure.options.forEach((option, index) => {
|
|
if (!option.label || option.label.trim().length === 0) {
|
|
errors.push({
|
|
field: `tree_structure.options[${index}].label`,
|
|
message: `Option ${index + 1} must have a label`,
|
|
})
|
|
}
|
|
})
|
|
}
|
|
} else if (treeStructure.type === 'action') {
|
|
if (!treeStructure.title || treeStructure.title.trim().length === 0) {
|
|
errors.push({
|
|
field: 'tree_structure.title',
|
|
message: 'Action node must have a title',
|
|
})
|
|
}
|
|
if (!treeStructure.description || treeStructure.description.trim().length === 0) {
|
|
errors.push({
|
|
field: 'tree_structure.description',
|
|
message: 'Action node must have a description',
|
|
})
|
|
}
|
|
} else if (treeStructure.type === 'solution') {
|
|
if (!treeStructure.title || treeStructure.title.trim().length === 0) {
|
|
errors.push({
|
|
field: 'tree_structure.title',
|
|
message: 'Solution node must have a title',
|
|
})
|
|
}
|
|
if (!treeStructure.description || treeStructure.description.trim().length === 0) {
|
|
errors.push({
|
|
field: 'tree_structure.description',
|
|
message: 'Solution node must have a description',
|
|
})
|
|
}
|
|
}
|
|
|
|
// Validate children recursively (basic check)
|
|
const validateChildren = (node: TreeStructure, path: string = 'tree_structure') => {
|
|
if (node.children && node.children.length > 0) {
|
|
node.children.forEach((child, index) => {
|
|
const childPath = `${path}.children[${index}]`
|
|
|
|
if (!child.type) {
|
|
errors.push({
|
|
field: `${childPath}.type`,
|
|
message: 'Child node must have a type',
|
|
})
|
|
}
|
|
|
|
// Recursively validate
|
|
if (child.type === 'decision' && (!child.question || child.question.trim().length === 0)) {
|
|
errors.push({
|
|
field: `${childPath}.question`,
|
|
message: 'Decision node must have a question',
|
|
})
|
|
}
|
|
if ((child.type === 'action' || child.type === 'solution') &&
|
|
(!child.title || child.title.trim().length === 0)) {
|
|
errors.push({
|
|
field: `${childPath}.title`,
|
|
message: `${child.type} node must have a title`,
|
|
})
|
|
}
|
|
|
|
if (child.children) {
|
|
validateChildren(child, childPath)
|
|
}
|
|
})
|
|
} else if (node.type === 'decision' && (!node.options || node.options.length === 0)) {
|
|
// Decision nodes without children should have had options validation above
|
|
// This is just a warning for decision nodes that might be incomplete
|
|
warnings.push({
|
|
field: path,
|
|
message: 'Decision node has no children (paths)',
|
|
})
|
|
}
|
|
}
|
|
|
|
validateChildren(treeStructure)
|
|
|
|
// Warnings
|
|
if (!description || description.trim().length === 0) {
|
|
warnings.push({
|
|
field: 'description',
|
|
message: 'Adding a description helps users understand the tree purpose',
|
|
})
|
|
}
|
|
|
|
if (treeStructure.type === 'decision' &&
|
|
treeStructure.children &&
|
|
treeStructure.children.length < 2) {
|
|
warnings.push({
|
|
field: 'tree_structure',
|
|
message: 'Tree has very few paths - consider adding more troubleshooting options',
|
|
})
|
|
}
|
|
|
|
return {
|
|
canPublish: errors.length === 0,
|
|
errors,
|
|
warnings,
|
|
}
|
|
}, [name, description, treeStructure])
|
|
}
|