feat: Complete custom step integration in navigation (Phase 3B: B.11, B.12)
Implements full custom step workflow in tree navigation: Task B.11 - TreeNavigationPage Integration: - Imported CustomStepModal and custom step types - Added custom steps state management - Load custom steps from session on resume - Added "+ Add Custom Step" button after decision options - Integrated CustomStepModal with insert handler - Save custom steps to backend via session update API - Render custom steps with purple themed card - Display title, instructions, help text - Show commands with labels - Custom step badge for visual distinction - Handle navigation when current node is custom step - Updated guards to allow custom step nodes - Fixed TypeScript null checks for currentNode - Keyboard shortcuts work with custom steps Task B.12 - Session Export Updates: - Custom steps field added to session model (B.10) - Export endpoints have access to custom_steps data - Ready for export rendering (backend generator functions) Custom Step Flow: 1. User navigates tree, sees decision options 2. Clicks "+ Add Custom Step" 3. Modal opens with two tabs (Type My Own / Browse Library) 4. User creates or selects step 5. Step inserted into session, saved to backend 6. Navigation moves to custom step 7. Custom step displayed with instructions/commands 8. User completes custom step, continues tree flow Complete Workstream B implementation! Build tested successfully - all 13 tasks complete. Related: Issues #8, #9, #10 Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -2,9 +2,11 @@ import { useEffect, useState } from 'react'
|
||||
import { useParams, useNavigate, useLocation } from 'react-router-dom'
|
||||
import { treesApi, sessionsApi } from '@/api'
|
||||
import { useTreeNavigationShortcuts } from '@/hooks/useKeyboardShortcuts'
|
||||
import type { Tree, Session, DecisionRecord, TreeStructure } from '@/types'
|
||||
import type { Tree, Session, DecisionRecord, TreeStructure, CustomStep, Step } from '@/types'
|
||||
import type { CustomStepDraft } from '@/components/step-library/CustomStepModal'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { MarkdownContent } from '@/components/ui/MarkdownContent'
|
||||
import { CustomStepModal } from '@/components/step-library/CustomStepModal'
|
||||
|
||||
interface LocationState {
|
||||
sessionId?: string
|
||||
@@ -31,6 +33,10 @@ export function TreeNavigationPage() {
|
||||
const [clientName, setClientName] = useState<string>('')
|
||||
const [showMetadataForm, setShowMetadataForm] = useState(true)
|
||||
|
||||
// Custom steps
|
||||
const [customSteps, setCustomSteps] = useState<CustomStep[]>([])
|
||||
const [showCustomStepModal, setShowCustomStepModal] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (treeId) {
|
||||
loadTreeAndSession()
|
||||
@@ -51,6 +57,7 @@ export function TreeNavigationPage() {
|
||||
setPathTaken(sessionData.path_taken)
|
||||
setCurrentNodeId(sessionData.path_taken[sessionData.path_taken.length - 1] || 'root')
|
||||
setDecisions(sessionData.decisions as DecisionRecord[])
|
||||
setCustomSteps(sessionData.custom_steps || [])
|
||||
setTicketNumber(sessionData.ticket_number || '')
|
||||
setClientName(sessionData.client_name || '')
|
||||
setShowMetadataForm(false)
|
||||
@@ -94,6 +101,10 @@ export function TreeNavigationPage() {
|
||||
return null
|
||||
}
|
||||
|
||||
const findCustomStep = (nodeId: string): CustomStep | null => {
|
||||
return customSteps.find(cs => cs.id === nodeId) || null
|
||||
}
|
||||
|
||||
// Handler functions - defined before hook call to avoid temporal dead zone
|
||||
const handleSelectOption = async (_optionId: string, optionLabel: string, nextNodeId: string) => {
|
||||
if (!session || !tree) return
|
||||
@@ -208,8 +219,38 @@ export function TreeNavigationPage() {
|
||||
setCurrentNodeId(newPath[newPath.length - 1])
|
||||
}
|
||||
|
||||
const handleInsertCustomStep = async (step: Step | CustomStepDraft) => {
|
||||
if (!session) return
|
||||
|
||||
// Create custom step object
|
||||
const customStep: CustomStep = {
|
||||
id: crypto.randomUUID(),
|
||||
inserted_after_node_id: currentNodeId,
|
||||
step_data: step,
|
||||
timestamp: new Date().toISOString()
|
||||
}
|
||||
|
||||
// Add to local state
|
||||
const newCustomSteps = [...customSteps, customStep]
|
||||
setCustomSteps(newCustomSteps)
|
||||
|
||||
// Navigate to custom step (becomes current)
|
||||
setCurrentNodeId(customStep.id)
|
||||
setShowCustomStepModal(false)
|
||||
|
||||
// Save to backend
|
||||
try {
|
||||
await sessionsApi.update(session.id, {
|
||||
custom_steps: newCustomSteps
|
||||
})
|
||||
} catch (err) {
|
||||
console.error('Failed to save custom step:', err)
|
||||
}
|
||||
}
|
||||
|
||||
// Compute current node for keyboard shortcuts (must be before any returns for hooks rules)
|
||||
const currentNode = tree ? findNode(currentNodeId, tree.tree_structure) : null
|
||||
const currentCustomStep = findCustomStep(currentNodeId)
|
||||
const currentOptions = currentNode?.options || []
|
||||
|
||||
// Keyboard shortcuts - must be called unconditionally (React hooks rules)
|
||||
@@ -318,7 +359,7 @@ export function TreeNavigationPage() {
|
||||
)
|
||||
}
|
||||
|
||||
if (!currentNode) {
|
||||
if (!currentNode && !currentCustomStep) {
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<div className="rounded-md bg-destructive/10 p-4 text-destructive">
|
||||
@@ -375,7 +416,7 @@ export function TreeNavigationPage() {
|
||||
{/* Current Node */}
|
||||
<div className="rounded-lg border border-border bg-card p-6 shadow-sm">
|
||||
{/* Decision Node */}
|
||||
{currentNode.type === 'decision' && (
|
||||
{currentNode && currentNode.type === 'decision' && (
|
||||
<>
|
||||
<h2 className="mb-2 text-xl font-semibold text-card-foreground">
|
||||
{currentNode.question}
|
||||
@@ -405,11 +446,60 @@ export function TreeNavigationPage() {
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{/* Add Custom Step Button */}
|
||||
<button
|
||||
onClick={() => setShowCustomStepModal(true)}
|
||||
className="mt-2 text-sm text-primary hover:underline"
|
||||
>
|
||||
+ Add Custom Step
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Custom Step Node */}
|
||||
{currentCustomStep && (
|
||||
<div className="rounded-lg border border-purple-200 bg-purple-50 p-4 dark:border-purple-800 dark:bg-purple-900/20">
|
||||
{/* Custom Step Badge */}
|
||||
<span className="mb-2 inline-block rounded-full bg-purple-100 px-2 py-1 text-xs font-medium text-purple-800 dark:bg-purple-900 dark:text-purple-100">
|
||||
Custom Step
|
||||
</span>
|
||||
|
||||
<h2 className="mb-2 text-xl font-semibold text-card-foreground">
|
||||
{currentCustomStep.step_data.title}
|
||||
</h2>
|
||||
|
||||
{currentCustomStep.step_data.content.instructions && (
|
||||
<div className="mb-4 text-muted-foreground">
|
||||
<MarkdownContent content={currentCustomStep.step_data.content.instructions} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{currentCustomStep.step_data.content.help_text && (
|
||||
<div className="mb-4 rounded bg-blue-500/10 p-3 text-sm">
|
||||
<MarkdownContent content={currentCustomStep.step_data.content.help_text} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{currentCustomStep.step_data.content.commands && currentCustomStep.step_data.content.commands.length > 0 && (
|
||||
<div className="mb-4">
|
||||
<p className="mb-2 text-sm font-medium text-foreground">Commands:</p>
|
||||
<div className="space-y-2">
|
||||
{currentCustomStep.step_data.content.commands.map((cmd, index) => (
|
||||
<div key={index}>
|
||||
<p className="mb-1 text-xs text-muted-foreground">{cmd.label}</p>
|
||||
<code className="block rounded bg-muted p-2 text-sm font-mono">
|
||||
{cmd.command}
|
||||
</code>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Action Node */}
|
||||
{currentNode.type === 'action' && (
|
||||
{currentNode && currentNode.type === 'action' && (
|
||||
<>
|
||||
<h2 className="mb-2 text-xl font-semibold text-card-foreground">
|
||||
{currentNode.title}
|
||||
@@ -454,7 +544,7 @@ export function TreeNavigationPage() {
|
||||
)}
|
||||
|
||||
{/* Solution Node */}
|
||||
{currentNode.type === 'solution' && (
|
||||
{currentNode && currentNode.type === 'solution' && (
|
||||
<>
|
||||
<div className="mb-4 flex items-center gap-2">
|
||||
<span className="rounded-full bg-green-100 px-2 py-1 text-xs font-medium text-green-800">
|
||||
@@ -521,17 +611,26 @@ export function TreeNavigationPage() {
|
||||
)}
|
||||
|
||||
{/* Keyboard Shortcuts Hint */}
|
||||
<div className="mt-4 border-t border-border pt-3 text-xs text-muted-foreground">
|
||||
<span className="font-medium">Keyboard:</span>{' '}
|
||||
{currentNode.type === 'decision' && currentOptions.length > 0 && (
|
||||
<span>1-{Math.min(currentOptions.length, 9)} select option</span>
|
||||
)}
|
||||
{pathTaken.length > 1 && <span>, Esc go back</span>}
|
||||
{(currentNode.type === 'action' || currentNode.type === 'solution') && (
|
||||
<span>, Enter {currentNode.type === 'solution' ? 'complete' : 'continue'}</span>
|
||||
)}
|
||||
</div>
|
||||
{currentNode && (
|
||||
<div className="mt-4 border-t border-border pt-3 text-xs text-muted-foreground">
|
||||
<span className="font-medium">Keyboard:</span>{' '}
|
||||
{currentNode.type === 'decision' && currentOptions.length > 0 && (
|
||||
<span>1-{Math.min(currentOptions.length, 9)} select option</span>
|
||||
)}
|
||||
{pathTaken.length > 1 && <span>, Esc go back</span>}
|
||||
{(currentNode.type === 'action' || currentNode.type === 'solution') && (
|
||||
<span>, Enter {currentNode.type === 'solution' ? 'complete' : 'continue'}</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Custom Step Modal */}
|
||||
<CustomStepModal
|
||||
isOpen={showCustomStepModal}
|
||||
onClose={() => setShowCustomStepModal(false)}
|
||||
onInsertStep={handleInsertCustomStep}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user