- Extract useCustomStepFlow hook from TreeNavigationPage (1040 → 759 lines) - Create core/filters.py with shared tree/step visibility filters - Create services/export_service.py from session export logic - Add GitHub Actions CI/CD pipeline (pytest + lint + build) - Add GIN index migration for full-text search on trees - Update FastAPI 0.128.5, Pydantic 2.12.5, SQLAlchemy 2.0.46, +5 more - Fix regex → pattern deprecation in Query() params Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
363 lines
10 KiB
TypeScript
363 lines
10 KiB
TypeScript
import { useState } from 'react'
|
|
import { useNavigate } from 'react-router-dom'
|
|
import { sessionsApi, stepsApi } from '@/api'
|
|
import { treesApi } from '@/api'
|
|
import type { Tree, Session, DecisionRecord, TreeStructure, CustomStep, Step } from '@/types'
|
|
import type { CustomStepDraft } from '@/components/step-library/CustomStepModal'
|
|
import type { DescendantNode } from '@/components/session'
|
|
|
|
interface UseCustomStepFlowParams {
|
|
tree: Tree | null
|
|
session: Session | null
|
|
currentNodeId: string
|
|
pathTaken: string[]
|
|
decisions: DecisionRecord[]
|
|
notes: string
|
|
findNode: (nodeId: string, structure?: TreeStructure) => TreeStructure | null
|
|
setCurrentNodeId: (id: string) => void
|
|
setPathTaken: (path: string[]) => void
|
|
setDecisions: (decisions: DecisionRecord[]) => void
|
|
setNotes: (notes: string) => void
|
|
setIsCompleting: (completing: boolean) => void
|
|
setError: (error: string | null) => void
|
|
isCompleting: boolean
|
|
}
|
|
|
|
export function useCustomStepFlow({
|
|
tree,
|
|
session,
|
|
currentNodeId,
|
|
pathTaken,
|
|
decisions,
|
|
notes,
|
|
findNode,
|
|
setCurrentNodeId,
|
|
setPathTaken,
|
|
setDecisions,
|
|
setNotes,
|
|
setIsCompleting,
|
|
setError,
|
|
isCompleting,
|
|
}: UseCustomStepFlowParams) {
|
|
const navigate = useNavigate()
|
|
|
|
// Custom steps
|
|
const [customSteps, setCustomSteps] = useState<CustomStep[]>([])
|
|
const [showCustomStepModal, setShowCustomStepModal] = useState(false)
|
|
|
|
// Post-step action flow
|
|
const [showPostStepModal, setShowPostStepModal] = useState(false)
|
|
const [pendingStep, setPendingStep] = useState<Step | CustomStepDraft | null>(null)
|
|
const [pendingStepIsFromLibrary, setPendingStepIsFromLibrary] = useState(false)
|
|
const [isSavingStep, setIsSavingStep] = useState(false)
|
|
|
|
// Continuation flow
|
|
const [showContinuationModal, setShowContinuationModal] = useState(false)
|
|
const [branchOriginNodeId, setBranchOriginNodeId] = useState<string | null>(null)
|
|
const [pendingContinuationNodeId, setPendingContinuationNodeId] = useState<string | null>(null)
|
|
|
|
// Custom branch mode
|
|
const [customBranchMode, setCustomBranchMode] = useState(false)
|
|
|
|
// Fork flow
|
|
const [showForkModal, setShowForkModal] = useState(false)
|
|
|
|
const findCustomStep = (nodeId: string): CustomStep | null => {
|
|
return customSteps.find(cs => cs.id === nodeId) || null
|
|
}
|
|
|
|
// Get descendant nodes two levels deep (grandchildren) from a decision node's options.
|
|
const getDescendantNodes = (decisionNodeId: string): DescendantNode[] => {
|
|
const decisionNode = findNode(decisionNodeId, tree?.tree_structure)
|
|
if (!decisionNode || decisionNode.type !== 'decision' || !decisionNode.options) {
|
|
return []
|
|
}
|
|
|
|
const descendants: DescendantNode[] = []
|
|
|
|
for (const option of decisionNode.options) {
|
|
if (!option.next_node_id) continue
|
|
const childNode = findNode(option.next_node_id, tree?.tree_structure)
|
|
if (!childNode) continue
|
|
|
|
if (childNode.type === 'decision' && childNode.options) {
|
|
for (const childOption of childNode.options) {
|
|
if (!childOption.next_node_id) continue
|
|
const grandchild = findNode(childOption.next_node_id, tree?.tree_structure)
|
|
if (!grandchild) continue
|
|
|
|
descendants.push({
|
|
id: grandchild.id,
|
|
label: grandchild.question || grandchild.title || 'Untitled',
|
|
type: grandchild.type,
|
|
parentOptionLabel: `${option.label} \u2192 ${childOption.label}`
|
|
})
|
|
}
|
|
} else if (childNode.type === 'action' && childNode.next_node_id) {
|
|
const grandchild = findNode(childNode.next_node_id, tree?.tree_structure)
|
|
if (grandchild) {
|
|
descendants.push({
|
|
id: grandchild.id,
|
|
label: grandchild.question || grandchild.title || 'Untitled',
|
|
type: grandchild.type,
|
|
parentOptionLabel: `${option.label} \u2192 ${childNode.title || 'Action'}`
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
return descendants
|
|
}
|
|
|
|
// Navigate back to a previously-created custom step from the decision node
|
|
const handleNavigateToCustomStep = (customStep: CustomStep) => {
|
|
const newPath = [...pathTaken, customStep.id]
|
|
setPathTaken(newPath)
|
|
setCurrentNodeId(customStep.id)
|
|
}
|
|
|
|
// Called when CustomStepModal submits - show action modal instead of inserting directly
|
|
const handleStepCreated = (step: Step | CustomStepDraft, isFromLibrary: boolean) => {
|
|
setPendingStep(step)
|
|
setPendingStepIsFromLibrary(isFromLibrary)
|
|
setShowCustomStepModal(false)
|
|
setShowPostStepModal(true)
|
|
}
|
|
|
|
const resetPendingStep = () => {
|
|
setShowPostStepModal(false)
|
|
setPendingStep(null)
|
|
setPendingStepIsFromLibrary(false)
|
|
setIsSavingStep(false)
|
|
}
|
|
|
|
// Save to library only, don't insert into session
|
|
const handleSaveForLater = async () => {
|
|
if (!pendingStep) return
|
|
|
|
setIsSavingStep(true)
|
|
try {
|
|
if (!pendingStepIsFromLibrary) {
|
|
await stepsApi.create({
|
|
title: pendingStep.title,
|
|
step_type: pendingStep.step_type,
|
|
content: pendingStep.content,
|
|
visibility: 'private',
|
|
tags: pendingStep.tags || []
|
|
})
|
|
}
|
|
resetPendingStep()
|
|
} catch (err) {
|
|
console.error('Failed to save step to library:', err)
|
|
setIsSavingStep(false)
|
|
}
|
|
}
|
|
|
|
// Insert into session and show continuation options
|
|
const handleUseNow = async () => {
|
|
if (!pendingStep || !session) return
|
|
|
|
setIsSavingStep(true)
|
|
try {
|
|
setBranchOriginNodeId(currentNodeId)
|
|
|
|
const customStep: CustomStep = {
|
|
id: crypto.randomUUID(),
|
|
inserted_after_node_id: currentNodeId,
|
|
step_data: pendingStep,
|
|
timestamp: new Date().toISOString()
|
|
}
|
|
|
|
const newDecision: DecisionRecord = {
|
|
node_id: customStep.id,
|
|
question: null,
|
|
answer: null,
|
|
action_performed: `Custom Step: ${pendingStep.title}`,
|
|
notes: pendingStep.content.instructions || null,
|
|
automation_used: false,
|
|
timestamp: new Date().toISOString(),
|
|
attachments: []
|
|
}
|
|
|
|
const newCustomSteps = [...customSteps, customStep]
|
|
const newDecisions = [...decisions, newDecision]
|
|
const newPath = [...pathTaken, customStep.id]
|
|
|
|
setCustomSteps(newCustomSteps)
|
|
setDecisions(newDecisions)
|
|
setPathTaken(newPath)
|
|
setCurrentNodeId(customStep.id)
|
|
|
|
await sessionsApi.update(session.id, {
|
|
path_taken: newPath,
|
|
decisions: newDecisions,
|
|
custom_steps: newCustomSteps
|
|
})
|
|
|
|
resetPendingStep()
|
|
setShowContinuationModal(true)
|
|
} catch (err) {
|
|
console.error('Failed to insert custom step:', err)
|
|
setIsSavingStep(false)
|
|
}
|
|
}
|
|
|
|
// Save to library AND insert into session
|
|
const handleBoth = async () => {
|
|
if (!pendingStep) return
|
|
|
|
setIsSavingStep(true)
|
|
try {
|
|
if (!pendingStepIsFromLibrary) {
|
|
await stepsApi.create({
|
|
title: pendingStep.title,
|
|
step_type: pendingStep.step_type,
|
|
content: pendingStep.content,
|
|
visibility: 'private',
|
|
tags: pendingStep.tags || []
|
|
})
|
|
}
|
|
await handleUseNow()
|
|
} catch (err) {
|
|
console.error('Failed to save and insert step:', err)
|
|
setIsSavingStep(false)
|
|
}
|
|
}
|
|
|
|
// Handle selecting a descendant node from continuation modal
|
|
const handleSelectDescendant = (nodeId: string) => {
|
|
setShowContinuationModal(false)
|
|
setBranchOriginNodeId(null)
|
|
setPendingContinuationNodeId(nodeId)
|
|
}
|
|
|
|
// Navigate to the previously-selected descendant node
|
|
const handleContinueToDescendant = async () => {
|
|
if (!pendingContinuationNodeId || !session) return
|
|
|
|
const newPath = [...pathTaken, pendingContinuationNodeId]
|
|
setPathTaken(newPath)
|
|
setCurrentNodeId(pendingContinuationNodeId)
|
|
setNotes('')
|
|
setPendingContinuationNodeId(null)
|
|
|
|
try {
|
|
await sessionsApi.update(session.id, { path_taken: newPath })
|
|
} catch (err) {
|
|
console.error('Failed to update session path:', err)
|
|
}
|
|
}
|
|
|
|
// Enter custom branch building mode
|
|
const handleBuildCustomBranch = () => {
|
|
setShowContinuationModal(false)
|
|
setCustomBranchMode(true)
|
|
}
|
|
|
|
// Complete session from custom branch mode
|
|
const handleCustomBranchComplete = async () => {
|
|
if (!session) return
|
|
|
|
setIsCompleting(true)
|
|
setError(null)
|
|
|
|
try {
|
|
const completionDecision: DecisionRecord = {
|
|
node_id: currentNodeId,
|
|
question: null,
|
|
answer: null,
|
|
action_performed: 'Custom Branch Completed',
|
|
notes: notes || 'Issue resolved via custom troubleshooting steps',
|
|
automation_used: false,
|
|
timestamp: new Date().toISOString(),
|
|
attachments: []
|
|
}
|
|
|
|
await sessionsApi.update(session.id, {
|
|
decisions: [...decisions, completionDecision]
|
|
})
|
|
|
|
await sessionsApi.complete(session.id)
|
|
|
|
if (customSteps.length > 0) {
|
|
setShowForkModal(true)
|
|
} else {
|
|
navigate(`/sessions/${session.id}`)
|
|
}
|
|
} catch (err) {
|
|
console.error('Failed to complete session:', err)
|
|
setError('Failed to complete session. Please try again.')
|
|
} finally {
|
|
setIsCompleting(false)
|
|
}
|
|
}
|
|
|
|
// Fork tree with custom branch
|
|
const handleForkTree = async (name: string, description: string) => {
|
|
if (!tree) return
|
|
|
|
try {
|
|
const forkedTree = await treesApi.create({
|
|
name,
|
|
description,
|
|
tree_structure: tree.tree_structure,
|
|
is_public: false
|
|
})
|
|
|
|
navigate(`/trees/${forkedTree.id}/edit`)
|
|
} catch (err) {
|
|
console.error('Failed to fork tree:', err)
|
|
throw err
|
|
}
|
|
}
|
|
|
|
// Skip forking and go to session detail
|
|
const handleSkipFork = () => {
|
|
setShowForkModal(false)
|
|
navigate(`/sessions/${session!.id}`)
|
|
}
|
|
|
|
// Initialize custom steps when resuming a session
|
|
const initCustomSteps = (steps: CustomStep[]) => {
|
|
setCustomSteps(steps)
|
|
}
|
|
|
|
return {
|
|
// State
|
|
customSteps,
|
|
showCustomStepModal,
|
|
showPostStepModal,
|
|
pendingStep,
|
|
pendingStepIsFromLibrary,
|
|
isSavingStep,
|
|
showContinuationModal,
|
|
branchOriginNodeId,
|
|
pendingContinuationNodeId,
|
|
customBranchMode,
|
|
showForkModal,
|
|
|
|
// Derived
|
|
findCustomStep,
|
|
getDescendantNodes,
|
|
|
|
// Actions
|
|
setShowCustomStepModal,
|
|
setShowContinuationModal,
|
|
setShowForkModal,
|
|
initCustomSteps,
|
|
handleNavigateToCustomStep,
|
|
handleStepCreated,
|
|
resetPendingStep,
|
|
handleSaveForLater,
|
|
handleUseNow,
|
|
handleBoth,
|
|
handleSelectDescendant,
|
|
handleContinueToDescendant,
|
|
handleBuildCustomBranch,
|
|
handleCustomBranchComplete,
|
|
handleForkTree,
|
|
handleSkipFork,
|
|
isCompleting,
|
|
}
|
|
}
|