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:
@@ -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'
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user