Convert scrolling document layout to fixed-height editor with accordion-mode collapsible sections for Details and Intake Form. Step list now gets all remaining height with independent scrolling. Add CollapsibleEditorSection component with ARIA attributes (aria-expanded, aria-controls). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
28 KiB
Procedural Editor Redesign Implementation Plan
For Claude: REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
Goal: Restructure the procedural/maintenance flow editor with collapsible sections, fixed-height layout, drag-to-reorder steps, and maintenance schedule management.
Architecture: Convert ProceduralEditorPage from a scrolling document to a fixed-height editor. Details and Intake Form become collapsible one-liners. Step list gets all remaining height with independent scrolling. Add @dnd-kit drag-to-reorder on steps. Maintenance flows get an inline schedule section.
Tech Stack: React 19, TypeScript, Tailwind CSS, Zustand (proceduralEditorStore), @dnd-kit/core + @dnd-kit/sortable (already installed), existing maintenance schedule APIs.
Phase 1: CollapsibleEditorSection Component
Task 1: Create CollapsibleEditorSection
Files:
- Create:
frontend/src/components/procedural-editor/CollapsibleEditorSection.tsx
Context: This is a reusable wrapper that shows a one-line summary when collapsed and reveals full content when expanded. Used by Details, Intake Form, and Maintenance Schedule sections. See the design doc at docs/plans/2026-02-19-procedural-editor-redesign-design.md for the spec.
Step 1: Create the component
// frontend/src/components/procedural-editor/CollapsibleEditorSection.tsx
import { useState, type ReactNode } from 'react'
import { ChevronRight } from 'lucide-react'
import { cn } from '@/lib/utils'
interface CollapsibleEditorSectionProps {
title: string
icon: ReactNode
summary: string
expanded?: boolean
onToggle?: () => void
defaultExpanded?: boolean
children: ReactNode
}
export function CollapsibleEditorSection({
title,
icon,
summary,
expanded: controlledExpanded,
onToggle,
defaultExpanded = false,
children,
}: CollapsibleEditorSectionProps) {
const [internalExpanded, setInternalExpanded] = useState(defaultExpanded)
const isExpanded = controlledExpanded ?? internalExpanded
const sectionId = `section-${title.toLowerCase().replace(/\s+/g, '-')}`
const handleToggle = () => {
if (onToggle) {
onToggle()
} else {
setInternalExpanded(!internalExpanded)
}
}
return (
<div className="border-b border-border">
{/* Collapsed header — always visible */}
<button
type="button"
onClick={handleToggle}
aria-expanded={isExpanded}
aria-controls={sectionId}
className="flex w-full items-center gap-3 px-4 py-2.5 text-left transition-colors hover:bg-accent/50"
>
<ChevronRight
className={cn(
'h-4 w-4 shrink-0 text-muted-foreground transition-transform duration-200',
isExpanded && 'rotate-90'
)}
/>
<span className="shrink-0 text-muted-foreground">{icon}</span>
<span className="text-sm font-medium text-foreground">{title}</span>
{!isExpanded && (
<span className="min-w-0 truncate text-sm text-muted-foreground">
— {summary}
</span>
)}
</button>
{/* Expanded content */}
{isExpanded && (
<div id={sectionId} className="px-4 pb-4 pt-1">
{children}
</div>
)}
</div>
)
}
Step 2: Verify it builds
Run: cd frontend && npm run build 2>&1 | tail -5
Expected: built in success message (component is created but not imported anywhere yet)
Step 3: Commit
git add frontend/src/components/procedural-editor/CollapsibleEditorSection.tsx
git commit -m "feat: add CollapsibleEditorSection component for procedural editor"
Phase 2: Layout Restructure
Task 2: Convert ProceduralEditorPage to Fixed-Height Editor
Files:
- Modify:
frontend/src/pages/ProceduralEditorPage.tsx
Context: The page currently uses container mx-auto px-4 py-6 with vertical scrolling. We need to convert it to flex flex-col h-full overflow-hidden so the step list can scroll independently. The toolbar becomes a sticky header, and Details + Intake Form become collapsible sections.
Reference the existing troubleshooting editor pattern: frontend/src/pages/TreeEditorPage.tsx lines 409-410 for the flex h-full flex-col overflow-hidden pattern.
Step 1: Read the current file
Read: frontend/src/pages/ProceduralEditorPage.tsx
Understand the full structure before making changes.
Step 2: Restructure the layout
Replace the entire render return (from return ( to the closing )) with the new fixed-height layout. The key changes:
- Outer wrapper:
<div className="flex h-full flex-col overflow-hidden">(replacescontainer mx-auto) - Toolbar: sticky
<div className="flex items-center justify-between border-b border-border bg-card px-4 py-2">containing the back button, title, and save/publish buttons - Collapsible sections zone:
<div className="shrink-0">containing CollapsibleEditorSection wrappers for Details and IntakeFormBuilder - Step list zone:
<div className="flex-1 min-h-0 overflow-y-auto px-4 py-4">containing StepList
Import CollapsibleEditorSection at the top:
import { CollapsibleEditorSection } from '@/components/procedural-editor/CollapsibleEditorSection'
The Details collapsible section needs a summary string. Build it from store state:
const detailsSummary = [
name ? `"${name}"` : '"Untitled"',
tags.length > 0 ? `${tags.length} tag${tags.length !== 1 ? 's' : ''}` : 'No tags',
isPublic ? 'Public' : 'Private',
].join(' · ')
The IntakeFormBuilder collapsible section needs a summary too. Read the intakeForm array from the store (add it to destructuring if not already there) and build:
const intakeSummary = intakeForm.length === 0
? 'No fields defined'
: `${intakeForm.length} field${intakeForm.length !== 1 ? 's' : ''}: ${intakeForm.map(f => f.label || f.variable_name).slice(0, 4).join(', ')}${intakeForm.length > 4 ? ', ...' : ''}`
For the Details section content inside CollapsibleEditorSection, move the existing form fields (name input, description textarea, tags input, public checkbox) directly as children.
The Details section should defaultExpanded={!isEditMode} so new flows start with Details expanded (name is required), while existing flows start collapsed.
The Intake Form section should defaultExpanded={false} always.
Step 3: Verify it builds
Run: cd frontend && npm run build 2>&1 | tail -5
Expected: Clean build
Step 4: Commit
git add frontend/src/pages/ProceduralEditorPage.tsx
git commit -m "feat: restructure procedural editor to fixed-height layout with collapsible sections"
Phase 3: Step List Improvements
Task 3: Add Empty State and Step Count Header
Files:
- Modify:
frontend/src/components/procedural-editor/StepList.tsx
Context: The step list needs: (1) a step count + total estimated time in the header, (2) auto-scroll to new steps. Note: the store enforces minimum one procedure_step, so remove any "0 steps" UI paths. The step list's outer card wrapper should be removed since ProceduralEditorPage now provides the scrolling container — StepList should just render its header and step items directly.
Step 1: Read the current file
Read: frontend/src/components/procedural-editor/StepList.tsx
Step 2: Modify the header and add empty state
Remove the outer <div className="bg-card border border-border rounded-2xl p-4 sm:p-6"> card wrapper. The step list now renders directly in the scrolling container.
Update the header to show step count + total estimated time:
const totalMinutes = steps
.filter(s => s.type === 'procedure_step' && s.estimated_minutes)
.reduce((sum, s) => sum + (s.estimated_minutes || 0), 0)
// In the header:
<span className="text-sm text-muted-foreground">
({procedureSteps.length} step{procedureSteps.length !== 1 ? 's' : ''}
{totalMinutes > 0 ? ` · ~${totalMinutes} min` : ''})
</span>
Add empty state after the header, before the step map:
{procedureSteps.length === 0 && (
<div className="flex flex-col items-center justify-center py-16 text-center">
<Shield className="mb-3 h-10 w-10 text-muted-foreground/50" />
<h3 className="mb-1 text-sm font-medium text-foreground">Add your first step</h3>
<p className="mb-4 max-w-xs text-xs text-muted-foreground">
Steps define the actions engineers follow during this procedure.
</p>
<div className="flex items-center gap-2">
<button
onClick={() => addStep()}
className="flex items-center gap-1.5 rounded-md bg-gradient-brand px-3 py-1.5 text-sm font-medium text-white shadow-lg shadow-primary/20 hover:opacity-90"
>
<Plus className="h-3.5 w-3.5" />
Add Step
</button>
<button
onClick={() => addSectionHeader()}
className="flex items-center gap-1.5 rounded-md border border-border px-3 py-1.5 text-sm text-muted-foreground hover:bg-accent hover:text-foreground"
>
<SeparatorHorizontal className="h-3.5 w-3.5" />
Add Section
</button>
</div>
</div>
)}
Step 3: Add auto-scroll to new step
When a new step is added, the list should scroll to show it. Add a ref and useEffect:
import { useRef, useEffect } from 'react'
const listEndRef = useRef<HTMLDivElement>(null)
const prevStepCount = useRef(steps.length)
useEffect(() => {
if (steps.length > prevStepCount.current) {
listEndRef.current?.scrollIntoView({ behavior: 'smooth' })
}
prevStepCount.current = steps.length
}, [steps.length])
// At the bottom of the step list, before the Add Step button:
<div ref={listEndRef} />
Step 4: Verify it builds
Run: cd frontend && npm run build 2>&1 | tail -5
Expected: Clean build
Step 5: Commit
git add frontend/src/components/procedural-editor/StepList.tsx
git commit -m "feat: add empty state, step count header, and auto-scroll to step list"
Phase 4: Drag-to-Reorder Steps
Task 4: Add @dnd-kit Drag-to-Reorder to StepList
Files:
- Modify:
frontend/src/components/procedural-editor/StepList.tsx
Context: @dnd-kit is already installed (@dnd-kit/core@^6.3.1, @dnd-kit/sortable@^10.0.0). There's an existing pattern in frontend/src/components/step-library/CategoryRow.tsx using useSortable and in frontend/src/pages/admin/AdminCategoriesPage.tsx using DndContext + SortableContext. The store already has moveStep(fromIndex, toIndex). Reorder is array-index based only — do NOT recalculate display_order. procedure_end must remain non-draggable and always last. Drag handles must have accessible labels and keyboard support (Enter/Space to pick up, arrow keys to move, Escape to cancel).
Step 1: Read the existing @dnd-kit pattern
Read: frontend/src/components/step-library/CategoryRow.tsx (lines 1-50 for the useSortable pattern)
Read: frontend/src/pages/admin/AdminCategoriesPage.tsx (lines 1-10 for imports, lines 110-140 for handleDragEnd)
Step 2: Add DndContext to StepList
Add imports at the top of StepList.tsx:
import {
DndContext,
closestCenter,
KeyboardSensor,
PointerSensor,
useSensor,
useSensors,
type DragEndEvent,
} from '@dnd-kit/core'
import {
SortableContext,
sortableKeyboardCoordinates,
verticalListSortingStrategy,
useSortable,
} from '@dnd-kit/sortable'
import { CSS } from '@dnd-kit/utilities'
Add sensors and drag handler before the return:
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 8 } }),
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates })
)
const handleDragEnd = useCallback((event: DragEndEvent) => {
const { active, over } = event
if (!over || active.id === over.id) return
const oldIndex = steps.findIndex(s => s.id === active.id)
const newIndex = steps.findIndex(s => s.id === over.id)
if (oldIndex === -1 || newIndex === -1) return
// Don't allow moving past the procedure_end step
const endIndex = steps.findIndex(s => s.type === 'procedure_end')
if (newIndex >= endIndex) return
moveStep(oldIndex, newIndex)
}, [steps, moveStep])
Wrap the step list <div className="space-y-2"> with DndContext and SortableContext:
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
<SortableContext items={steps.filter(s => s.type !== 'procedure_end').map(s => s.id)} strategy={verticalListSortingStrategy}>
<div className="space-y-2">
{steps.map((step) => {
// ... existing rendering logic
})}
</div>
</SortableContext>
</DndContext>
Step 3: Make each step card sortable
For each step card (procedure_step collapsed, section_header collapsed), extract the card div into a SortableStepCard wrapper or apply useSortable inline.
The simplest approach: create a small wrapper component inside StepList.tsx:
function SortableStepWrapper({ id, children, disabled }: { id: string; children: ReactNode; disabled?: boolean }) {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id, disabled })
const style = {
transform: CSS.Transform.toString(transform),
transition,
}
return (
<div ref={setNodeRef} style={style} className={cn(isDragging && 'opacity-50 z-10')}>
{/* Pass drag handle props to children via render prop or context */}
{typeof children === 'function'
? children({ dragHandleProps: { ...attributes, ...listeners } })
: children}
</div>
)
}
Actually, a simpler approach: apply useSortable directly to each card's GripVertical button. Wrap each step's outer <div> with ref={setNodeRef} and pass {...attributes} {...listeners} to the GripVertical button.
For collapsed procedure_step cards (the main case), the existing <GripVertical> button at line 148 gets the sortable props:
// Replace the GripVertical span/button:
<button
type="button"
className="shrink-0 cursor-grab active:cursor-grabbing text-muted-foreground"
{...attributes}
{...listeners}
>
<GripVertical className="h-4 w-4" />
</button>
For expanded steps (StepEditor), disable sorting (disabled: true in useSortable) since the user is editing.
The procedure_end step should NOT be in the SortableContext items array (already excluded above) and should not have useSortable applied.
Step 4: Verify it builds
Run: cd frontend && npm run build 2>&1 | tail -5
Expected: Clean build
Step 5: Commit
git add frontend/src/components/procedural-editor/StepList.tsx
git commit -m "feat: add drag-to-reorder steps with @dnd-kit"
Phase 5: Maintenance Schedule Section
Task 5: Create MaintenanceScheduleSection
Files:
- Create:
frontend/src/components/procedural-editor/MaintenanceScheduleSection.tsx
Context: This component renders only for maintenance flows. Schedule is NOT part of tree_structure — it's persisted through separate maintenance schedule API endpoints. Two-stage save: tree first, then schedule. If schedule save fails, tree save remains successful — show actionable error and preserve schedule draft state. It uses the existing maintenanceSchedulesApi from frontend/src/api/maintenanceSchedules.ts and targetListsApi from frontend/src/api/targetLists.ts. Types are in frontend/src/types/maintenance.ts. Schedule draft state should be local UI state, not stored in proceduralEditorStore.
Read these files first:
frontend/src/api/maintenanceSchedules.tsfrontend/src/api/targetLists.tsfrontend/src/types/maintenance.ts
Step 1: Create the component
// frontend/src/components/procedural-editor/MaintenanceScheduleSection.tsx
import { useState, useEffect } from 'react'
import { Calendar, Clock } from 'lucide-react'
import { maintenanceSchedulesApi } from '@/api/maintenanceSchedules'
import { targetListsApi } from '@/api/targetLists'
import type { MaintenanceSchedule, TargetList } from '@/types'
import { cn } from '@/lib/utils'
import { toast } from '@/lib/toast'
interface MaintenanceScheduleSectionProps {
treeId: string | null // null for new flows
}
const FREQUENCY_OPTIONS = [
{ value: 'daily', label: 'Daily', cron: (hour: number, min: number) => `${min} ${hour} * * *` },
{ value: 'weekly-mon', label: 'Weekly (Monday)', cron: (hour: number, min: number) => `${min} ${hour} * * 1` },
{ value: 'weekly-fri', label: 'Weekly (Friday)', cron: (hour: number, min: number) => `${min} ${hour} * * 5` },
{ value: 'monthly', label: 'Monthly (1st)', cron: (hour: number, min: number) => `${min} ${hour} 1 * *` },
] as const
const TIMEZONE_OPTIONS = [
'UTC',
'America/New_York',
'America/Chicago',
'America/Denver',
'America/Los_Angeles',
'Europe/London',
'Europe/Berlin',
'Asia/Tokyo',
'Australia/Sydney',
]
export function MaintenanceScheduleSection({ treeId }: MaintenanceScheduleSectionProps) {
const [schedule, setSchedule] = useState<MaintenanceSchedule | null>(null)
const [targetLists, setTargetLists] = useState<TargetList[]>([])
const [isLoading, setIsLoading] = useState(false)
const [isSaving, setIsSaving] = useState(false)
// Form state
const [frequency, setFrequency] = useState('weekly-mon')
const [hour, setHour] = useState(9)
const [minute, setMinute] = useState(0)
const [timezone, setTimezone] = useState('UTC')
const [selectedTargetListId, setSelectedTargetListId] = useState<string>('')
// Load existing schedule and target lists
useEffect(() => {
const load = async () => {
setIsLoading(true)
try {
const lists = await targetListsApi.list()
setTargetLists(lists)
if (treeId) {
try {
const existing = await maintenanceSchedulesApi.getForTree(treeId)
setSchedule(existing)
if (existing.target_list_id) {
setSelectedTargetListId(existing.target_list_id)
}
} catch {
// No schedule yet — that's fine
}
}
} catch {
// Target lists may not load — non-critical
} finally {
setIsLoading(false)
}
}
load()
}, [treeId])
const handleSaveSchedule = async () => {
if (!treeId) {
toast.error('Save the flow first before configuring a schedule')
return
}
setIsSaving(true)
try {
const freqOption = FREQUENCY_OPTIONS.find(f => f.value === frequency)
const cronExpression = freqOption?.cron(hour, minute) ?? `${minute} ${hour} * * 1`
if (schedule) {
const updated = await maintenanceSchedulesApi.update(schedule.id, {
cron_expression: cronExpression,
timezone,
target_list_id: selectedTargetListId || undefined,
})
setSchedule(updated)
toast.success('Schedule updated')
} else {
const created = await maintenanceSchedulesApi.create({
tree_id: treeId,
cron_expression: cronExpression,
timezone,
target_list_id: selectedTargetListId || undefined,
})
setSchedule(created)
toast.success('Schedule created')
}
} catch {
toast.error('Failed to save schedule')
} finally {
setIsSaving(false)
}
}
const selectedTargetList = targetLists.find(tl => tl.id === selectedTargetListId)
if (isLoading) {
return (
<div className="px-4 py-3 text-sm text-muted-foreground">Loading schedule...</div>
)
}
return (
<div className="space-y-4">
{/* Frequency */}
<div>
<label className="mb-1 block text-sm font-medium text-muted-foreground">Frequency</label>
<select
value={frequency}
onChange={(e) => setFrequency(e.target.value)}
className="w-full rounded-lg border border-border bg-card px-3 py-2 text-foreground focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary/20"
>
{FREQUENCY_OPTIONS.map(opt => (
<option key={opt.value} value={opt.value}>{opt.label}</option>
))}
</select>
</div>
{/* Time */}
<div className="grid grid-cols-2 gap-4">
<div>
<label className="mb-1 block text-sm font-medium text-muted-foreground">Hour</label>
<input
type="number"
min={0}
max={23}
value={hour}
onChange={(e) => setHour(Number(e.target.value))}
className="w-full rounded-lg border border-border bg-card px-3 py-2 text-foreground focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary/20"
/>
</div>
<div>
<label className="mb-1 block text-sm font-medium text-muted-foreground">Minute</label>
<input
type="number"
min={0}
max={59}
value={minute}
onChange={(e) => setMinute(Number(e.target.value))}
className="w-full rounded-lg border border-border bg-card px-3 py-2 text-foreground focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary/20"
/>
</div>
</div>
{/* Timezone */}
<div>
<label className="mb-1 block text-sm font-medium text-muted-foreground">Timezone</label>
<select
value={timezone}
onChange={(e) => setTimezone(e.target.value)}
className="w-full rounded-lg border border-border bg-card px-3 py-2 text-foreground focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary/20"
>
{TIMEZONE_OPTIONS.map(tz => (
<option key={tz} value={tz}>{tz}</option>
))}
</select>
</div>
{/* Target List */}
{targetLists.length > 0 && (
<div>
<label className="mb-1 block text-sm font-medium text-muted-foreground">Target List (optional)</label>
<select
value={selectedTargetListId}
onChange={(e) => setSelectedTargetListId(e.target.value)}
className="w-full rounded-lg border border-border bg-card px-3 py-2 text-foreground focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary/20"
>
<option value="">None — manual targets only</option>
{targetLists.map(tl => (
<option key={tl.id} value={tl.id}>{tl.name} ({tl.targets.length} targets)</option>
))}
</select>
</div>
)}
{/* Save button */}
<button
onClick={handleSaveSchedule}
disabled={isSaving || !treeId}
className={cn(
'flex items-center gap-2 rounded-lg px-4 py-2 text-sm font-medium',
treeId
? 'bg-gradient-brand text-white shadow-lg shadow-primary/20 hover:opacity-90'
: 'bg-card border border-border text-muted-foreground cursor-not-allowed opacity-50'
)}
>
<Clock className="h-4 w-4" />
{isSaving ? 'Saving...' : schedule ? 'Update Schedule' : 'Create Schedule'}
</button>
{!treeId && (
<p className="text-xs text-muted-foreground">Save the flow first to configure a schedule.</p>
)}
</div>
)
}
Step 2: Build a summary string helper
Add this exported function at the bottom of the file (used by ProceduralEditorPage for the collapsible section summary):
export function getScheduleSummary(schedule: MaintenanceSchedule | null, targetList?: TargetList | null): string {
if (!schedule) return 'No schedule configured'
// Parse cron for human-readable display
const parts = schedule.cron_expression.split(' ')
const min = parts[0] ?? '0'
const hour = parts[1] ?? '0'
const timeStr = `${hour.padStart(2, '0')}:${min.padStart(2, '0')}`
const dayOfWeek = parts[4]
let freqStr = 'Custom'
if (parts[2] === '*' && parts[3] === '*') {
if (dayOfWeek === '*') freqStr = 'Daily'
else if (dayOfWeek === '1') freqStr = 'Every Monday'
else if (dayOfWeek === '5') freqStr = 'Every Friday'
} else if (parts[2] === '1') {
freqStr = 'Monthly (1st)'
}
const targetStr = targetList ? ` · ${targetList.targets.length} targets` : ''
return `${freqStr} at ${timeStr} ${schedule.timezone}${targetStr}`
}
Step 3: Verify it builds
Run: cd frontend && npm run build 2>&1 | tail -5
Expected: Clean build
Step 4: Commit
git add frontend/src/components/procedural-editor/MaintenanceScheduleSection.tsx
git commit -m "feat: add MaintenanceScheduleSection with schedule builder and summary"
Phase 6: Wire Maintenance Schedule into ProceduralEditorPage
Task 6: Add Schedule Section to ProceduralEditorPage
Files:
- Modify:
frontend/src/pages/ProceduralEditorPage.tsx
Context: The MaintenanceScheduleSection should appear as a third collapsible section, only for maintenance flows. It needs access to treeId (from store) and renders between Intake Form and the step list.
Step 1: Read the current ProceduralEditorPage
Read: frontend/src/pages/ProceduralEditorPage.tsx
Step 2: Add imports and the schedule section
Add imports:
import { MaintenanceScheduleSection, getScheduleSummary } from '@/components/procedural-editor/MaintenanceScheduleSection'
import { Calendar } from 'lucide-react'
Add the schedule state (load existing schedule for summary):
import { useState, useEffect } from 'react'
import { maintenanceSchedulesApi } from '@/api/maintenanceSchedules'
import { targetListsApi } from '@/api/targetLists'
import type { MaintenanceSchedule, TargetList } from '@/types'
// Inside the component:
const [schedule, setSchedule] = useState<MaintenanceSchedule | null>(null)
const [scheduleTargetList, setScheduleTargetList] = useState<TargetList | null>(null)
useEffect(() => {
if (!isMaintenance || !treeId) return
const loadSchedule = async () => {
try {
const s = await maintenanceSchedulesApi.getForTree(treeId)
setSchedule(s)
if (s.target_list_id) {
const tl = await targetListsApi.get(s.target_list_id)
setScheduleTargetList(tl)
}
} catch {
// No schedule — fine
}
}
loadSchedule()
}, [isMaintenance, treeId])
const scheduleSummary = getScheduleSummary(schedule, scheduleTargetList)
Add the collapsible schedule section after the Intake Form section and before the step list:
{isMaintenance && (
<CollapsibleEditorSection
title="Schedule"
icon={<Calendar className="h-4 w-4" />}
summary={scheduleSummary}
defaultExpanded={!isEditMode || !schedule}
>
<MaintenanceScheduleSection treeId={treeId} />
</CollapsibleEditorSection>
)}
Step 3: Verify it builds
Run: cd frontend && npm run build 2>&1 | tail -5
Expected: Clean build
Step 4: Commit
git add frontend/src/pages/ProceduralEditorPage.tsx
git commit -m "feat: wire maintenance schedule section into procedural editor"
Phase 7: Final Verification
Task 7: Build and Manual Testing Checklist
Step 1: Run full build
Run: cd frontend && npm run build 2>&1 | tail -10
Expected: Clean build with no TypeScript or lint errors
Step 2: Manual testing checklist
Start the dev server: cd frontend && npm run dev
Test each scenario:
Procedural flow — new:
- Navigate to
/flows/new?type=procedural - Page uses fixed-height layout (no page scrolling)
- Details section is expanded by default (name field visible)
- Intake Form section is collapsed, shows "No fields defined"
- No Schedule section visible (procedural, not maintenance)
- Step list shows empty state with "Add your first step"
- Click "Add Step" — new step appears and auto-expands
- Step list scrolls independently from the toolbar
- Fill in name, collapse Details — summary shows
"Name" · No tags · Private
Procedural flow — existing:
- Edit an existing procedural flow
- Details section is collapsed with rich summary
- Intake Form section is collapsed with field names
- Steps visible immediately with count and estimated time
- Drag a step by its grip handle — reorders correctly
- Expanded step cannot be dragged
- Section headers can be dragged
- procedure_end step stays at the bottom (cannot be dragged above it)
Maintenance flow — new:
- Navigate to
/flows/new?type=maintenance - Schedule section is visible and expanded
- Can select frequency, time, timezone
- "Save the flow first" message shown (no treeId yet)
- After saving, schedule can be created
Maintenance flow — existing with schedule:
- Schedule section shows collapsed summary: "Every Monday at 09:00 UTC · 5 targets"
- Expand to modify schedule
- Update saves correctly
Step 3: Commit any fixes found during testing
git add -A
git commit -m "fix: address issues found during manual testing"
Step 4: Final commit message for the complete feature
If all tests pass and no fixes needed, the branch is ready for PR.