Files
resolutionflow/frontend/src/pages/ProceduralEditorPage.tsx
chihlasm c309a0ba84 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>
2026-02-19 14:32:01 -05:00

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