Files
resolutionflow/frontend/src/pages/ProceduralEditorPage.tsx
Michael Chihlas 505b1c8246 fix: allow section_header step type in validation, improve tag input
- Add 'section_header' to VALID_STEP_TYPES in backend validation so
  procedural flows with section headers can be published
- Replace procedural editor's inline tag input with TagInput component
  (supports autocomplete, Tab, comma, semicolon, and paste splitting)
- Add semicolon delimiter support to TagInput component

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-14 21:08:59 -05:00

206 lines
6.7 KiB
TypeScript

import { useEffect } from 'react'
import { useParams, useNavigate } from 'react-router-dom'
import { Save, ArrowLeft, ListOrdered } from 'lucide-react'
import { treesApi } from '@/api/trees'
import { useProceduralEditorStore } from '@/store/proceduralEditorStore'
import { IntakeFormBuilder } from '@/components/procedural-editor/IntakeFormBuilder'
import { StepList } from '@/components/procedural-editor/StepList'
import { TagInput } from '@/components/common/TagInput'
import { toast } from '@/lib/toast'
export function ProceduralEditorPage() {
const { id } = useParams<{ id: string }>()
const navigate = useNavigate()
const isEditMode = !!id
const {
treeId,
name,
description,
tags,
isPublic,
isDirty,
isSaving,
isLoading,
initNew,
loadTree,
reset,
setName,
setDescription,
setTags,
setIsPublic,
setIsSaving,
markSaved,
getTreeForSave,
} = useProceduralEditorStore()
// Load tree or init new
useEffect(() => {
if (isEditMode && id) {
loadExistingTree(id)
} else {
initNew()
}
return () => { reset() }
}, [id])
const loadExistingTree = async (treeId: string) => {
try {
const tree = await treesApi.get(treeId)
if (tree.tree_type !== 'procedural') {
toast.error('This tree is not a procedural flow')
navigate('/my-trees')
return
}
loadTree(tree)
} catch {
toast.error('Failed to load procedure')
navigate('/my-trees')
}
}
const handleSave = async (saveStatus?: 'draft' | 'published') => {
if (!name.trim()) {
toast.error('Please enter a name for the procedure')
return
}
setIsSaving(true)
try {
const payload = getTreeForSave()
if (saveStatus) {
payload.status = saveStatus
}
if (isEditMode && treeId) {
await treesApi.update(treeId, payload)
markSaved()
toast.success('Procedure saved')
} else {
const created = await treesApi.create(payload)
markSaved()
toast.success('Procedure 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 procedure'
toast.error(errorText)
} finally {
setIsSaving(false)
}
}
if (isLoading) {
return (
<div className="flex min-h-[50vh] items-center justify-center">
<div className="h-8 w-8 animate-spin rounded-full border-4 border-white/20 border-t-white" />
</div>
)
}
return (
<div className="container mx-auto px-4 py-6 sm:px-6 sm:py-8">
{/* Header */}
<div className="mb-6 flex items-center justify-between sm:mb-8">
<div className="flex items-center gap-3">
<button
onClick={() => navigate('/my-trees')}
className="rounded-md p-2 text-white/40 hover:bg-white/10 hover:text-white"
>
<ArrowLeft className="h-5 w-5" />
</button>
<div className="flex items-center gap-2">
<ListOrdered className="h-5 w-5 text-white/50" />
<h1 className="text-xl font-bold text-white sm:text-2xl">
{isEditMode ? 'Edit Procedure' : 'New Procedure'}
</h1>
</div>
</div>
<div className="flex items-center gap-2">
{isDirty && (
<span className="text-xs text-white/40">Unsaved changes</span>
)}
<button
onClick={() => handleSave('draft')}
disabled={isSaving}
className="flex items-center gap-1.5 rounded-md border border-white/10 px-3 py-2 text-sm text-white/60 hover:bg-white/10 hover:text-white disabled:opacity-50"
>
Save Draft
</button>
<button
onClick={() => handleSave('published')}
disabled={isSaving}
className="flex items-center gap-1.5 rounded-md bg-white px-4 py-2 text-sm font-medium text-black hover:bg-white/90 disabled:opacity-50"
>
<Save className="h-4 w-4" />
{isSaving ? 'Saving...' : 'Publish'}
</button>
</div>
</div>
{/* Content */}
<div className="space-y-6">
{/* Metadata */}
<div className="glass-card rounded-2xl p-4 sm:p-6">
<h2 className="mb-4 text-lg font-semibold text-white">Details</h2>
<div className="space-y-4">
<div>
<label className="mb-1 block text-sm font-medium text-white/60">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-white/10 bg-black/50 px-3 py-2 text-white placeholder:text-white/30 focus:border-white/30 focus:outline-none focus:ring-1 focus:ring-white/20"
/>
</div>
<div>
<label className="mb-1 block text-sm font-medium text-white/60">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-white/10 bg-black/50 px-3 py-2 text-white placeholder:text-white/30 focus:border-white/30 focus:outline-none focus:ring-1 focus:ring-white/20"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="mb-1 block text-sm font-medium text-white/60">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-white/60">
<input
type="checkbox"
checked={isPublic}
onChange={(e) => setIsPublic(e.target.checked)}
className="rounded border-white/20"
/>
Public (visible to all users)
</label>
</div>
</div>
</div>
</div>
{/* Intake Form Builder */}
<IntakeFormBuilder />
{/* Step List */}
<StepList />
</div>
</div>
)
}
export default ProceduralEditorPage