feat: add procedural flows with intake forms, navigation, and seed templates
Adds a new "procedural" tree type for linear step-by-step project workflows (domain controller setup, M365 onboarding, VPN config, etc). Includes intake form builder, two-panel step navigation, variable resolution, procedural exports, 3 seed templates, and UI rename from "Trees" to "Flows". Also archives 19 implemented plan docs and creates deferred features backlog. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
245
frontend/src/pages/ProceduralEditorPage.tsx
Normal file
245
frontend/src/pages/ProceduralEditorPage.tsx
Normal file
@@ -0,0 +1,245 @@
|
||||
import { useEffect, useState } 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 { 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()
|
||||
|
||||
const [tagInput, setTagInput] = useState('')
|
||||
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
|
||||
const handleAddTag = () => {
|
||||
const tag = tagInput.trim()
|
||||
if (tag && !tags.includes(tag)) {
|
||||
setTags([...tags, tag])
|
||||
setTagInput('')
|
||||
}
|
||||
}
|
||||
|
||||
const handleRemoveTag = (tag: string) => {
|
||||
setTags(tags.filter((t) => t !== tag))
|
||||
}
|
||||
|
||||
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>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={tagInput}
|
||||
onChange={(e) => setTagInput(e.target.value)}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); handleAddTag() } }}
|
||||
placeholder="Add tag..."
|
||||
className="flex-1 rounded-lg border border-white/10 bg-black/50 px-3 py-2 text-sm text-white placeholder:text-white/30 focus:border-white/30 focus:outline-none focus:ring-1 focus:ring-white/20"
|
||||
/>
|
||||
</div>
|
||||
{tags.length > 0 && (
|
||||
<div className="mt-2 flex flex-wrap gap-1">
|
||||
{tags.map((tag) => (
|
||||
<span
|
||||
key={tag}
|
||||
className="inline-flex items-center gap-1 rounded-full bg-white/10 px-2 py-0.5 text-xs text-white/70"
|
||||
>
|
||||
{tag}
|
||||
<button
|
||||
onClick={() => handleRemoveTag(tag)}
|
||||
className="text-white/40 hover:text-white"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</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
|
||||
Reference in New Issue
Block a user