feat: procedural editor redesign with collapsible sections and DnD #84
795
docs/plans/2026-02-19-procedural-editor-redesign-impl.md
Normal file
795
docs/plans/2026-02-19-procedural-editor-redesign-impl.md
Normal file
@@ -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 (
|
||||
<div className="border-b border-border">
|
||||
{/* Collapsed header — always visible */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
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',
|
||||
expanded && 'rotate-90'
|
||||
)}
|
||||
/>
|
||||
<span className="shrink-0 text-muted-foreground">{icon}</span>
|
||||
<span className="text-sm font-medium text-foreground">{title}</span>
|
||||
{!expanded && (
|
||||
<span className="min-w-0 truncate text-sm text-muted-foreground">
|
||||
— {summary}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Expanded content */}
|
||||
{expanded && (
|
||||
<div 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**
|
||||
|
||||
```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: `<div className="flex h-full flex-col overflow-hidden">` (replaces `container mx-auto`)
|
||||
2. 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
|
||||
3. Collapsible sections zone: `<div className="shrink-0">` containing CollapsibleEditorSection wrappers for Details and IntakeFormBuilder
|
||||
4. Step list zone: `<div className="flex-1 min-h-0 overflow-y-auto px-4 py-4">` 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 `<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:
|
||||
```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:
|
||||
<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:
|
||||
```tsx
|
||||
{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:
|
||||
|
||||
```tsx
|
||||
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**
|
||||
|
||||
```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 `<div className="space-y-2">` with DndContext and SortableContext:
|
||||
```tsx
|
||||
<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:
|
||||
|
||||
```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:
|
||||
```tsx
|
||||
// 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**
|
||||
|
||||
```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<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):
|
||||
|
||||
```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<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:
|
||||
```tsx
|
||||
{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**
|
||||
|
||||
```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.
|
||||
Reference in New Issue
Block a user