feat: procedural editor UX improvements

Add URL intake field type, fix variable name editing collapsing fields
(index-based keys/updates), auto-generate variable names by field type,
add section header as first-class step type, and simplify step editor
with "More Options" collapsible for advanced fields.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Michael Chihlas
2026-02-14 20:48:41 -05:00
parent 6bc8554202
commit db5d8a81c1
7 changed files with 306 additions and 173 deletions

View File

@@ -8,6 +8,7 @@ const FIELD_TYPE_OPTIONS: { value: IntakeFieldType; label: string }[] = [
{ value: 'number', label: 'Number' }, { value: 'number', label: 'Number' },
{ value: 'ip_address', label: 'IP Address' }, { value: 'ip_address', label: 'IP Address' },
{ value: 'email', label: 'Email' }, { value: 'email', label: 'Email' },
{ value: 'url', label: 'URL' },
{ value: 'select', label: 'Select (Dropdown)' }, { value: 'select', label: 'Select (Dropdown)' },
{ value: 'multi_select', label: 'Multi-Select' }, { value: 'multi_select', label: 'Multi-Select' },
{ value: 'checkbox', label: 'Checkbox' }, { value: 'checkbox', label: 'Checkbox' },
@@ -16,11 +17,12 @@ const FIELD_TYPE_OPTIONS: { value: IntakeFieldType; label: string }[] = [
interface IntakeFieldEditorProps { interface IntakeFieldEditorProps {
field: IntakeFormField field: IntakeFormField
index: number
onUpdate: (updates: Partial<IntakeFormField>) => void onUpdate: (updates: Partial<IntakeFormField>) => void
onRemove: () => void onRemove: () => void
} }
export function IntakeFieldEditor({ field, onUpdate, onRemove }: IntakeFieldEditorProps) { export function IntakeFieldEditor({ field, index: _index, onUpdate, onRemove }: IntakeFieldEditorProps) {
const [expanded, setExpanded] = useState(false) const [expanded, setExpanded] = useState(false)
const needsOptions = field.field_type === 'select' || field.field_type === 'multi_select' const needsOptions = field.field_type === 'select' || field.field_type === 'multi_select'

View File

@@ -36,10 +36,11 @@ export function IntakeFormBuilder() {
<div className="space-y-2"> <div className="space-y-2">
{intakeForm.map((field, index) => ( {intakeForm.map((field, index) => (
<IntakeFieldEditor <IntakeFieldEditor
key={field.variable_name + '-' + index} key={`field-${index}-${field.display_order}`}
field={field} field={field}
onUpdate={(updates) => updateField(field.variable_name, updates)} index={index}
onRemove={() => removeField(field.variable_name)} onUpdate={(updates) => updateField(index, updates)}
onRemove={() => removeField(index)}
/> />
))} ))}
</div> </div>

View File

@@ -1,4 +1,5 @@
import { ChevronUp, AlertTriangle, Clock, ExternalLink, CheckSquare, Terminal, Type } from 'lucide-react' import { useState } from 'react'
import { ChevronUp, ChevronDown, AlertTriangle, Clock, ExternalLink, CheckSquare, Terminal, Settings2 } from 'lucide-react'
import type { ProceduralStep, StepContentType, IntakeFormField } from '@/types' import type { ProceduralStep, StepContentType, IntakeFormField } from '@/types'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
@@ -18,6 +19,35 @@ interface StepEditorProps {
} }
export function StepEditor({ step, stepNumber, onUpdate, onCollapse, availableVariables }: StepEditorProps) { export function StepEditor({ step, stepNumber, onUpdate, onCollapse, availableVariables }: StepEditorProps) {
const [showMore, setShowMore] = useState(false)
// Section header steps get a minimal editor
if (step.type === 'section_header') {
return (
<div className="glass-card rounded-xl border border-white/10 p-4">
<div className="mb-4 flex items-center justify-between">
<span className="text-sm font-medium text-white/50">Edit Section Header</span>
<button
onClick={onCollapse}
className="rounded p-1 text-white/40 hover:bg-white/10 hover:text-white"
>
<ChevronUp className="h-4 w-4" />
</button>
</div>
<div>
<label className="mb-1 block text-xs font-medium text-white/50">Title</label>
<input
type="text"
value={step.title}
onChange={(e) => onUpdate({ title: e.target.value })}
placeholder="Section title"
className="w-full rounded 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>
</div>
)
}
return ( return (
<div className="glass-card rounded-xl border border-white/10 p-4"> <div className="glass-card rounded-xl border border-white/10 p-4">
{/* Header */} {/* Header */}
@@ -48,55 +78,18 @@ export function StepEditor({ step, stepNumber, onUpdate, onCollapse, availableVa
/> />
</div> </div>
{/* Content type + Section header row */} {/* Est. Minutes */}
<div className="grid grid-cols-2 gap-3"> <div className="w-40">
<div>
<label className="mb-1 block text-xs font-medium text-white/50">Content Type</label>
<div className="flex gap-1">
{CONTENT_TYPE_OPTIONS.map((opt) => (
<button
key={opt.value}
onClick={() => onUpdate({ content_type: opt.value })}
className={cn(
'rounded px-2 py-1 text-xs font-medium transition-colors',
step.content_type === opt.value
? 'bg-white/15 ' + opt.color
: 'text-white/40 hover:bg-white/10 hover:text-white/60'
)}
>
{opt.label}
</button>
))}
</div>
</div>
<div>
<label className="mb-1 flex items-center gap-1 text-xs font-medium text-white/50">
<Clock className="h-3 w-3" />
Est. Minutes
</label>
<input
type="number"
value={step.estimated_minutes || ''}
onChange={(e) => onUpdate({ estimated_minutes: e.target.value ? parseInt(e.target.value) : undefined })}
placeholder="—"
min={1}
className="w-full rounded 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>
</div>
{/* Section Header */}
<div>
<label className="mb-1 flex items-center gap-1 text-xs font-medium text-white/50"> <label className="mb-1 flex items-center gap-1 text-xs font-medium text-white/50">
<Type className="h-3 w-3" /> <Clock className="h-3 w-3" />
Section Header (optional) Est. Minutes
</label> </label>
<input <input
type="text" type="number"
value={step.section_header || ''} value={step.estimated_minutes || ''}
onChange={(e) => onUpdate({ section_header: e.target.value || undefined })} onChange={(e) => onUpdate({ estimated_minutes: e.target.value ? parseInt(e.target.value) : undefined })}
placeholder="e.g. Phase 2: AD Configuration" placeholder=""
min={1}
className="w-full rounded 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" className="w-full rounded 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> </div>
@@ -127,23 +120,6 @@ export function StepEditor({ step, stepNumber, onUpdate, onCollapse, availableVa
)} )}
</div> </div>
{/* Warning text */}
{(step.content_type === 'warning' || step.warning_text) && (
<div>
<label className="mb-1 flex items-center gap-1 text-xs font-medium text-yellow-400/70">
<AlertTriangle className="h-3 w-3" />
Warning Text
</label>
<textarea
value={step.warning_text || ''}
onChange={(e) => onUpdate({ warning_text: e.target.value || undefined })}
placeholder="Caution: This will restart the service..."
rows={2}
className="w-full rounded border border-yellow-400/20 bg-yellow-400/5 px-3 py-2 text-sm text-white placeholder:text-white/30 focus:border-yellow-400/30 focus:outline-none focus:ring-1 focus:ring-yellow-400/20"
/>
</div>
)}
{/* Commands */} {/* Commands */}
<div> <div>
<label className="mb-1 flex items-center gap-1 text-xs font-medium text-white/50"> <label className="mb-1 flex items-center gap-1 text-xs font-medium text-white/50">
@@ -159,74 +135,127 @@ export function StepEditor({ step, stepNumber, onUpdate, onCollapse, availableVa
/> />
</div> </div>
{/* Expected Outcome */} {/* More Options toggle */}
<div> <button
<label className="mb-1 block text-xs font-medium text-white/50">Expected Outcome (optional)</label> type="button"
<input onClick={() => setShowMore(!showMore)}
type="text" className="flex items-center gap-1.5 text-xs text-white/40 hover:text-white/60"
value={step.expected_outcome || ''} >
onChange={(e) => onUpdate({ expected_outcome: e.target.value || undefined })} <Settings2 className="h-3 w-3" />
placeholder="Server should respond with..." More Options
className="w-full rounded 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" {showMore ? <ChevronUp className="h-3 w-3" /> : <ChevronDown className="h-3 w-3" />}
/> </button>
</div>
{/* Verification */} {showMore && (
<div className="grid grid-cols-2 gap-3"> <div className="space-y-4 border-t border-white/[0.06] pt-4">
<div> {/* Content Type */}
<label className="mb-1 flex items-center gap-1 text-xs font-medium text-white/50"> <div>
<CheckSquare className="h-3 w-3" /> <label className="mb-1 block text-xs font-medium text-white/50">Content Type</label>
Verification Prompt (optional) <div className="flex gap-1">
</label> {CONTENT_TYPE_OPTIONS.map((opt) => (
<input <button
type="text" key={opt.value}
value={step.verification_prompt || ''} onClick={() => onUpdate({ content_type: opt.value })}
onChange={(e) => onUpdate({ verification_prompt: e.target.value || undefined })} className={cn(
placeholder="Confirm the role was installed" 'rounded px-2 py-1 text-xs font-medium transition-colors',
className="w-full rounded 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" step.content_type === opt.value
/> ? 'bg-white/15 ' + opt.color
</div> : 'text-white/40 hover:bg-white/10 hover:text-white/60'
<div> )}
<label className="mb-1 block text-xs font-medium text-white/50">Verification Type</label> >
<select {opt.label}
value={step.verification_type || ''} </button>
onChange={(e) => onUpdate({ verification_type: e.target.value as 'checkbox' | 'text_input' || undefined })} ))}
className="w-full rounded border border-white/10 bg-black/50 px-3 py-2 text-sm text-white focus:border-white/30 focus:outline-none focus:ring-1 focus:ring-white/20" </div>
> </div>
<option value="">None</option>
<option value="checkbox">Checkbox (confirm done)</option>
<option value="text_input">Text input (enter value)</option>
</select>
</div>
</div>
{/* Reference URL + Notes toggle */} {/* Warning text */}
<div className="grid grid-cols-2 gap-3"> {(step.content_type === 'warning' || step.warning_text) && (
<div> <div>
<label className="mb-1 flex items-center gap-1 text-xs font-medium text-white/50"> <label className="mb-1 flex items-center gap-1 text-xs font-medium text-yellow-400/70">
<ExternalLink className="h-3 w-3" /> <AlertTriangle className="h-3 w-3" />
Reference URL (optional) Warning Text
</label> </label>
<input <textarea
type="url" value={step.warning_text || ''}
value={step.reference_url || ''} onChange={(e) => onUpdate({ warning_text: e.target.value || undefined })}
onChange={(e) => onUpdate({ reference_url: e.target.value || undefined })} placeholder="Caution: This will restart the service..."
placeholder="https://learn.microsoft.com/..." rows={2}
className="w-full rounded 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" className="w-full rounded border border-yellow-400/20 bg-yellow-400/5 px-3 py-2 text-sm text-white placeholder:text-white/30 focus:border-yellow-400/30 focus:outline-none focus:ring-1 focus:ring-yellow-400/20"
/> />
</div> </div>
<div className="flex items-end pb-1"> )}
<label className="flex items-center gap-2 text-sm text-white/60">
{/* Expected Outcome */}
<div>
<label className="mb-1 block text-xs font-medium text-white/50">Expected Outcome (optional)</label>
<input <input
type="checkbox" type="text"
checked={step.notes_enabled !== false} value={step.expected_outcome || ''}
onChange={(e) => onUpdate({ notes_enabled: e.target.checked })} onChange={(e) => onUpdate({ expected_outcome: e.target.value || undefined })}
className="rounded border-white/20" placeholder="Server should respond with..."
className="w-full rounded 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"
/> />
Allow tech notes </div>
</label>
{/* Verification */}
<div className="grid grid-cols-2 gap-3">
<div>
<label className="mb-1 flex items-center gap-1 text-xs font-medium text-white/50">
<CheckSquare className="h-3 w-3" />
Verification Prompt (optional)
</label>
<input
type="text"
value={step.verification_prompt || ''}
onChange={(e) => onUpdate({ verification_prompt: e.target.value || undefined })}
placeholder="Confirm the role was installed"
className="w-full rounded 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>
<div>
<label className="mb-1 block text-xs font-medium text-white/50">Verification Type</label>
<select
value={step.verification_type || ''}
onChange={(e) => onUpdate({ verification_type: e.target.value as 'checkbox' | 'text_input' || undefined })}
className="w-full rounded border border-white/10 bg-black/50 px-3 py-2 text-sm text-white focus:border-white/30 focus:outline-none focus:ring-1 focus:ring-white/20"
>
<option value="">None</option>
<option value="checkbox">Checkbox (confirm done)</option>
<option value="text_input">Text input (enter value)</option>
</select>
</div>
</div>
{/* Reference URL + Notes toggle */}
<div className="grid grid-cols-2 gap-3">
<div>
<label className="mb-1 flex items-center gap-1 text-xs font-medium text-white/50">
<ExternalLink className="h-3 w-3" />
Reference URL (optional)
</label>
<input
type="url"
value={step.reference_url || ''}
onChange={(e) => onUpdate({ reference_url: e.target.value || undefined })}
placeholder="https://learn.microsoft.com/..."
className="w-full rounded 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>
<div className="flex items-end pb-1">
<label className="flex items-center gap-2 text-sm text-white/60">
<input
type="checkbox"
checked={step.notes_enabled !== false}
onChange={(e) => onUpdate({ notes_enabled: e.target.checked })}
className="rounded border-white/20"
/>
Allow tech notes
</label>
</div>
</div>
</div> </div>
</div> )}
</div> </div>
</div> </div>
) )

View File

@@ -1,4 +1,4 @@
import { Plus, GripVertical, Trash2, ChevronDown, CheckCircle2, AlertTriangle, Info, Zap, Shield } from 'lucide-react' import { Plus, GripVertical, Trash2, ChevronDown, CheckCircle2, AlertTriangle, Info, Zap, Shield, SeparatorHorizontal } from 'lucide-react'
import type { StepContentType } from '@/types' import type { StepContentType } from '@/types'
import { StepEditor } from './StepEditor' import { StepEditor } from './StepEditor'
import { useProceduralEditorStore } from '@/store/proceduralEditorStore' import { useProceduralEditorStore } from '@/store/proceduralEditorStore'
@@ -18,11 +18,13 @@ export function StepList() {
expandedStepId, expandedStepId,
setExpandedStepId, setExpandedStepId,
addStep, addStep,
addSectionHeader,
removeStep, removeStep,
updateStep, updateStep,
} = useProceduralEditorStore() } = useProceduralEditorStore()
const procedureSteps = steps.filter((s) => s.type === 'procedure_step') const procedureSteps = steps.filter((s) => s.type === 'procedure_step')
let stepCounter = 0
return ( return (
<div className="glass-card rounded-2xl p-4 sm:p-6"> <div className="glass-card rounded-2xl p-4 sm:p-6">
@@ -34,19 +36,27 @@ export function StepList() {
({procedureSteps.length} step{procedureSteps.length !== 1 ? 's' : ''}) ({procedureSteps.length} step{procedureSteps.length !== 1 ? 's' : ''})
</span> </span>
</div> </div>
<button <div className="flex items-center gap-2">
onClick={() => addStep()} <button
className="flex items-center gap-1.5 rounded-md border border-white/10 px-3 py-1.5 text-sm text-white/60 hover:bg-white/10 hover:text-white" onClick={() => addSectionHeader()}
> className="flex items-center gap-1.5 rounded-md border border-white/10 px-3 py-1.5 text-sm text-white/60 hover:bg-white/10 hover:text-white"
<Plus className="h-3.5 w-3.5" /> >
Add Step <SeparatorHorizontal className="h-3.5 w-3.5" />
</button> Add Section
</button>
<button
onClick={() => addStep()}
className="flex items-center gap-1.5 rounded-md border border-white/10 px-3 py-1.5 text-sm text-white/60 hover:bg-white/10 hover:text-white"
>
<Plus className="h-3.5 w-3.5" />
Add Step
</button>
</div>
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
{steps.map((step, index) => { {steps.map((step) => {
if (step.type === 'procedure_end') { if (step.type === 'procedure_end') {
// Render end step as a simple footer
return ( return (
<div <div
key={step.id} key={step.id}
@@ -65,20 +75,57 @@ export function StepList() {
) )
} }
// Section header rendering
if (step.type === 'section_header') {
const isExpanded = expandedStepId === step.id
if (isExpanded) {
return (
<div key={step.id}>
<StepEditor
step={step}
stepNumber={0}
onUpdate={(updates) => updateStep(step.id, updates)}
onCollapse={() => setExpandedStepId(null)}
availableVariables={intakeForm}
/>
</div>
)
}
return (
<div
key={step.id}
className="group flex items-center gap-2 border-b border-white/[0.06] pb-1 pt-3"
>
<GripVertical className="h-4 w-4 shrink-0 cursor-grab text-white/20 group-hover:text-white/40" />
<span
className="min-w-0 flex-1 cursor-pointer text-xs font-semibold uppercase tracking-wider text-white/40 hover:text-white/60"
onClick={() => setExpandedStepId(step.id)}
>
{step.title || 'Untitled Section'}
</span>
<button
onClick={() => removeStep(step.id)}
className="shrink-0 rounded p-1 text-white/30 opacity-0 hover:bg-red-500/20 hover:text-red-400 group-hover:opacity-100"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</div>
)
}
// Regular procedure step
stepCounter++
const stepNumber = stepCounter
const isExpanded = expandedStepId === step.id const isExpanded = expandedStepId === step.id
const contentType = step.content_type || 'action' const contentType = step.content_type || 'action'
const config = contentTypeConfig[contentType] const config = contentTypeConfig[contentType]
const Icon = config.icon const Icon = config.icon
const stepNumber = index + 1
if (isExpanded) { if (isExpanded) {
return ( return (
<div key={step.id}> <div key={step.id}>
{step.section_header && (
<div className="mb-2 mt-4 border-b border-white/[0.06] pb-1 text-xs font-semibold uppercase tracking-wider text-white/40">
{step.section_header}
</div>
)}
<StepEditor <StepEditor
step={step} step={step}
stepNumber={stepNumber} stepNumber={stepNumber}
@@ -92,11 +139,6 @@ export function StepList() {
return ( return (
<div key={step.id}> <div key={step.id}>
{step.section_header && (
<div className="mb-2 mt-4 border-b border-white/[0.06] pb-1 text-xs font-semibold uppercase tracking-wider text-white/40">
{step.section_header}
</div>
)}
<div <div
className={cn( className={cn(
'group flex items-center gap-2 rounded-xl border border-white/[0.06] px-3 py-2.5 transition-colors', 'group flex items-center gap-2 rounded-xl border border-white/[0.06] px-3 py-2.5 transition-colors',

View File

@@ -171,6 +171,18 @@ export function IntakeFormModal({ isOpen, fields, treeName, onSubmit, onCancel }
) )
break break
case 'url':
input = (
<input
type="url"
value={value}
onChange={(e) => setValue(field.variable_name, e.target.value)}
placeholder={field.placeholder || 'https://...'}
className={baseInputClass}
/>
)
break
default: // text, ip_address, email default: // text, ip_address, email
input = ( input = (
<input <input

View File

@@ -1,10 +1,29 @@
import { create } from 'zustand' import { create } from 'zustand'
import { temporal } from 'zundo' import { temporal } from 'zundo'
import { immer } from 'zustand/middleware/immer' import { immer } from 'zustand/middleware/immer'
import type { Tree, IntakeFormField, ProceduralStep, ProceduralTreeStructure, TreeType } from '@/types' import type { Tree, IntakeFormField, IntakeFieldType, ProceduralStep, ProceduralTreeStructure, TreeType } from '@/types'
const generateId = () => crypto.randomUUID() const generateId = () => crypto.randomUUID()
const FIELD_TYPE_PREFIX: Record<IntakeFieldType, string> = {
text: 'text',
textarea: 'textarea',
number: 'number',
ip_address: 'ip',
email: 'email',
url: 'url',
select: 'select',
multi_select: 'multiselect',
checkbox: 'checkbox',
password: 'password',
}
function generateVariableName(fieldType: IntakeFieldType, existingFields: IntakeFormField[]): string {
const prefix = FIELD_TYPE_PREFIX[fieldType] || 'field'
const count = existingFields.filter((f) => f.field_type === fieldType).length
return `${prefix}_${count + 1}`
}
function createDefaultStep(index: number): ProceduralStep { function createDefaultStep(index: number): ProceduralStep {
return { return {
id: generateId(), id: generateId(),
@@ -24,9 +43,17 @@ function createEndStep(): ProceduralStep {
} }
} }
function createDefaultField(index: number): IntakeFormField { function createSectionHeader(title: string): ProceduralStep {
return { return {
variable_name: `field_${index + 1}`, id: generateId(),
type: 'section_header',
title,
}
}
function createDefaultField(index: number, existingFields: IntakeFormField[]): IntakeFormField {
return {
variable_name: generateVariableName('text', existingFields),
label: `Field ${index + 1}`, label: `Field ${index + 1}`,
field_type: 'text', field_type: 'text',
required: true, required: true,
@@ -70,6 +97,7 @@ interface ProceduralEditorState {
// Actions - Steps // Actions - Steps
addStep: (afterIndex?: number) => void addStep: (afterIndex?: number) => void
addSectionHeader: (afterIndex?: number) => void
removeStep: (stepId: string) => void removeStep: (stepId: string) => void
updateStep: (stepId: string, updates: Partial<ProceduralStep>) => void updateStep: (stepId: string, updates: Partial<ProceduralStep>) => void
moveStep: (fromIndex: number, toIndex: number) => void moveStep: (fromIndex: number, toIndex: number) => void
@@ -78,8 +106,8 @@ interface ProceduralEditorState {
// Actions - Intake Form // Actions - Intake Form
addField: () => void addField: () => void
removeField: (variableName: string) => void removeField: (index: number) => void
updateField: (variableName: string, updates: Partial<IntakeFormField>) => void updateField: (index: number, updates: Partial<IntakeFormField>) => void
moveField: (fromIndex: number, toIndex: number) => void moveField: (fromIndex: number, toIndex: number) => void
// Actions - Save // Actions - Save
@@ -200,15 +228,31 @@ export const useProceduralEditorStore = create<ProceduralEditorState>()(
}) })
}, },
addSectionHeader: (afterIndex) => {
set((state) => {
const endIndex = state.steps.findIndex((s) => s.type === 'procedure_end')
const insertAt = afterIndex !== undefined
? Math.min(afterIndex + 1, endIndex >= 0 ? endIndex : state.steps.length)
: (endIndex >= 0 ? endIndex : state.steps.length)
const newHeader = createSectionHeader('New Section')
state.steps.splice(insertAt, 0, newHeader)
state.expandedStepId = newHeader.id
state.isDirty = true
})
},
removeStep: (stepId) => { removeStep: (stepId) => {
set((state) => { set((state) => {
const index = state.steps.findIndex((s) => s.id === stepId) const index = state.steps.findIndex((s) => s.id === stepId)
if (index === -1) return if (index === -1) return
// Don't remove the end step // Don't remove the end step
if (state.steps[index].type === 'procedure_end') return if (state.steps[index].type === 'procedure_end') return
// Don't remove if it's the only procedure_step // Don't remove if it's the only procedure_step (section headers can always be removed)
const stepCount = state.steps.filter((s) => s.type === 'procedure_step').length if (state.steps[index].type === 'procedure_step') {
if (stepCount <= 1) return const stepCount = state.steps.filter((s) => s.type === 'procedure_step').length
if (stepCount <= 1) return
}
state.steps.splice(index, 1) state.steps.splice(index, 1)
if (state.selectedStepId === stepId) state.selectedStepId = null if (state.selectedStepId === stepId) state.selectedStepId = null
@@ -249,28 +293,31 @@ export const useProceduralEditorStore = create<ProceduralEditorState>()(
// Intake Form // Intake Form
addField: () => { addField: () => {
set((state) => { set((state) => {
const newField = createDefaultField(state.intakeForm.length) const newField = createDefaultField(state.intakeForm.length, state.intakeForm)
state.intakeForm.push(newField) state.intakeForm.push(newField)
state.isDirty = true state.isDirty = true
}) })
}, },
removeField: (variableName) => { removeField: (index) => {
set((state) => { set((state) => {
const index = state.intakeForm.findIndex((f) => f.variable_name === variableName) if (index < 0 || index >= state.intakeForm.length) return
if (index !== -1) { state.intakeForm.splice(index, 1)
state.intakeForm.splice(index, 1) // Reorder display_order
// Reorder display_order state.intakeForm.forEach((f, i) => { f.display_order = i + 1 })
state.intakeForm.forEach((f, i) => { f.display_order = i + 1 }) state.isDirty = true
state.isDirty = true
}
}) })
}, },
updateField: (variableName, updates) => { updateField: (index, updates) => {
set((state) => { set((state) => {
const field = state.intakeForm.find((f) => f.variable_name === variableName) const field = state.intakeForm[index]
if (field) { if (field) {
// If field_type changed, auto-generate a new variable name
if (updates.field_type && updates.field_type !== field.field_type) {
const otherFields = state.intakeForm.filter((_, i) => i !== index)
updates.variable_name = generateVariableName(updates.field_type, otherFields)
}
Object.assign(field, updates) Object.assign(field, updates)
state.isDirty = true state.isDirty = true
} }

View File

@@ -61,7 +61,7 @@ export interface TreeStructure {
export type TreeType = 'troubleshooting' | 'procedural' export type TreeType = 'troubleshooting' | 'procedural'
export type IntakeFieldType = export type IntakeFieldType =
| 'text' | 'textarea' | 'number' | 'ip_address' | 'email' | 'text' | 'textarea' | 'number' | 'ip_address' | 'email' | 'url'
| 'select' | 'multi_select' | 'checkbox' | 'password' | 'select' | 'multi_select' | 'checkbox' | 'password'
export type StepContentType = 'action' | 'informational' | 'verification' | 'warning' export type StepContentType = 'action' | 'informational' | 'verification' | 'warning'
@@ -105,7 +105,7 @@ export interface StepVerification {
export interface ProceduralStep { export interface ProceduralStep {
id: string id: string
type: 'procedure_step' | 'procedure_end' type: 'procedure_step' | 'procedure_end' | 'section_header'
title: string title: string
description?: string description?: string
content_type?: StepContentType content_type?: StepContentType