* fix: tree editor authoring blockers - scroll trap, form density, branching hint - Replace fixed viewport height with flex layout in NodeEditorPanel - Make footer sticky so Save/Cancel always reachable - Compact root node banner to single-line with InfoTip tooltip - Reduce resolution note from callout box to inline text - Add answer-first branching hint below options label Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: broken functionality - auth errors, toast logic, role update, routing, step library - Extract backend error detail in auth store login/register - Fix inverted 4xx toast logic and add 429 rate limit handling - Send account_role field to match backend schema in role update - Use type-aware routing for Repeat Last Session button - Add step library placeholder page and route, remove dot badge Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: navigation correctness - back buttons, exit dialog, dedup nav, redirects - Standardize all procedural back/exit paths to /trees (not /my-trees) - Add exit button with ConfirmDialog to procedural session top bar - Consolidate duplicate account links in sidebar and topbar - Auto-redirect non-owners to personal analytics - Add toast feedback before silent permission redirects in tree editor - Delete orphaned AdminCategoriesPage Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * refactor: shared components, ConfirmDialog migration, pinned flow fixes - Create shared Spinner component with sm/md/lg sizes - Migrate 13 page-level spinners to shared Spinner - Promote EmptyState to shared component, adopt in MyShares and SessionHistory - Replace window.confirm with ConfirmDialog in 3 files - Fix PinnedFlow.tree_type to include maintenance, update emoji display - Verify sidebar unpin handler already correct (no-op) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: visual consistency - toasts, typography, focus rings, container padding - Remove richColors from Sonner toasts, limit stacking to 3 - Add font-heading to all page H1s (7 files) - Add font-label (Outfit) to TagBadges component - Fix focus ring tokens on analytics pages - Replace deprecated glass-stat with design system tokens - Standardize container padding on analytics pages Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: backend alignment - remove drafts toggle, clean dead code, truncation indicator - Remove non-functional drafts toggle and clean TreeFilters type - Fix AccountInvite type to match backend schema - Remove dead API methods: pinnedFlows.pin/reorder, trees.getSharedTree - Remove unused types: SessionListResponse, RatingCreate.is_verified_use - Add session list truncation indicator with size=51 lookahead Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: remove bg-black from PageLoader and RouteError, fix PageLoader height PageLoader used h-screen inside a grid cell, causing it to overflow. Changed to h-full so it fits within the main-content area. Removed bg-black from both PageLoader and RouteError in favor of theme-aware bg-background to prevent black flash during lazy loading. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: guard against Pydantic validation error objects in toast/error messages FastAPI returns `detail` as an array of objects for 422 validation errors, not a string. Passing these objects to toast.error() or rendering them in JSX crashes React with Error #31 ("Objects are not valid as a React child"). Now checks typeof detail === 'string' before using it. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: toast styling, node editor first-click, action node placeholder pattern 1. Toast fixes: Add theme="dark" to Sonner, use !important CSS overrides instead of zero-specificity :where() selectors, suppress noisy 4xx global toasts (pages handle their own errors) 2. Node editor first-click: Add node.type to draft initialization useEffect deps so draft resets when answer stub converts to real type 3. Action node redesign: Remove NodePicker dropdown, auto-create answer placeholder on save (matching decision node pattern). Users click the placeholder on canvas to choose type and fill in details. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: auto-seed test users when release command fails on PR envs The background seeder now creates users directly via DB if login fails, instead of silently aborting. This handles Railway PR environments where the releaseCommand may not execute properly. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: remove categories/tags from sidebar to prevent footer clipping Categories and Tags sections were pushing Feedback, Account, and Collapse off-screen when All Flows expanded its children. These filters already exist on the TreeLibraryPage, so the sidebar duplicates were removed. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
284 lines
10 KiB
TypeScript
284 lines
10 KiB
TypeScript
import { useEffect, useState, useCallback } from 'react'
|
|
import { useParams, useNavigate, useSearchParams } from 'react-router-dom'
|
|
import { Save, ArrowLeft, ListOrdered, Wrench, Settings, FileText, Calendar } from 'lucide-react'
|
|
import { treesApi } from '@/api/trees'
|
|
import { useProceduralEditorStore } from '@/store/proceduralEditorStore'
|
|
import { CollapsibleEditorSection } from '@/components/procedural-editor/CollapsibleEditorSection'
|
|
import { IntakeFormBuilder } from '@/components/procedural-editor/IntakeFormBuilder'
|
|
import { MaintenanceScheduleSection } from '@/components/procedural-editor/MaintenanceScheduleSection'
|
|
import { getScheduleSummary } from '@/components/procedural-editor/scheduleUtils'
|
|
import { StepList } from '@/components/procedural-editor/StepList'
|
|
import { TagInput } from '@/components/common/TagInput'
|
|
import { Spinner } from '@/components/common/Spinner'
|
|
import { toast } from '@/lib/toast'
|
|
import type { TreeType, MaintenanceSchedule, TargetList } from '@/types'
|
|
|
|
type SectionKey = 'details' | 'intake' | 'schedule'
|
|
|
|
export function ProceduralEditorPage() {
|
|
const { id } = useParams<{ id: string }>()
|
|
const [searchParams] = useSearchParams()
|
|
const navigate = useNavigate()
|
|
const isEditMode = !!id
|
|
|
|
const {
|
|
treeId,
|
|
treeType,
|
|
name,
|
|
description,
|
|
tags,
|
|
isPublic,
|
|
intakeForm,
|
|
isDirty,
|
|
isSaving,
|
|
isLoading,
|
|
initNew,
|
|
loadTree,
|
|
reset,
|
|
setName,
|
|
setDescription,
|
|
setTags,
|
|
setIsPublic,
|
|
setIsSaving,
|
|
markSaved,
|
|
getTreeForSave,
|
|
} = useProceduralEditorStore()
|
|
|
|
const isMaintenance = treeType === 'maintenance'
|
|
const flowLabel = isMaintenance ? 'Maintenance Flow' : 'Procedure'
|
|
|
|
// Accordion state: only one section open at a time
|
|
const [expandedSection, setExpandedSection] = useState<SectionKey | null>(
|
|
isEditMode ? null : 'details'
|
|
)
|
|
|
|
// Schedule state for collapsed summary
|
|
const [schedule, setSchedule] = useState<MaintenanceSchedule | null>(null)
|
|
const [scheduleTargetList, setScheduleTargetList] = useState<TargetList | null>(null)
|
|
|
|
const toggleSection = useCallback((key: SectionKey) => {
|
|
setExpandedSection(prev => prev === key ? null : key)
|
|
}, [])
|
|
|
|
const handleScheduleLoaded = useCallback((s: MaintenanceSchedule | null, tl: TargetList | null) => {
|
|
setSchedule(s)
|
|
setScheduleTargetList(tl)
|
|
}, [])
|
|
|
|
// Load tree or init new
|
|
useEffect(() => {
|
|
if (isEditMode && id) {
|
|
loadExistingTree(id)
|
|
} else {
|
|
const urlType = searchParams.get('type')
|
|
initNew((urlType === 'maintenance' ? 'maintenance' : 'procedural') as TreeType)
|
|
// New flows: details expanded, or schedule for new maintenance
|
|
setExpandedSection(urlType === 'maintenance' ? 'schedule' : 'details')
|
|
}
|
|
|
|
return () => { reset() }
|
|
}, [id])
|
|
|
|
const loadExistingTree = async (treeId: string) => {
|
|
try {
|
|
const tree = await treesApi.get(treeId)
|
|
if (tree.tree_type !== 'procedural' && tree.tree_type !== 'maintenance') {
|
|
toast.error('This flow is not a procedural or maintenance flow')
|
|
navigate('/trees')
|
|
return
|
|
}
|
|
loadTree(tree)
|
|
} catch {
|
|
toast.error('Failed to load flow')
|
|
navigate('/trees')
|
|
}
|
|
}
|
|
|
|
const handleSave = async (saveStatus?: 'draft' | 'published') => {
|
|
if (!name.trim()) {
|
|
toast.error(`Please enter a name for the ${flowLabel.toLowerCase()}`)
|
|
return
|
|
}
|
|
|
|
setIsSaving(true)
|
|
try {
|
|
const payload = getTreeForSave()
|
|
if (saveStatus) {
|
|
payload.status = saveStatus
|
|
}
|
|
|
|
if (isEditMode && treeId) {
|
|
await treesApi.update(treeId, payload)
|
|
markSaved()
|
|
toast.success(`${flowLabel} saved`)
|
|
} else {
|
|
const created = await treesApi.create(payload)
|
|
markSaved()
|
|
toast.success(`${flowLabel} created`)
|
|
navigate(`/flows/${created.id}/edit`, { replace: true })
|
|
}
|
|
} catch (err: unknown) {
|
|
const message = err && typeof err === 'object' && 'response' in err
|
|
? (err as { response?: { data?: { detail?: string | { message?: string } } } }).response?.data?.detail
|
|
: null
|
|
const errorText = typeof message === 'string' ? message : typeof message === 'object' && message?.message ? message.message : `Failed to save ${flowLabel.toLowerCase()}`
|
|
toast.error(errorText)
|
|
} finally {
|
|
setIsSaving(false)
|
|
}
|
|
}
|
|
|
|
// Summary strings for collapsed sections
|
|
const detailsSummary = [
|
|
name ? `"${name}"` : '"Untitled"',
|
|
tags.length > 0 ? `${tags.length} tag${tags.length !== 1 ? 's' : ''}` : 'No tags',
|
|
isPublic ? 'Public' : 'Private',
|
|
].join(' \u00b7 ')
|
|
|
|
const scheduleSummary = getScheduleSummary(schedule, scheduleTargetList)
|
|
|
|
const intakeSummary = intakeForm.length === 0
|
|
? 'No fields defined'
|
|
: `${intakeForm.length} field${intakeForm.length !== 1 ? 's' : ''}: ${intakeForm.map(f => f.label || f.variable_name).slice(0, 4).join(', ')}${intakeForm.length > 4 ? ', \u2026' : ''}`
|
|
|
|
if (isLoading) {
|
|
return (
|
|
<div className="flex min-h-[50vh] items-center justify-center">
|
|
<Spinner />
|
|
</div>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<div className="flex h-full flex-col overflow-hidden">
|
|
{/* Toolbar — sticky */}
|
|
<div className="flex shrink-0 items-center justify-between border-b border-border bg-card px-4 py-2">
|
|
<div className="flex items-center gap-3">
|
|
<button
|
|
onClick={() => navigate('/trees')}
|
|
className="rounded-md p-2 text-muted-foreground hover:bg-accent hover:text-foreground"
|
|
>
|
|
<ArrowLeft className="h-5 w-5" />
|
|
</button>
|
|
<div className="flex items-center gap-2">
|
|
{isMaintenance
|
|
? <Wrench className="h-5 w-5 text-amber-400" />
|
|
: <ListOrdered className="h-5 w-5 text-muted-foreground" />}
|
|
<h1 className="text-lg font-bold text-foreground">
|
|
{isEditMode ? `Edit ${flowLabel}` : `New ${flowLabel}`}
|
|
{name && <span className="ml-2 font-normal text-muted-foreground">— {name}</span>}
|
|
</h1>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex items-center gap-2">
|
|
{isDirty && (
|
|
<span className="text-xs text-muted-foreground">Unsaved changes</span>
|
|
)}
|
|
<button
|
|
onClick={() => handleSave('draft')}
|
|
disabled={isSaving}
|
|
className="flex items-center gap-1.5 rounded-md border border-border px-3 py-2 text-sm text-muted-foreground hover:bg-accent hover:text-foreground disabled:opacity-50"
|
|
>
|
|
Save Draft
|
|
</button>
|
|
<button
|
|
onClick={() => handleSave('published')}
|
|
disabled={isSaving}
|
|
className="flex items-center gap-1.5 rounded-md bg-gradient-brand px-4 py-2 text-sm font-medium text-white shadow-lg shadow-primary/20 hover:opacity-90 disabled:opacity-50"
|
|
>
|
|
<Save className="h-4 w-4" />
|
|
{isSaving ? 'Saving...' : 'Publish'}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Collapsible sections */}
|
|
<div className="shrink-0">
|
|
<CollapsibleEditorSection
|
|
title="Details"
|
|
icon={<Settings className="h-4 w-4" />}
|
|
summary={detailsSummary}
|
|
expanded={expandedSection === 'details'}
|
|
onToggle={() => toggleSection('details')}
|
|
>
|
|
<div className="space-y-4">
|
|
<div>
|
|
<label className="mb-1 block text-sm font-medium text-muted-foreground">Name</label>
|
|
<input
|
|
type="text"
|
|
value={name}
|
|
onChange={(e) => setName(e.target.value)}
|
|
placeholder="e.g. Domain Controller Build"
|
|
className="w-full rounded-lg border border-border bg-card px-3 py-2 text-foreground placeholder:text-muted-foreground focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary/20"
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="mb-1 block text-sm font-medium text-muted-foreground">Description</label>
|
|
<textarea
|
|
value={description}
|
|
onChange={(e) => setDescription(e.target.value)}
|
|
placeholder="Brief description of this procedure..."
|
|
rows={2}
|
|
className="w-full rounded-lg border border-border bg-card px-3 py-2 text-foreground placeholder:text-muted-foreground focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary/20"
|
|
/>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<div>
|
|
<label className="mb-1 block text-sm font-medium text-muted-foreground">Tags</label>
|
|
<TagInput tags={tags} onChange={setTags} />
|
|
</div>
|
|
|
|
<div className="flex items-end pb-1">
|
|
<label className="flex items-center gap-2 text-sm text-muted-foreground">
|
|
<input
|
|
type="checkbox"
|
|
checked={isPublic}
|
|
onChange={(e) => setIsPublic(e.target.checked)}
|
|
className="rounded border-border"
|
|
/>
|
|
Public (visible to all users)
|
|
</label>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</CollapsibleEditorSection>
|
|
|
|
<CollapsibleEditorSection
|
|
title="Intake Form"
|
|
icon={<FileText className="h-4 w-4" />}
|
|
summary={intakeSummary}
|
|
expanded={expandedSection === 'intake'}
|
|
onToggle={() => toggleSection('intake')}
|
|
>
|
|
<IntakeFormBuilder />
|
|
</CollapsibleEditorSection>
|
|
|
|
{isMaintenance && (
|
|
<CollapsibleEditorSection
|
|
title="Schedule"
|
|
icon={<Calendar className="h-4 w-4" />}
|
|
summary={scheduleSummary}
|
|
expanded={expandedSection === 'schedule'}
|
|
onToggle={() => toggleSection('schedule')}
|
|
>
|
|
<MaintenanceScheduleSection
|
|
treeId={treeId}
|
|
onScheduleLoaded={handleScheduleLoaded}
|
|
/>
|
|
</CollapsibleEditorSection>
|
|
)}
|
|
</div>
|
|
|
|
{/* Step List — flex-1, scrolls independently */}
|
|
<div className="min-h-0 flex-1 overflow-y-auto px-4 py-4">
|
|
<StepList />
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
export default ProceduralEditorPage
|