From 96b8818b364b7afc79066187ac551e97f3b49d8e Mon Sep 17 00:00:00 2001 From: chihlasm Date: Thu, 19 Feb 2026 02:53:59 -0500 Subject: [PATCH] docs: add procedural editor redesign implementation plan 7 tasks across 7 phases: collapsible sections, fixed-height layout, step list improvements, drag-to-reorder, maintenance schedule section. Co-Authored-By: Claude Opus 4.6 --- ...6-02-19-procedural-editor-redesign-impl.md | 795 ++++++++++++++++++ 1 file changed, 795 insertions(+) create mode 100644 docs/plans/2026-02-19-procedural-editor-redesign-impl.md diff --git a/docs/plans/2026-02-19-procedural-editor-redesign-impl.md b/docs/plans/2026-02-19-procedural-editor-redesign-impl.md new file mode 100644 index 00000000..a4f794cf --- /dev/null +++ b/docs/plans/2026-02-19-procedural-editor-redesign-impl.md @@ -0,0 +1,795 @@ +# 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 + defaultExpanded?: boolean + children: ReactNode +} + +export function CollapsibleEditorSection({ + title, + icon, + summary, + defaultExpanded = false, + children, +}: CollapsibleEditorSectionProps) { + const [expanded, setExpanded] = useState(defaultExpanded) + + return ( +
+ {/* Collapsed header — always visible */} + + + {/* Expanded content */} + {expanded && ( +
+ {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) an empty state when no steps exist. 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)`. + +**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. It shows a schedule builder for new flows (or flows without a schedule) and a collapsed summary for existing scheduled flows. 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`. + +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.