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: 'ip_address', label: 'IP Address' },
{ value: 'email', label: 'Email' },
{ value: 'url', label: 'URL' },
{ value: 'select', label: 'Select (Dropdown)' },
{ value: 'multi_select', label: 'Multi-Select' },
{ value: 'checkbox', label: 'Checkbox' },
@@ -16,11 +17,12 @@ const FIELD_TYPE_OPTIONS: { value: IntakeFieldType; label: string }[] = [
interface IntakeFieldEditorProps {
field: IntakeFormField
index: number
onUpdate: (updates: Partial<IntakeFormField>) => 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 needsOptions = field.field_type === 'select' || field.field_type === 'multi_select'

View File

@@ -36,10 +36,11 @@ export function IntakeFormBuilder() {
<div className="space-y-2">
{intakeForm.map((field, index) => (
<IntakeFieldEditor
key={field.variable_name + '-' + index}
key={`field-${index}-${field.display_order}`}
field={field}
onUpdate={(updates) => updateField(field.variable_name, updates)}
onRemove={() => removeField(field.variable_name)}
index={index}
onUpdate={(updates) => updateField(index, updates)}
onRemove={() => removeField(index)}
/>
))}
</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 { cn } from '@/lib/utils'
@@ -18,6 +19,35 @@ interface 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 (
<div className="glass-card rounded-xl border border-white/10 p-4">
{/* Header */}
@@ -48,55 +78,18 @@ export function StepEditor({ step, stepNumber, onUpdate, onCollapse, availableVa
/>
</div>
{/* Content type + Section header row */}
<div className="grid grid-cols-2 gap-3">
<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>
{/* Est. Minutes */}
<div className="w-40">
<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)
<Clock className="h-3 w-3" />
Est. Minutes
</label>
<input
type="text"
value={step.section_header || ''}
onChange={(e) => onUpdate({ section_header: e.target.value || undefined })}
placeholder="e.g. Phase 2: AD Configuration"
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>
@@ -127,23 +120,6 @@ export function StepEditor({ step, stepNumber, onUpdate, onCollapse, availableVa
)}
</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 */}
<div>
<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>
{/* Expected Outcome */}
<div>
<label className="mb-1 block text-xs font-medium text-white/50">Expected Outcome (optional)</label>
<input
type="text"
value={step.expected_outcome || ''}
onChange={(e) => onUpdate({ expected_outcome: e.target.value || undefined })}
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"
/>
</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>
{/* 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>
{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>
{/* 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">
{/* 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>
)}
{/* Expected Outcome */}
<div>
<label className="mb-1 block text-xs font-medium text-white/50">Expected Outcome (optional)</label>
<input
type="checkbox"
checked={step.notes_enabled !== false}
onChange={(e) => onUpdate({ notes_enabled: e.target.checked })}
className="rounded border-white/20"
type="text"
value={step.expected_outcome || ''}
onChange={(e) => onUpdate({ expected_outcome: e.target.value || undefined })}
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
</label>
</div>
{/* 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>
)

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 { StepEditor } from './StepEditor'
import { useProceduralEditorStore } from '@/store/proceduralEditorStore'
@@ -18,11 +18,13 @@ export function StepList() {
expandedStepId,
setExpandedStepId,
addStep,
addSectionHeader,
removeStep,
updateStep,
} = useProceduralEditorStore()
const procedureSteps = steps.filter((s) => s.type === 'procedure_step')
let stepCounter = 0
return (
<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' : ''})
</span>
</div>
<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 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
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 className="space-y-2">
{steps.map((step, index) => {
{steps.map((step) => {
if (step.type === 'procedure_end') {
// Render end step as a simple footer
return (
<div
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 contentType = step.content_type || 'action'
const config = contentTypeConfig[contentType]
const Icon = config.icon
const stepNumber = index + 1
if (isExpanded) {
return (
<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
step={step}
stepNumber={stepNumber}
@@ -92,11 +139,6 @@ export function StepList() {
return (
<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
className={cn(
'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
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
input = (
<input

View File

@@ -1,10 +1,29 @@
import { create } from 'zustand'
import { temporal } from 'zundo'
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 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 {
return {
id: generateId(),
@@ -24,9 +43,17 @@ function createEndStep(): ProceduralStep {
}
}
function createDefaultField(index: number): IntakeFormField {
function createSectionHeader(title: string): ProceduralStep {
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}`,
field_type: 'text',
required: true,
@@ -70,6 +97,7 @@ interface ProceduralEditorState {
// Actions - Steps
addStep: (afterIndex?: number) => void
addSectionHeader: (afterIndex?: number) => void
removeStep: (stepId: string) => void
updateStep: (stepId: string, updates: Partial<ProceduralStep>) => void
moveStep: (fromIndex: number, toIndex: number) => void
@@ -78,8 +106,8 @@ interface ProceduralEditorState {
// Actions - Intake Form
addField: () => void
removeField: (variableName: string) => void
updateField: (variableName: string, updates: Partial<IntakeFormField>) => void
removeField: (index: number) => void
updateField: (index: number, updates: Partial<IntakeFormField>) => void
moveField: (fromIndex: number, toIndex: number) => void
// 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) => {
set((state) => {
const index = state.steps.findIndex((s) => s.id === stepId)
if (index === -1) return
// Don't remove the end step
if (state.steps[index].type === 'procedure_end') return
// Don't remove if it's the only procedure_step
const stepCount = state.steps.filter((s) => s.type === 'procedure_step').length
if (stepCount <= 1) return
// 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
if (stepCount <= 1) return
}
state.steps.splice(index, 1)
if (state.selectedStepId === stepId) state.selectedStepId = null
@@ -249,28 +293,31 @@ export const useProceduralEditorStore = create<ProceduralEditorState>()(
// Intake Form
addField: () => {
set((state) => {
const newField = createDefaultField(state.intakeForm.length)
const newField = createDefaultField(state.intakeForm.length, state.intakeForm)
state.intakeForm.push(newField)
state.isDirty = true
})
},
removeField: (variableName) => {
removeField: (index) => {
set((state) => {
const index = state.intakeForm.findIndex((f) => f.variable_name === variableName)
if (index !== -1) {
state.intakeForm.splice(index, 1)
// Reorder display_order
state.intakeForm.forEach((f, i) => { f.display_order = i + 1 })
state.isDirty = true
}
if (index < 0 || index >= state.intakeForm.length) return
state.intakeForm.splice(index, 1)
// Reorder display_order
state.intakeForm.forEach((f, i) => { f.display_order = i + 1 })
state.isDirty = true
})
},
updateField: (variableName, updates) => {
updateField: (index, updates) => {
set((state) => {
const field = state.intakeForm.find((f) => f.variable_name === variableName)
const field = state.intakeForm[index]
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)
state.isDirty = true
}

View File

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