feat: implement My Trees, admin UI, rating modal, and bundle optimization (Issues #15, #18, #19, #31)
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>
This commit is contained in:
177
frontend/src/hooks/useTreeValidation.ts
Normal file
177
frontend/src/hooks/useTreeValidation.ts
Normal file
@@ -0,0 +1,177 @@
|
||||
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])
|
||||
}
|
||||
Reference in New Issue
Block a user