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:
154
frontend/src/components/procedural-editor/IntakeFieldEditor.tsx
Normal file
154
frontend/src/components/procedural-editor/IntakeFieldEditor.tsx
Normal file
@@ -0,0 +1,154 @@
|
||||
import { GripVertical, Trash2, ChevronDown, ChevronUp } from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
import type { IntakeFormField, IntakeFieldType } from '@/types'
|
||||
|
||||
const FIELD_TYPE_OPTIONS: { value: IntakeFieldType; label: string }[] = [
|
||||
{ value: 'text', label: 'Text' },
|
||||
{ value: 'textarea', label: 'Text Area' },
|
||||
{ value: 'number', label: 'Number' },
|
||||
{ value: 'ip_address', label: 'IP Address' },
|
||||
{ value: 'email', label: 'Email' },
|
||||
{ value: 'select', label: 'Select (Dropdown)' },
|
||||
{ value: 'multi_select', label: 'Multi-Select' },
|
||||
{ value: 'checkbox', label: 'Checkbox' },
|
||||
{ value: 'password', label: 'Password' },
|
||||
]
|
||||
|
||||
interface IntakeFieldEditorProps {
|
||||
field: IntakeFormField
|
||||
onUpdate: (updates: Partial<IntakeFormField>) => void
|
||||
onRemove: () => void
|
||||
}
|
||||
|
||||
export function IntakeFieldEditor({ field, onUpdate, onRemove }: IntakeFieldEditorProps) {
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
const needsOptions = field.field_type === 'select' || field.field_type === 'multi_select'
|
||||
|
||||
return (
|
||||
<div className="glass-card rounded-xl p-3">
|
||||
{/* Header row */}
|
||||
<div className="flex items-center gap-2">
|
||||
<GripVertical className="h-4 w-4 shrink-0 cursor-grab text-white/30" />
|
||||
|
||||
<input
|
||||
type="text"
|
||||
value={field.label}
|
||||
onChange={(e) => onUpdate({ label: e.target.value })}
|
||||
placeholder="Field label"
|
||||
className="min-w-0 flex-1 rounded border border-white/10 bg-black/50 px-2 py-1.5 text-sm text-white placeholder:text-white/30 focus:border-white/30 focus:outline-none focus:ring-1 focus:ring-white/20"
|
||||
/>
|
||||
|
||||
<select
|
||||
value={field.field_type}
|
||||
onChange={(e) => onUpdate({ field_type: e.target.value as IntakeFieldType })}
|
||||
className="rounded border border-white/10 bg-black/50 px-2 py-1.5 text-sm text-white focus:border-white/30 focus:outline-none focus:ring-1 focus:ring-white/20"
|
||||
>
|
||||
{FIELD_TYPE_OPTIONS.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>{opt.label}</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<label className="flex items-center gap-1 text-xs text-white/50">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={field.required}
|
||||
onChange={(e) => onUpdate({ required: e.target.checked })}
|
||||
className="rounded border-white/20"
|
||||
/>
|
||||
Req
|
||||
</label>
|
||||
|
||||
<button
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
className="rounded p-1 text-white/40 hover:bg-white/10 hover:text-white"
|
||||
>
|
||||
{expanded ? <ChevronUp className="h-3.5 w-3.5" /> : <ChevronDown className="h-3.5 w-3.5" />}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={onRemove}
|
||||
className="rounded p-1 text-white/40 hover:bg-red-500/20 hover:text-red-400"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Expanded details */}
|
||||
{expanded && (
|
||||
<div className="mt-3 grid grid-cols-2 gap-3 border-t border-white/[0.06] pt-3">
|
||||
<div>
|
||||
<label className="mb-1 block text-xs text-white/50">Variable Name</label>
|
||||
<input
|
||||
type="text"
|
||||
value={field.variable_name}
|
||||
onChange={(e) => onUpdate({ variable_name: e.target.value.toLowerCase().replace(/[^a-z0-9_]/g, '') })}
|
||||
placeholder="e.g. server_name"
|
||||
className="w-full rounded border border-white/10 bg-black/50 px-2 py-1.5 text-sm font-mono text-white placeholder:text-white/30 focus:border-white/30 focus:outline-none focus:ring-1 focus:ring-white/20"
|
||||
/>
|
||||
<p className="mt-0.5 text-[10px] text-white/30">Used as [VAR:{field.variable_name}]</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1 block text-xs text-white/50">Placeholder</label>
|
||||
<input
|
||||
type="text"
|
||||
value={field.placeholder || ''}
|
||||
onChange={(e) => onUpdate({ placeholder: e.target.value || undefined })}
|
||||
placeholder="Hint text"
|
||||
className="w-full rounded border border-white/10 bg-black/50 px-2 py-1.5 text-sm text-white placeholder:text-white/30 focus:border-white/30 focus:outline-none focus:ring-1 focus:ring-white/20"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="col-span-2">
|
||||
<label className="mb-1 block text-xs text-white/50">Help Text</label>
|
||||
<input
|
||||
type="text"
|
||||
value={field.help_text || ''}
|
||||
onChange={(e) => onUpdate({ help_text: e.target.value || undefined })}
|
||||
placeholder="Description or instructions"
|
||||
className="w-full rounded border border-white/10 bg-black/50 px-2 py-1.5 text-sm 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-xs text-white/50">Default Value</label>
|
||||
<input
|
||||
type="text"
|
||||
value={field.default_value || ''}
|
||||
onChange={(e) => onUpdate({ default_value: e.target.value || undefined })}
|
||||
placeholder="Pre-filled value"
|
||||
className="w-full rounded border border-white/10 bg-black/50 px-2 py-1.5 text-sm 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-xs text-white/50">Group Name</label>
|
||||
<input
|
||||
type="text"
|
||||
value={field.group_name || ''}
|
||||
onChange={(e) => onUpdate({ group_name: e.target.value || undefined })}
|
||||
placeholder="e.g. Network Settings"
|
||||
className="w-full rounded border border-white/10 bg-black/50 px-2 py-1.5 text-sm text-white placeholder:text-white/30 focus:border-white/30 focus:outline-none focus:ring-1 focus:ring-white/20"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{needsOptions && (
|
||||
<div className="col-span-2">
|
||||
<label className="mb-1 block text-xs text-white/50">Options (one per line)</label>
|
||||
<textarea
|
||||
value={(field.options || []).join('\n')}
|
||||
onChange={(e) => {
|
||||
const options = e.target.value.split('\n').filter((o) => o.trim())
|
||||
onUpdate({ options: options.length > 0 ? options : undefined })
|
||||
}}
|
||||
placeholder="Option 1 Option 2 Option 3"
|
||||
rows={3}
|
||||
className="w-full rounded border border-white/10 bg-black/50 px-2 py-1.5 text-sm text-white placeholder:text-white/30 focus:border-white/30 focus:outline-none focus:ring-1 focus:ring-white/20"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user