feat: restructure procedural editor with collapsible sections and fixed-height layout
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>
This commit is contained in:
@@ -81,6 +81,14 @@ A reusable component used by Details, Intake Form, and Maintenance Schedule.
|
||||
|
||||
**Expanded state:** Full content slides down with a subtle animation. Collapse chevron rotates.
|
||||
|
||||
**Accordion mode:** Single-open by default — expanding one section collapses others. Controlled by parent page component.
|
||||
|
||||
**Accessibility:**
|
||||
- Toggle button has `aria-expanded` and `aria-controls` pointing to section content `id`
|
||||
- Content region has matching `id`
|
||||
- Keyboard operable (Enter/Space to toggle)
|
||||
- Focus remains stable after toggle
|
||||
|
||||
### Details Section — Collapsed Summary
|
||||
|
||||
Format: `"Flow Name" · N tags · Public/Private`
|
||||
@@ -107,7 +115,9 @@ Only renders when `treeType === 'maintenance'`.
|
||||
**New flow (no schedule):** Section starts expanded with:
|
||||
- Cron expression builder (frequency picker: daily/weekly/monthly + time + timezone)
|
||||
- Target list selector (dropdown of saved target lists, or create new inline)
|
||||
- These fields write to the store and get saved with the flow
|
||||
- These fields write to local UI draft state (NOT tree_structure)
|
||||
- On save: tree saved first, then schedule created via `maintenanceSchedulesApi.create` with resulting `tree_id`
|
||||
- If schedule create fails: tree save remains successful, show actionable error, preserve draft
|
||||
|
||||
**Existing flow (has schedule):** Collapsed summary:
|
||||
- Format: `"Every Monday at 2:00 AM UTC · 5 targets"`
|
||||
@@ -142,7 +152,7 @@ The Steps section header shows aggregate info:
|
||||
|
||||
- `Steps (4 steps · ~25 min estimated)` — when steps have time estimates
|
||||
- `Steps (4 steps)` — when no time estimates set
|
||||
- `Steps (0 steps)` — empty, triggers the empty state below
|
||||
- `Steps (0 steps)` — empty state (note: store currently enforces minimum one `procedure_step`, so 0-step state only appears if invariant is intentionally changed)
|
||||
|
||||
### Auto-Expand New Steps
|
||||
|
||||
@@ -159,8 +169,8 @@ Same for `addSectionHeader()` — auto-expand for immediate title editing.
|
||||
- Dragged card lifts with `shadow-lg` and slight scale
|
||||
- Drop target: blue insertion line between steps
|
||||
- Section headers are draggable — moving a section header moves it independently (steps below stay in place)
|
||||
- On drop: update the store's step array order and recalculate `display_order` values
|
||||
- Keyboard accessible: focus grip handle, Enter to pick up, arrow keys to move, Enter to drop
|
||||
- On drop: update the store's step array order (array-index based only, no `display_order` recalculation)
|
||||
- Keyboard accessible: focus grip handle, Enter/Space to pick up, arrow keys to move, Enter to drop, Escape to cancel
|
||||
|
||||
**Implementation:**
|
||||
- Wrap step list in `<DndContext>` + `<SortableContext>`
|
||||
@@ -208,11 +218,16 @@ Maintenance flows show `Wrench` icon + "Edit Maintenance Flow" title.
|
||||
| `components/procedural-editor/CollapsibleEditorSection.tsx` | Shared collapsible wrapper with summary display |
|
||||
| `components/procedural-editor/MaintenanceScheduleSection.tsx` | Schedule builder + collapsed summary for maintenance flows |
|
||||
|
||||
### New Dependencies
|
||||
### Existing Dependencies Used
|
||||
|
||||
- `@dnd-kit/core` — drag-and-drop framework
|
||||
- `@dnd-kit/sortable` — sortable preset for ordered lists
|
||||
- `@dnd-kit/utilities` — CSS utilities for transforms
|
||||
- `@dnd-kit/core` — drag-and-drop framework (already installed)
|
||||
- `@dnd-kit/sortable` — sortable preset for ordered lists (already installed)
|
||||
- `@dnd-kit/utilities` — CSS utilities for transforms (already installed)
|
||||
|
||||
### Existing APIs Used
|
||||
|
||||
- `frontend/src/api/maintenanceSchedules.ts` — schedule CRUD via separate endpoints (NOT tree_structure)
|
||||
- `frontend/src/api/targetLists.ts` — target list selection for schedules
|
||||
|
||||
### Unchanged
|
||||
|
||||
|
||||
@@ -31,6 +31,8 @@ interface CollapsibleEditorSectionProps {
|
||||
title: string
|
||||
icon: ReactNode
|
||||
summary: string
|
||||
expanded?: boolean
|
||||
onToggle?: () => void
|
||||
defaultExpanded?: boolean
|
||||
children: ReactNode
|
||||
}
|
||||
@@ -39,28 +41,42 @@ export function CollapsibleEditorSection({
|
||||
title,
|
||||
icon,
|
||||
summary,
|
||||
expanded: controlledExpanded,
|
||||
onToggle,
|
||||
defaultExpanded = false,
|
||||
children,
|
||||
}: CollapsibleEditorSectionProps) {
|
||||
const [expanded, setExpanded] = useState(defaultExpanded)
|
||||
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={() => setExpanded(!expanded)}
|
||||
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',
|
||||
expanded && 'rotate-90'
|
||||
isExpanded && 'rotate-90'
|
||||
)}
|
||||
/>
|
||||
<span className="shrink-0 text-muted-foreground">{icon}</span>
|
||||
<span className="text-sm font-medium text-foreground">{title}</span>
|
||||
{!expanded && (
|
||||
{!isExpanded && (
|
||||
<span className="min-w-0 truncate text-sm text-muted-foreground">
|
||||
— {summary}
|
||||
</span>
|
||||
@@ -68,8 +84,8 @@ export function CollapsibleEditorSection({
|
||||
</button>
|
||||
|
||||
{/* Expanded content */}
|
||||
{expanded && (
|
||||
<div className="px-4 pb-4 pt-1">
|
||||
{isExpanded && (
|
||||
<div id={sectionId} className="px-4 pb-4 pt-1">
|
||||
{children}
|
||||
</div>
|
||||
)}
|
||||
@@ -165,7 +181,7 @@ git commit -m "feat: restructure procedural editor to fixed-height layout with c
|
||||
**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.
|
||||
**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**
|
||||
|
||||
@@ -259,7 +275,7 @@ git commit -m "feat: add empty state, step count header, and auto-scroll to step
|
||||
**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)`.
|
||||
**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**
|
||||
|
||||
@@ -390,7 +406,7 @@ git commit -m "feat: add drag-to-reorder steps with @dnd-kit"
|
||||
**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`.
|
||||
**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`
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import { useState, useId, 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 generatedId = useId()
|
||||
const sectionId = `section-${generatedId}`
|
||||
|
||||
const handleToggle = () => {
|
||||
if (onToggle) {
|
||||
onToggle()
|
||||
} else {
|
||||
setInternalExpanded(!internalExpanded)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="border-b border-border">
|
||||
<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>
|
||||
|
||||
{isExpanded && (
|
||||
<div id={sectionId} className="px-4 pb-4 pt-1">
|
||||
{children}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -6,15 +6,8 @@ export function IntakeFormBuilder() {
|
||||
const { intakeForm, addField, removeField, updateField } = useProceduralEditorStore()
|
||||
|
||||
return (
|
||||
<div className="bg-card border border-border rounded-2xl p-4 sm:p-6">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<FileText className="h-5 w-5 text-muted-foreground" />
|
||||
<h2 className="text-lg font-semibold text-foreground">Intake Form</h2>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
({intakeForm.length} field{intakeForm.length !== 1 ? 's' : ''})
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<div className="mb-3 flex items-center justify-end">
|
||||
<button
|
||||
onClick={addField}
|
||||
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"
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
import { useEffect } from 'react'
|
||||
import { useEffect, useState, useCallback } from 'react'
|
||||
import { useParams, useNavigate, useSearchParams } from 'react-router-dom'
|
||||
import { Save, ArrowLeft, ListOrdered, Wrench } from 'lucide-react'
|
||||
import { Save, ArrowLeft, ListOrdered, Wrench, Settings, FileText } from 'lucide-react'
|
||||
import { treesApi } from '@/api/trees'
|
||||
import { useProceduralEditorStore } from '@/store/proceduralEditorStore'
|
||||
import { CollapsibleEditorSection } from '@/components/procedural-editor/CollapsibleEditorSection'
|
||||
import { IntakeFormBuilder } from '@/components/procedural-editor/IntakeFormBuilder'
|
||||
import { StepList } from '@/components/procedural-editor/StepList'
|
||||
import { TagInput } from '@/components/common/TagInput'
|
||||
import { toast } from '@/lib/toast'
|
||||
import type { TreeType } from '@/types'
|
||||
|
||||
type SectionKey = 'details' | 'intake' | 'schedule'
|
||||
|
||||
export function ProceduralEditorPage() {
|
||||
const { id } = useParams<{ id: string }>()
|
||||
const [searchParams] = useSearchParams()
|
||||
@@ -22,6 +25,7 @@ export function ProceduralEditorPage() {
|
||||
description,
|
||||
tags,
|
||||
isPublic,
|
||||
intakeForm,
|
||||
isDirty,
|
||||
isSaving,
|
||||
isLoading,
|
||||
@@ -40,6 +44,15 @@ export function ProceduralEditorPage() {
|
||||
const isMaintenance = treeType === 'maintenance'
|
||||
const flowLabel = isMaintenance ? 'Maintenance Flow' : 'Procedure'
|
||||
|
||||
// Accordion state: only one section open at a time
|
||||
const [expandedSection, setExpandedSection] = useState<SectionKey | null>(
|
||||
isEditMode ? null : 'details'
|
||||
)
|
||||
|
||||
const toggleSection = useCallback((key: SectionKey) => {
|
||||
setExpandedSection(prev => prev === key ? null : key)
|
||||
}, [])
|
||||
|
||||
// Load tree or init new
|
||||
useEffect(() => {
|
||||
if (isEditMode && id) {
|
||||
@@ -47,6 +60,8 @@ export function ProceduralEditorPage() {
|
||||
} else {
|
||||
const urlType = searchParams.get('type')
|
||||
initNew((urlType === 'maintenance' ? 'maintenance' : 'procedural') as TreeType)
|
||||
// New flows: details expanded, or schedule for new maintenance
|
||||
setExpandedSection(urlType === 'maintenance' ? 'schedule' : 'details')
|
||||
}
|
||||
|
||||
return () => { reset() }
|
||||
@@ -101,6 +116,17 @@ export function ProceduralEditorPage() {
|
||||
}
|
||||
}
|
||||
|
||||
// Summary strings for collapsed sections
|
||||
const detailsSummary = [
|
||||
name ? `"${name}"` : '"Untitled"',
|
||||
tags.length > 0 ? `${tags.length} tag${tags.length !== 1 ? 's' : ''}` : 'No tags',
|
||||
isPublic ? 'Public' : 'Private',
|
||||
].join(' \u00b7 ')
|
||||
|
||||
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 ? ', \u2026' : ''}`
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex min-h-[50vh] items-center justify-center">
|
||||
@@ -110,9 +136,9 @@ export function ProceduralEditorPage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-6 sm:px-6 sm:py-8">
|
||||
{/* Header */}
|
||||
<div className="mb-6 flex items-center justify-between sm:mb-8">
|
||||
<div className="flex h-full flex-col overflow-hidden">
|
||||
{/* Toolbar — sticky */}
|
||||
<div className="flex shrink-0 items-center justify-between border-b border-border bg-card px-4 py-2">
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={() => navigate('/my-trees')}
|
||||
@@ -124,8 +150,9 @@ export function ProceduralEditorPage() {
|
||||
{isMaintenance
|
||||
? <Wrench className="h-5 w-5 text-amber-400" />
|
||||
: <ListOrdered className="h-5 w-5 text-muted-foreground" />}
|
||||
<h1 className="text-xl font-bold text-foreground sm:text-2xl">
|
||||
<h1 className="text-lg font-bold text-foreground">
|
||||
{isEditMode ? `Edit ${flowLabel}` : `New ${flowLabel}`}
|
||||
{name && <span className="ml-2 font-normal text-muted-foreground">— {name}</span>}
|
||||
</h1>
|
||||
</div>
|
||||
</div>
|
||||
@@ -144,7 +171,7 @@ export function ProceduralEditorPage() {
|
||||
<button
|
||||
onClick={() => handleSave('published')}
|
||||
disabled={isSaving}
|
||||
className="flex items-center gap-1.5 rounded-md bg-gradient-brand text-white shadow-lg shadow-primary/20 px-4 py-2 text-sm font-medium hover:opacity-90 disabled:opacity-50"
|
||||
className="flex items-center gap-1.5 rounded-md bg-gradient-brand px-4 py-2 text-sm font-medium text-white shadow-lg shadow-primary/20 hover:opacity-90 disabled:opacity-50"
|
||||
>
|
||||
<Save className="h-4 w-4" />
|
||||
{isSaving ? 'Saving...' : 'Publish'}
|
||||
@@ -152,11 +179,15 @@ export function ProceduralEditorPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="space-y-6">
|
||||
{/* Metadata */}
|
||||
<div className="bg-card border border-border rounded-xl p-4 sm:p-6">
|
||||
<h2 className="mb-4 text-lg font-semibold text-foreground">Details</h2>
|
||||
{/* Collapsible sections */}
|
||||
<div className="shrink-0">
|
||||
<CollapsibleEditorSection
|
||||
title="Details"
|
||||
icon={<Settings className="h-4 w-4" />}
|
||||
summary={detailsSummary}
|
||||
expanded={expandedSection === 'details'}
|
||||
onToggle={() => toggleSection('details')}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-medium text-muted-foreground">Name</label>
|
||||
@@ -199,12 +230,21 @@ export function ProceduralEditorPage() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CollapsibleEditorSection>
|
||||
|
||||
{/* Intake Form Builder */}
|
||||
<IntakeFormBuilder />
|
||||
<CollapsibleEditorSection
|
||||
title="Intake Form"
|
||||
icon={<FileText className="h-4 w-4" />}
|
||||
summary={intakeSummary}
|
||||
expanded={expandedSection === 'intake'}
|
||||
onToggle={() => toggleSection('intake')}
|
||||
>
|
||||
<IntakeFormBuilder />
|
||||
</CollapsibleEditorSection>
|
||||
</div>
|
||||
|
||||
{/* Step List */}
|
||||
{/* Step List — flex-1, scrolls independently */}
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-4 py-4">
|
||||
<StepList />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user