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,29 +78,8 @@ 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"> <label className="mb-1 flex items-center gap-1 text-xs font-medium text-white/50">
<Clock className="h-3 w-3" /> <Clock className="h-3 w-3" />
Est. Minutes Est. Minutes
@@ -84,22 +93,6 @@ export function StepEditor({ step, stepNumber, onUpdate, onCollapse, availableVa
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>
</div>
{/* Section Header */}
<div>
<label className="mb-1 flex items-center gap-1 text-xs font-medium text-white/50">
<Type className="h-3 w-3" />
Section Header (optional)
</label>
<input
type="text"
value={step.section_header || ''}
onChange={(e) => onUpdate({ section_header: e.target.value || undefined })}
placeholder="e.g. Phase 2: AD Configuration"
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>
{/* Description */} {/* Description */}
<div> <div>
@@ -127,6 +120,55 @@ export function StepEditor({ step, stepNumber, onUpdate, onCollapse, availableVa
)} )}
</div> </div>
{/* Commands */}
<div>
<label className="mb-1 flex items-center gap-1 text-xs font-medium text-white/50">
<Terminal className="h-3 w-3" />
Commands (optional)
</label>
<textarea
value={typeof step.commands === 'string' ? step.commands : (Array.isArray(step.commands) ? step.commands.map(c => c.code).join('\n\n') : '')}
onChange={(e) => onUpdate({ commands: e.target.value || undefined })}
placeholder="Install-WindowsFeature AD-Domain-Services -IncludeManagementTools"
rows={3}
className="w-full rounded border border-white/10 bg-black/50 px-3 py-2 font-mono text-sm text-white placeholder:text-white/30 focus:border-white/30 focus:outline-none focus:ring-1 focus:ring-white/20"
/>
</div>
{/* More Options toggle */}
<button
type="button"
onClick={() => setShowMore(!showMore)}
className="flex items-center gap-1.5 text-xs text-white/40 hover:text-white/60"
>
<Settings2 className="h-3 w-3" />
More Options
{showMore ? <ChevronUp className="h-3 w-3" /> : <ChevronDown className="h-3 w-3" />}
</button>
{showMore && (
<div className="space-y-4 border-t border-white/[0.06] pt-4">
{/* Content Type */}
<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>
{/* Warning text */} {/* Warning text */}
{(step.content_type === 'warning' || step.warning_text) && ( {(step.content_type === 'warning' || step.warning_text) && (
<div> <div>
@@ -144,21 +186,6 @@ export function StepEditor({ step, stepNumber, onUpdate, onCollapse, availableVa
</div> </div>
)} )}
{/* Commands */}
<div>
<label className="mb-1 flex items-center gap-1 text-xs font-medium text-white/50">
<Terminal className="h-3 w-3" />
Commands (optional)
</label>
<textarea
value={typeof step.commands === 'string' ? step.commands : (Array.isArray(step.commands) ? step.commands.map(c => c.code).join('\n\n') : '')}
onChange={(e) => onUpdate({ commands: e.target.value || undefined })}
placeholder="Install-WindowsFeature AD-Domain-Services -IncludeManagementTools"
rows={3}
className="w-full rounded border border-white/10 bg-black/50 px-3 py-2 font-mono text-sm text-white placeholder:text-white/30 focus:border-white/30 focus:outline-none focus:ring-1 focus:ring-white/20"
/>
</div>
{/* Expected Outcome */} {/* Expected Outcome */}
<div> <div>
<label className="mb-1 block text-xs font-medium text-white/50">Expected Outcome (optional)</label> <label className="mb-1 block text-xs font-medium text-white/50">Expected Outcome (optional)</label>
@@ -228,6 +255,8 @@ export function StepEditor({ step, stepNumber, onUpdate, onCollapse, availableVa
</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,6 +36,14 @@ export function StepList() {
({procedureSteps.length} step{procedureSteps.length !== 1 ? 's' : ''}) ({procedureSteps.length} step{procedureSteps.length !== 1 ? 's' : ''})
</span> </span>
</div> </div>
<div className="flex items-center gap-2">
<button
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"
>
<SeparatorHorizontal className="h-3.5 w-3.5" />
Add Section
</button>
<button <button
onClick={() => addStep()} 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" 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"
@@ -42,11 +52,11 @@ export function StepList() {
Add Step Add Step
</button> </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 const isExpanded = expandedStepId === step.id
const contentType = step.content_type || 'action'
const config = contentTypeConfig[contentType]
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 && ( <StepEditor
<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={step}
{step.section_header} stepNumber={0}
onUpdate={(updates) => updateStep(step.id, updates)}
onCollapse={() => setExpandedStepId(null)}
availableVariables={intakeForm}
/>
</div> </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 contentType = step.content_type || 'action'
const config = contentTypeConfig[contentType]
const Icon = config.icon
if (isExpanded) {
return (
<div key={step.id}>
<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)
if (state.steps[index].type === 'procedure_step') {
const stepCount = state.steps.filter((s) => s.type === 'procedure_step').length const stepCount = state.steps.filter((s) => s.type === 'procedure_step').length
if (stepCount <= 1) return 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