fix: add type-aware routing for procedural flows
Centralizes tree navigation routing via getTreeNavigatePath helper. Fixes all pages to route procedural sessions to /flows/:id/navigate instead of /trees/:id/navigate. Adds safety redirect in troubleshooting navigator and resume support in procedural navigator. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -12,7 +12,7 @@ TreeType = Literal['troubleshooting', 'procedural']
|
|||||||
# --- Intake Form Schemas ---
|
# --- Intake Form Schemas ---
|
||||||
|
|
||||||
FIELD_TYPES = Literal[
|
FIELD_TYPES = Literal[
|
||||||
'text', 'textarea', 'number', 'ip_address', 'email',
|
'text', 'textarea', 'number', 'ip_address', 'email', 'url',
|
||||||
'select', 'multi_select', 'checkbox', 'password'
|
'select', 'multi_select', 'checkbox', 'password'
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
31
frontend/src/lib/routing.ts
Normal file
31
frontend/src/lib/routing.ts
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
/**
|
||||||
|
* Shared routing helpers for tree/session navigation.
|
||||||
|
* Centralizes the logic for determining the correct navigation path
|
||||||
|
* based on tree type (troubleshooting vs procedural).
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the navigation path for starting or resuming a tree/session.
|
||||||
|
*/
|
||||||
|
export function getTreeNavigatePath(
|
||||||
|
treeId: string,
|
||||||
|
treeType?: string
|
||||||
|
): string {
|
||||||
|
if (treeType === 'procedural') {
|
||||||
|
return `/flows/${treeId}/navigate`
|
||||||
|
}
|
||||||
|
return `/trees/${treeId}/navigate`
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the editor path for a tree.
|
||||||
|
*/
|
||||||
|
export function getTreeEditorPath(
|
||||||
|
treeId: string,
|
||||||
|
treeType?: string
|
||||||
|
): string {
|
||||||
|
if (treeType === 'procedural') {
|
||||||
|
return `/flows/${treeId}/edit`
|
||||||
|
}
|
||||||
|
return `/trees/${treeId}/edit`
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useEffect, useState, useRef } from 'react'
|
import { useEffect, useState, useRef } from 'react'
|
||||||
import { useParams, useNavigate } from 'react-router-dom'
|
import { useParams, useNavigate, useLocation } from 'react-router-dom'
|
||||||
import { ChevronLeft, ChevronRight, ListOrdered, Settings2, X } from 'lucide-react'
|
import { ChevronLeft, ChevronRight, ListOrdered, Settings2, X } from 'lucide-react'
|
||||||
import { treesApi } from '@/api/trees'
|
import { treesApi } from '@/api/trees'
|
||||||
import { sessionsApi } from '@/api/sessions'
|
import { sessionsApi } from '@/api/sessions'
|
||||||
@@ -21,6 +21,8 @@ interface StepState {
|
|||||||
export function ProceduralNavigationPage() {
|
export function ProceduralNavigationPage() {
|
||||||
const { id: treeId } = useParams<{ id: string }>()
|
const { id: treeId } = useParams<{ id: string }>()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
|
const location = useLocation()
|
||||||
|
const locationState = location.state as { sessionId?: string } | undefined
|
||||||
|
|
||||||
const [tree, setTree] = useState<Tree | null>(null)
|
const [tree, setTree] = useState<Tree | null>(null)
|
||||||
const [session, setSession] = useState<Session | null>(null)
|
const [session, setSession] = useState<Session | null>(null)
|
||||||
@@ -98,6 +100,12 @@ export function ProceduralNavigationPage() {
|
|||||||
}
|
}
|
||||||
setTree(treeData)
|
setTree(treeData)
|
||||||
|
|
||||||
|
// If resuming an existing session
|
||||||
|
if (locationState?.sessionId) {
|
||||||
|
await resumeSession(treeData, locationState.sessionId)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// Check if intake form exists
|
// Check if intake form exists
|
||||||
if (treeData.intake_form && treeData.intake_form.length > 0) {
|
if (treeData.intake_form && treeData.intake_form.length > 0) {
|
||||||
setShowIntakeForm(true)
|
setShowIntakeForm(true)
|
||||||
@@ -134,6 +142,42 @@ export function ProceduralNavigationPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const resumeSession = async (treeData: Tree, sessionId: string) => {
|
||||||
|
try {
|
||||||
|
const sessionData = await sessionsApi.get(sessionId)
|
||||||
|
setSession(sessionData)
|
||||||
|
setSessionVariables(sessionData.session_variables || {})
|
||||||
|
setShowIntakeForm(false)
|
||||||
|
|
||||||
|
// Initialize step states from session decisions
|
||||||
|
const allSteps = getStepsFromTree(treeData)
|
||||||
|
const initialStates = new Map<string, StepState>()
|
||||||
|
for (const step of allSteps) {
|
||||||
|
initialStates.set(step.id, { notes: '', verificationValue: '', completedAt: null })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hydrate completed steps from decisions
|
||||||
|
for (const decision of sessionData.decisions || []) {
|
||||||
|
if (decision.answer === 'completed' && initialStates.has(decision.node_id)) {
|
||||||
|
initialStates.set(decision.node_id, {
|
||||||
|
notes: decision.notes || '',
|
||||||
|
verificationValue: decision.command_output || '',
|
||||||
|
completedAt: decision.exited_at || decision.timestamp,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setStepStates(initialStates)
|
||||||
|
|
||||||
|
// Set current step to first incomplete step
|
||||||
|
const pSteps = allSteps.filter((s) => s.type === 'procedure_step')
|
||||||
|
const firstIncomplete = pSteps.findIndex((s) => !initialStates.get(s.id)?.completedAt)
|
||||||
|
setCurrentStepIndex(firstIncomplete >= 0 ? firstIncomplete : pSteps.length - 1)
|
||||||
|
} catch {
|
||||||
|
toast.error('Failed to resume session')
|
||||||
|
navigate('/my-trees')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const getStepsFromTree = (t: Tree): ProceduralStep[] => {
|
const getStepsFromTree = (t: Tree): ProceduralStep[] => {
|
||||||
const structure = t.tree_structure as unknown as { steps?: ProceduralStep[] }
|
const structure = t.tree_structure as unknown as { steps?: ProceduralStep[] }
|
||||||
return structure.steps || []
|
return structure.steps || []
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { treesApi } from '@/api/trees'
|
|||||||
import { sessionsApi } from '@/api/sessions'
|
import { sessionsApi } from '@/api/sessions'
|
||||||
import type { TreeListItem } from '@/types'
|
import type { TreeListItem } from '@/types'
|
||||||
import type { Session } from '@/types/session'
|
import type { Session } from '@/types/session'
|
||||||
|
import { getTreeNavigatePath } from '@/lib/routing'
|
||||||
|
|
||||||
|
|
||||||
function timeAgo(dateStr: string): string {
|
function timeAgo(dateStr: string): string {
|
||||||
@@ -27,7 +28,7 @@ export function QuickStartPage() {
|
|||||||
const [isSearching, setIsSearching] = useState(false)
|
const [isSearching, setIsSearching] = useState(false)
|
||||||
const [showResults, setShowResults] = useState(false)
|
const [showResults, setShowResults] = useState(false)
|
||||||
const [activeSessions, setActiveSessions] = useState<Session[]>([])
|
const [activeSessions, setActiveSessions] = useState<Session[]>([])
|
||||||
const [recentTrees, setRecentTrees] = useState<{ tree_id: string; name: string; lastUsed: string }[]>([])
|
const [recentTrees, setRecentTrees] = useState<{ tree_id: string; name: string; lastUsed: string; tree_type?: string }[]>([])
|
||||||
const [isLoading, setIsLoading] = useState(true)
|
const [isLoading, setIsLoading] = useState(true)
|
||||||
const searchRef = useRef<HTMLDivElement>(null)
|
const searchRef = useRef<HTMLDivElement>(null)
|
||||||
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||||
@@ -44,7 +45,7 @@ export function QuickStartPage() {
|
|||||||
|
|
||||||
// Deduplicate recent sessions by tree_id, max 5
|
// Deduplicate recent sessions by tree_id, max 5
|
||||||
const seen = new Set<string>()
|
const seen = new Set<string>()
|
||||||
const deduped: { tree_id: string; name: string; lastUsed: string }[] = []
|
const deduped: { tree_id: string; name: string; lastUsed: string; tree_type?: string }[] = []
|
||||||
for (const s of recent) {
|
for (const s of recent) {
|
||||||
if (!seen.has(s.tree_id) && deduped.length < 5) {
|
if (!seen.has(s.tree_id) && deduped.length < 5) {
|
||||||
seen.add(s.tree_id)
|
seen.add(s.tree_id)
|
||||||
@@ -52,6 +53,7 @@ export function QuickStartPage() {
|
|||||||
tree_id: s.tree_id,
|
tree_id: s.tree_id,
|
||||||
name: s.tree_snapshot?.name || 'Unnamed Tree',
|
name: s.tree_snapshot?.name || 'Unnamed Tree',
|
||||||
lastUsed: s.started_at,
|
lastUsed: s.started_at,
|
||||||
|
tree_type: s.tree_snapshot?.tree_type,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -164,7 +166,7 @@ export function QuickStartPage() {
|
|||||||
{searchResults.map((tree) => (
|
{searchResults.map((tree) => (
|
||||||
<li key={tree.id}>
|
<li key={tree.id}>
|
||||||
<button
|
<button
|
||||||
onClick={() => navigate(`/trees/${tree.id}/navigate`)}
|
onClick={() => navigate(getTreeNavigatePath(tree.id, tree.tree_type))}
|
||||||
className="w-full px-5 py-3.5 text-left transition-all hover:bg-white/[0.06]"
|
className="w-full px-5 py-3.5 text-left transition-all hover:bg-white/[0.06]"
|
||||||
>
|
>
|
||||||
<div className="text-sm font-medium text-white">
|
<div className="text-sm font-medium text-white">
|
||||||
@@ -210,7 +212,7 @@ export function QuickStartPage() {
|
|||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
navigate(`/trees/${activeSessions[0].tree_id}/navigate`, {
|
navigate(getTreeNavigatePath(activeSessions[0].tree_id, activeSessions[0].tree_snapshot?.tree_type), {
|
||||||
state: { sessionId: activeSessions[0].id },
|
state: { sessionId: activeSessions[0].id },
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -234,7 +236,7 @@ export function QuickStartPage() {
|
|||||||
<button
|
<button
|
||||||
key={session.id}
|
key={session.id}
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
navigate(`/trees/${session.tree_id}/navigate`, {
|
navigate(getTreeNavigatePath(session.tree_id, session.tree_snapshot?.tree_type), {
|
||||||
state: { sessionId: session.id },
|
state: { sessionId: session.id },
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -282,7 +284,7 @@ export function QuickStartPage() {
|
|||||||
{recentTrees.map((tree) => (
|
{recentTrees.map((tree) => (
|
||||||
<button
|
<button
|
||||||
key={tree.tree_id}
|
key={tree.tree_id}
|
||||||
onClick={() => navigate(`/trees/${tree.tree_id}/navigate`)}
|
onClick={() => navigate(getTreeNavigatePath(tree.tree_id, tree.tree_type))}
|
||||||
className="glass-card hover:glass-card-hover rounded-2xl p-5 text-left transition-all hover:scale-[1.02] cursor-pointer"
|
className="glass-card hover:glass-card-hover rounded-2xl p-5 text-left transition-all hover:scale-[1.02] cursor-pointer"
|
||||||
>
|
>
|
||||||
<div className="flex items-start justify-between mb-3">
|
<div className="flex items-start justify-between mb-3">
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { SessionFilters } from '@/components/session/SessionFilters'
|
|||||||
import type { SessionFilterState } from '@/components/session/SessionFilters'
|
import type { SessionFilterState } from '@/components/session/SessionFilters'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import { toast } from '@/lib/toast'
|
import { toast } from '@/lib/toast'
|
||||||
|
import { getTreeNavigatePath } from '@/lib/routing'
|
||||||
|
|
||||||
export function SessionHistoryPage() {
|
export function SessionHistoryPage() {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
@@ -284,7 +285,7 @@ export function SessionHistoryPage() {
|
|||||||
</button>
|
</button>
|
||||||
{!session.completed_at && (
|
{!session.completed_at && (
|
||||||
<button
|
<button
|
||||||
onClick={() => navigate(`/trees/${session.tree_id}/navigate`, { state: { sessionId: session.id } })}
|
onClick={() => navigate(getTreeNavigatePath(session.tree_id, session.tree_snapshot?.tree_type), { state: { sessionId: session.id } })}
|
||||||
className={cn(
|
className={cn(
|
||||||
'rounded-md bg-white px-3 py-2 text-sm font-medium text-black',
|
'rounded-md bg-white px-3 py-2 text-sm font-medium text-black',
|
||||||
'hover:bg-white/90'
|
'hover:bg-white/90'
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import { TreeTableView } from '@/components/library/TreeTableView'
|
|||||||
import { ViewToggle } from '@/components/library/ViewToggle'
|
import { ViewToggle } from '@/components/library/ViewToggle'
|
||||||
import { SortDropdown } from '@/components/library/SortDropdown'
|
import { SortDropdown } from '@/components/library/SortDropdown'
|
||||||
import { cn, safeGetItem } from '@/lib/utils'
|
import { cn, safeGetItem } from '@/lib/utils'
|
||||||
|
import { getTreeNavigatePath } from '@/lib/routing'
|
||||||
import { usePermissions } from '@/hooks/usePermissions'
|
import { usePermissions } from '@/hooks/usePermissions'
|
||||||
import { useUserPreferencesStore } from '@/store/userPreferencesStore'
|
import { useUserPreferencesStore } from '@/store/userPreferencesStore'
|
||||||
import { toast } from '@/lib/toast'
|
import { toast } from '@/lib/toast'
|
||||||
@@ -450,7 +451,7 @@ export function TreeLibraryPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<button
|
<button
|
||||||
onClick={() => navigate(`/trees/${s.tree_id}/navigate`, { state: { sessionId: s.id } })}
|
onClick={() => navigate(getTreeNavigatePath(s.tree_id, s.tree_snapshot?.tree_type), { state: { sessionId: s.id } })}
|
||||||
className="flex items-center gap-1.5 rounded-md bg-white px-3 py-1.5 text-sm font-medium text-black hover:bg-white/90"
|
className="flex items-center gap-1.5 rounded-md bg-white px-3 py-1.5 text-sm font-medium text-black hover:bg-white/90"
|
||||||
>
|
>
|
||||||
<Play className="h-3.5 w-3.5" />
|
<Play className="h-3.5 w-3.5" />
|
||||||
|
|||||||
@@ -261,6 +261,16 @@ export function TreeNavigationPage() {
|
|||||||
setError(null)
|
setError(null)
|
||||||
try {
|
try {
|
||||||
const treeData = await treesApi.get(treeId!)
|
const treeData = await treesApi.get(treeId!)
|
||||||
|
|
||||||
|
// Safety redirect: procedural trees should use the procedural navigator
|
||||||
|
if (treeData.tree_type === 'procedural') {
|
||||||
|
navigate(`/flows/${treeId}/navigate`, {
|
||||||
|
replace: true,
|
||||||
|
state: locationState,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
setTree(treeData)
|
setTree(treeData)
|
||||||
|
|
||||||
// If resuming a session
|
// If resuming a session
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ export interface TreeSnapshot extends TreeStructure {
|
|||||||
description?: string
|
description?: string
|
||||||
category?: string
|
category?: string
|
||||||
version?: number
|
version?: number
|
||||||
|
tree_type?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Session {
|
export interface Session {
|
||||||
@@ -58,6 +59,7 @@ export interface Session {
|
|||||||
exported: boolean
|
exported: boolean
|
||||||
scratchpad: string
|
scratchpad: string
|
||||||
next_steps: string
|
next_steps: string
|
||||||
|
session_variables: Record<string, string>
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SessionCreate {
|
export interface SessionCreate {
|
||||||
|
|||||||
Reference in New Issue
Block a user