# 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** ```tsx // 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 (
{/* Collapsed header — always visible */} {/* Expanded content */} {isExpanded && (
{children}
)}
) } ``` **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** ```bash 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: 1. Outer wrapper: `
` (replaces `container mx-auto`) 2. Toolbar: sticky `
` containing the back button, title, and save/publish buttons 3. Collapsible sections zone: `
` containing CollapsibleEditorSection wrappers for Details and IntakeFormBuilder 4. Step list zone: `
` containing StepList Import `CollapsibleEditorSection` at the top: ```tsx import { CollapsibleEditorSection } from '@/components/procedural-editor/CollapsibleEditorSection' ``` The Details collapsible section needs a summary string. Build it from store state: ```tsx 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: ```tsx 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** ```bash 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 `
` card wrapper. The step list now renders directly in the scrolling container. Update the header to show step count + total estimated time: ```tsx const totalMinutes = steps .filter(s => s.type === 'procedure_step' && s.estimated_minutes) .reduce((sum, s) => sum + (s.estimated_minutes || 0), 0) // In the header: ({procedureSteps.length} step{procedureSteps.length !== 1 ? 's' : ''} {totalMinutes > 0 ? ` · ~${totalMinutes} min` : ''}) ``` Add empty state after the header, before the step map: ```tsx {procedureSteps.length === 0 && (

Add your first step

Steps define the actions engineers follow during this procedure.

)} ``` **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: ```tsx import { useRef, useEffect } from 'react' const listEndRef = useRef(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:
``` **Step 4: Verify it builds** Run: `cd frontend && npm run build 2>&1 | tail -5` Expected: Clean build **Step 5: Commit** ```bash 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: ```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: ```tsx 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 `
` with DndContext and SortableContext: ```tsx s.type !== 'procedure_end').map(s => s.id)} strategy={verticalListSortingStrategy}>
{steps.map((step) => { // ... existing rendering logic })}
``` **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: ```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 (
{/* Pass drag handle props to children via render prop or context */} {typeof children === 'function' ? children({ dragHandleProps: { ...attributes, ...listeners } }) : children}
) } ``` Actually, a simpler approach: apply useSortable directly to each card's GripVertical button. Wrap each step's outer `
` with `ref={setNodeRef}` and pass `{...attributes} {...listeners}` to the GripVertical button. For collapsed procedure_step cards (the main case), the existing `` button at line 148 gets the sortable props: ```tsx // Replace the GripVertical span/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** ```bash 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.ts` - `frontend/src/api/targetLists.ts` - `frontend/src/types/maintenance.ts` **Step 1: Create the component** ```tsx // 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(null) const [targetLists, setTargetLists] = useState([]) 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('') // 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 (
Loading schedule...
) } return (
{/* Frequency */}
{/* Time */}
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" />
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" />
{/* Timezone */}
{/* Target List */} {targetLists.length > 0 && (
)} {/* Save button */} {!treeId && (

Save the flow first to configure a schedule.

)}
) } ``` **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): ```tsx 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** ```bash 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: ```tsx import { MaintenanceScheduleSection, getScheduleSummary } from '@/components/procedural-editor/MaintenanceScheduleSection' import { Calendar } from 'lucide-react' ``` Add the schedule state (load existing schedule for summary): ```tsx 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(null) const [scheduleTargetList, setScheduleTargetList] = useState(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: ```tsx {isMaintenance && ( } summary={scheduleSummary} defaultExpanded={!isEditMode || !schedule} > )} ``` **Step 3: Verify it builds** Run: `cd frontend && npm run build 2>&1 | tail -5` Expected: Clean build **Step 4: Commit** ```bash 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** ```bash 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.