feat: procedural editor redesign with collapsible sections and DnD #84

Merged
chihlasm merged 13 commits from feat/procedural-editor-redesign into main 2026-02-19 13:39:25 +00:00
15 changed files with 2217 additions and 157 deletions

View File

@@ -72,6 +72,9 @@ class Settings(BaseSettings):
"""Check if Stripe is configured."""
return self.STRIPE_SECRET_KEY is not None and self.STRIPE_WEBHOOK_SECRET is not None
# Deployment auto-seed test data on PR environments
SEED_ON_DEPLOY: bool = False
# CORS - set FRONTEND_URL in production (e.g., https://patherly.up.railway.app)
CORS_ORIGINS: list[str] = ["http://localhost:3000", "http://localhost:5173", "http://localhost:5174"]
FRONTEND_URL: Optional[str] = None

View File

@@ -1,4 +1,6 @@
import asyncio
import logging
import os
from contextlib import asynccontextmanager
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
@@ -18,6 +20,62 @@ setup_logging()
logger = logging.getLogger(__name__)
def _configure_seed_module(mod: object, api_url: str, email: str, password: str) -> None:
"""Set globals on a seed script module."""
mod.API_BASE_URL = api_url # type: ignore[attr-defined]
mod.ADMIN_EMAIL = email # type: ignore[attr-defined]
mod.ADMIN_PASSWORD = password # type: ignore[attr-defined]
async def _seed_trees_background() -> None:
"""Background task: seed all flows via HTTP API after server is ready."""
await asyncio.sleep(5) # Wait for server to be fully ready
port = os.environ.get("PORT", "8000")
api_url = f"http://127.0.0.1:{port}/api/v1"
email = "admin@resolutionflow.example.com"
password = "TestPass123!"
try:
import httpx
# Login to verify admin user exists
async with httpx.AsyncClient(base_url=api_url, timeout=30) as client:
login_resp = await client.post("/auth/login/json", json={"email": email, "password": password})
if login_resp.status_code != 200:
logger.warning("[seed] Could not login as admin — skipping flow seeding")
return
token = login_resp.json()["access_token"]
# Check if trees already exist
trees_resp = await client.get("/trees", headers={"Authorization": f"Bearer {token}"})
if trees_resp.status_code == 200 and len(trees_resp.json()) > 0:
logger.info(f"[seed] {len(trees_resp.json())} flows already exist — skipping flow seeding")
return
# No flows yet — run all seed scripts
seed_scripts = [
("seed_trees (Tier 1)", "scripts.seed_trees", "seed_database"),
("seed_trees_v2 (AD/M365/Networking)", "scripts.seed_trees_v2", "seed_database"),
("seed_procedural_flows", "scripts.seed_procedural_flows", "seed_procedural_flows"),
("seed_maintenance_flows", "scripts.seed_maintenance_flows", "seed_maintenance_flows"),
]
for label, module_path, func_name in seed_scripts:
try:
import importlib
mod = importlib.import_module(module_path)
_configure_seed_module(mod, api_url, email, password)
seed_fn = getattr(mod, func_name)
logger.info(f"[seed] Running {label}...")
await seed_fn()
logger.info(f"[seed] {label} complete!")
except Exception as e:
logger.warning(f"[seed] {label} failed (non-fatal): {e}")
logger.info("[seed] All flow seeding complete!")
except Exception as e:
logger.warning(f"[seed] Flow seeding failed (non-fatal): {e}")
@asynccontextmanager
async def lifespan(app: FastAPI):
"""Application lifespan handler."""
@@ -33,9 +91,17 @@ async def lifespan(app: FastAPI):
async with async_session_maker() as db:
await load_all_schedules(db)
# Auto-seed trees in background on PR environments
seed_task = None
if settings.SEED_ON_DEPLOY:
logger.info("[seed] SEED_ON_DEPLOY=true — scheduling background tree seeding")
seed_task = asyncio.create_task(_seed_trees_background())
yield
# Shutdown
if seed_task and not seed_task.done():
seed_task.cancel()
scheduler.shutdown(wait=False)
logger.info("Shutting down ResolutionFlow API server...")

View File

@@ -7,4 +7,4 @@ healthcheckPath = "/health"
healthcheckTimeout = 100
restartPolicyType = "on_failure"
restartPolicyMaxRetries = 3
releaseCommand = "alembic upgrade head"
releaseCommand = "python -m scripts.release"

View File

@@ -28,6 +28,9 @@ stripe==14.3.0
# Email
resend==2.21.0
# HTTP client (seed scripts, internal API calls)
httpx>=0.27.0
# Utilities
python-dotenv==1.0.1
croniter>=2.0.0

View File

@@ -0,0 +1,53 @@
#!/usr/bin/env python3
"""
Railway release command — runs migrations + optional seed data.
Set SEED_ON_DEPLOY=true in Railway env vars to auto-seed test users
on PR environments. Seeding is idempotent (skips existing records).
Usage (called by railway.toml releaseCommand):
python -m scripts.release
"""
import asyncio
import subprocess
import sys
from app.core.config import settings
def run_migrations() -> None:
"""Run alembic upgrade head."""
print("\n[release] Running database migrations...")
result = subprocess.run(
["alembic", "upgrade", "head"],
capture_output=False,
)
if result.returncode != 0:
print("[release] ERROR: Migrations failed!")
sys.exit(1)
print("[release] Migrations complete.")
async def seed_test_data() -> None:
"""Seed test users (direct DB, no HTTP needed)."""
print("\n[release] Seeding test users...")
from scripts.seed_test_users import main as seed_users
await seed_users()
print("[release] Test users seeded.")
def main() -> None:
run_migrations()
if settings.SEED_ON_DEPLOY:
print("[release] SEED_ON_DEPLOY=true — seeding test data...")
asyncio.run(seed_test_data())
else:
print("[release] SEED_ON_DEPLOY not set — skipping seed data.")
print("\n[release] Release complete!")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,155 @@
# Procedural & Maintenance Editor Redesign - Design Revisions
> **Date:** 2026-02-19
> **Revises:** `docs/plans/2026-02-19-procedural-editor-redesign-design.md`
> **Purpose:** Resolve implementation gaps and make the design decision-complete for engineering handoff
## Summary
This revision tightens the original design to match current architecture and APIs.
It resolves contradictions around maintenance schedule persistence, aligns step-list behavior with store invariants, and defines concrete DnD/accessibility/test expectations.
## Revision Decisions (Locked)
1. Keep fixed-height editor layout with independent StepList scrolling.
2. Use collapsible sections for Details, Intake Form, and Maintenance Schedule.
3. Use existing installed `@dnd-kit/*` packages (no new dependency work).
4. Keep `procedure_end` as non-draggable and always last.
5. Keep schedule as optional for maintenance flows (manual batch launch remains valid).
## Critical Corrections to Original Design
1. **Maintenance schedule persistence**
- Schedule is not embedded in `tree_structure`.
- Schedule must be persisted through maintenance schedule endpoints.
- New unsaved flow requires two-stage save:
1. Save tree (`treesApi.create` / `treesApi.update`)
2. Create/update schedule (`maintenanceSchedulesApi.create` or `maintenanceSchedulesApi.update`)
- One schedule per tree (`uq_maintenance_schedules_tree_id`).
2. **Step empty-state semantics**
- Original "0 steps" state conflicts with current store minimum step behavior.
- Revised behavior:
- If minimum-one-step invariant remains, remove "0 steps" UX language.
- Empty-state is only shown if invariant is intentionally changed in store.
3. **DnD data model correction**
- Do not mention recalculating `display_order` for procedural steps.
- Reorder is array-index based only.
- Explicitly block dragging `procedure_end`.
4. **Dependencies section correction**
- Replace "New Dependencies" with "Existing Dependencies Used" for `@dnd-kit/core`, `@dnd-kit/sortable`, `@dnd-kit/utilities`.
5. **File impact correction**
- Add API integration surfaces:
- `frontend/src/api/maintenanceSchedules.ts`
- target-list integration file(s) if inline list creation is in scope
- Clarify interaction with `frontend/src/pages/MaintenanceFlowDetailPage.tsx` (retain schedule view/edit there or shift entirely to editor).
6. **Collapsible behavior clarification**
- Use single-open accordion mode by default.
- Details defaults expanded for new flows.
- Intake defaults collapsed.
- Maintenance Schedule defaults:
- new maintenance flow: expanded
- existing with schedule: collapsed summary
- existing without schedule: collapsed with "Set Up" affordance
7. **A11y + keyboard DnD acceptance**
- Include keyboard reorder acceptance criteria.
- Include focus order and ARIA labeling criteria for section toggles and drag handles.
## Revised Maintenance Schedule Section Spec
### New maintenance flow (tree not yet saved)
1. Render schedule editor expanded.
2. Keep schedule input in local UI draft state.
3. On Save Draft / Publish:
- Save tree first.
- If tree save succeeds, create schedule with resulting `tree_id`.
4. If schedule create fails:
- Tree save remains successful.
- Show actionable error ("Schedule not saved. Retry.").
- Preserve schedule draft state.
### Existing maintenance flow
1. Load schedule via `maintenanceSchedulesApi.getForTree(treeId)`.
2. Edit and persist via `maintenanceSchedulesApi.update(scheduleId, data)`.
3. If no schedule exists, show collapsed "No schedule configured" summary + setup button.
## Revised StepList Reorder Spec
1. Draggable types:
- `procedure_step`
- `section_header`
2. Non-draggable:
- `procedure_end`
3. Reorder behavior:
- Move item by array index.
- Preserve all step payload fields.
- No implicit grouped movement under section headers.
4. Keyboard behavior:
- Drag handle focusable.
- Enter/Space pick up + drop.
- Arrow keys move while grabbed.
- Escape cancels.
## Acceptance Criteria
1. **Layout**
- Toolbar remains visible while StepList scrolls.
- Details/Intake/Schedule sections collapse without shrinking StepList usability.
2. **Steps**
- New step auto-expands.
- New step scrolls into view.
- Reorder works by pointer and keyboard.
- `procedure_end` remains last and fixed.
3. **Maintenance schedule**
- New unsaved maintenance flow can be saved without schedule.
- Schedule can be created immediately after first tree save.
- Existing schedule loads and updates in editor.
- Schedule failure does not roll back successful tree save.
4. **Accessibility**
- Section toggles keyboard operable.
- Drag handles have accessible labels.
- Focus remains stable after reorder.
5. **Build/Test**
- `npm run build` succeeds.
- Affected component/store tests pass.
## Updated File Impact
### Modified
- `frontend/src/pages/ProceduralEditorPage.tsx`
- `frontend/src/components/procedural-editor/StepList.tsx`
- `frontend/src/components/procedural-editor/IntakeFormBuilder.tsx`
- `frontend/src/store/proceduralEditorStore.ts`
- `frontend/src/api/maintenanceSchedules.ts`
- `frontend/src/pages/MaintenanceFlowDetailPage.tsx` (if schedule UX ownership changes)
### New
- `frontend/src/components/procedural-editor/CollapsibleEditorSection.tsx`
- `frontend/src/components/procedural-editor/MaintenanceScheduleSection.tsx`
### Existing dependencies used
- `@dnd-kit/core`
- `@dnd-kit/sortable`
- `@dnd-kit/utilities`
## Out of Scope (unchanged)
1. Intake field DnD reorder.
2. Procedural undo/redo parity.
3. Step templates/presets.
4. Bulk step operations.
5. Backend schema/model changes for procedural steps.

View File

@@ -0,0 +1,247 @@
# Procedural & Maintenance Editor Redesign — Design
> **Date:** 2026-02-19
> **Scope:** Restructure the procedural/maintenance flow editor for better space utilization, collapsible sections, drag-to-reorder steps, and maintenance-specific schedule management
## Overview
The current procedural editor (`ProceduralEditorPage.tsx`) uses a scrolling document layout where Details, Intake Form, and Steps stack vertically. The Details and Intake Form sections consume significant screen space, pushing the step list — the core editing surface — to the bottom. This redesign converts the page to a fixed-height editor with collapsible sections and drag-to-reorder steps.
## Problems Solved
1. **Steps buried at the bottom** — Details and Intake Form sections are "screen space goblins" that push the step list down, especially on smaller screens
2. **No drag reorder** — grip handles are visible but non-functional; reordering requires deleting and re-creating steps
3. **Adding steps is tedious** — new steps append at the bottom but don't auto-expand, requiring an extra click
4. **No overview at a glance** — collapsed sections don't show useful summaries; users expand just to check what's there
5. **Maintenance flows lack inline schedule management** — schedule/targets are only configurable on the detail page, not during initial creation
## Layout Architecture
### Current Layout (Scrolling Document)
```
┌─────────────────────────────────────┐
│ Header (back, title, save, publish) │ ← scrolls with page
├─────────────────────────────────────┤
│ Details Card (~200px) │ ← always expanded
│ Name, Description, Tags, Public │
├─────────────────────────────────────┤
│ Intake Form Card (~150-400px) │ ← always expanded
│ Field editors... │
├─────────────────────────────────────┤
│ Steps Card (whatever's left) │ ← pushed to bottom
│ Step list... │
└─────────────────────────────────────┘
↕ entire page scrolls
```
### New Layout (Fixed-Height Editor)
```
┌─────────────────────────────────────┐
│ Toolbar (sticky) │ ← fixed, never scrolls
├─────────────────────────────────────┤
│ ▶ Details: "DC Build" · 3 tags · … │ ← collapsed one-liner
│ ▶ Intake Form: 3 fields: Host, … │ ← collapsed one-liner
│ ▶ Schedule: Mon 2:00 AM · 5 targets│ ← maintenance only
├─────────────────────────────────────┤
│ │
│ Steps (flex-1, scrolls alone) │ ← gets all remaining height
│ ┌─ 1. Check prerequisites ───────┐│
│ ├─ 2. Install AD DS role ────────┤│
│ ├─ 3. Promote to DC ────────────┤│
│ ├─ 4. Verify replication ───────┤│
│ └─ + Add Step ───────────────────┘│
│ │
└─────────────────────────────────────┘
```
### Key Layout Changes
- **Page:** `container mx-auto` scrolling → `flex flex-col h-full overflow-hidden`
- **Toolbar:** Scrolls with page → sticky at top (matches troubleshooting editor pattern)
- **Sections:** Always-expanded cards → collapsible one-liners with rich summaries
- **Step list:** Stacked in scroll flow → `flex-1 overflow-y-auto` (independent scrolling)
## Collapsible Sections
### Shared Wrapper: `CollapsibleEditorSection`
A reusable component used by Details, Intake Form, and Maintenance Schedule.
**Props:**
- `title` — section label ("Details", "Intake Form", "Schedule")
- `icon` — Lucide icon component
- `summary` — rich one-line summary string shown when collapsed
- `defaultExpanded` — whether to start expanded (default: `false`)
- `children` — expanded content
- `onEdit` — optional callback for the Edit button (alternative to expanding)
**Collapsed state:** Single row with icon, title, summary text, and expand chevron. Entire row is clickable.
**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`
Examples:
- `"Domain Controller Build" · 3 tags · Public`
- `"New Procedure" · No tags · Private` (new flow, default expanded)
**New flow behavior:** Details section starts **expanded** when creating a new flow (name is required), collapses after the user provides a name and clicks away or expands another section.
### Intake Form Section — Collapsed Summary
Format: `N fields: Field1, Field2, Field3` (field names truncated if many)
Examples:
- `3 fields: Hostname, Domain Name, IP Address`
- `6 fields: Hostname, Domain, IP, DNS, Gateway, …` (truncated with ellipsis)
- `No fields defined` (empty state)
### Maintenance Schedule Section
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 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"`
- Expand to modify schedule and targets
**Existing flow (no schedule yet):** Shows collapsed with summary `"No schedule configured"` + "Set Up" button that expands the section.
## Step List Improvements
### Empty State
When the step list has 0 steps, show a centered empty state instead of a blank area:
```
┌──────────────────────────┐
│ 📋 │
│ Add your first step │
│ │
│ Steps define the actions │
│ engineers follow during │
│ this procedure. │
│ │
│ [+ Add Step] [+ Section]│
└──────────────────────────┘
```
When 1-2 steps exist, the list renders normally — no special treatment needed since the steps themselves fill the space adequately.
### Step Count + Time in Header
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 state (note: store currently enforces minimum one `procedure_step`, so 0-step state only appears if invariant is intentionally changed)
### Auto-Expand New Steps
When `addStep()` is called, the new step is automatically expanded (`setExpandedStepId(newStep.id)`) and the step list scrolls to the bottom to show it. This eliminates the extra click to start editing.
Same for `addSectionHeader()` — auto-expand for immediate title editing.
### Drag-to-Reorder
**Library:** `@dnd-kit/core` + `@dnd-kit/sortable`
**Behavior:**
- Drag via the `GripVertical` handle on each step card
- 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 (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>`
- Each step card wrapped in `useSortable()` hook
- Drag overlay shows a simplified card (just step number + title)
- `onDragEnd` handler reorders the `steps` array in the procedural editor store
### Collapsed Step Cards
Current cards are already compact. Minor tightening:
- Step number badge + content type icon + title + time estimate + chevron + delete (on hover)
- No changes to the collapsed card layout — it's already well-designed
## Toolbar
Matches the troubleshooting editor's toolbar pattern:
```
┌─────────────────────────────────────────────────────────────┐
│ ← Back Edit Procedure — DC Build [Unsaved] │ Save │ Publish │
└─────────────────────────────────────────────────────────────┘
```
- **Left:** Back button (→ `/my-trees`), flow type icon, title with flow name
- **Right:** Unsaved indicator, Save Draft button, Publish button (gradient)
- **Sticky:** `sticky top-0 z-10` within the editor area (not the app shell)
Maintenance flows show `Wrench` icon + "Edit Maintenance Flow" title.
## File Changes
### Modified Files
| File | Changes |
|------|---------|
| `ProceduralEditorPage.tsx` | Layout restructure: scrolling → fixed-height, collapsible sections, toolbar refactor |
| `StepList.tsx` | Drag reorder with @dnd-kit, auto-expand on add, empty state, step count header |
| `IntakeFormBuilder.tsx` | Wrap in collapsible section with field-name summary |
| `proceduralEditorStore.ts` | `reorderSteps(fromIndex, toIndex)` action, auto-expand on add |
### New Files
| File | Purpose |
|------|---------|
| `components/procedural-editor/CollapsibleEditorSection.tsx` | Shared collapsible wrapper with summary display |
| `components/procedural-editor/MaintenanceScheduleSection.tsx` | Schedule builder + collapsed summary for maintenance flows |
### Existing Dependencies Used
- `@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
- `StepEditor.tsx` — inline step editing form, no changes
- `IntakeFieldEditor.tsx` — field editor, no changes
- `proceduralEditorStore.ts` steps/intakeForm data model — no schema changes
- Backend — no API changes needed
- Troubleshooting tree editor — completely separate, unaffected
## Not Included (YAGNI)
- No drag-to-reorder intake form fields (low value, fields rarely reordered)
- No inline cron expression text input (use friendly frequency picker instead)
- No step templates or presets
- No bulk step operations (select multiple, delete, move)
- No step preview/dry-run from the editor
- No undo/redo for the procedural editor (separate effort)

View File

@@ -0,0 +1,225 @@
# Procedural Editor Redesign - Implementation Revisions
> **Date:** 2026-02-19
> **Revises:** `docs/plans/2026-02-19-procedural-editor-redesign-impl.md`
> **Related Design Revision:** `docs/plans/2026-02-19-procedural-editor-redesign-design-revisions.md`
## Goal
Revise the implementation plan so it matches actual architecture and APIs, with explicit handling for maintenance schedule persistence, step-list invariants, DnD constraints, and accessibility/test requirements.
## Critical Corrections from Original Impl Plan
1. Do not treat maintenance schedule as part of `tree_structure`.
2. Do not use `display_order` for procedural step reorder.
3. Do not assume `0 steps` state unless store invariant is changed intentionally.
4. Do not list `@dnd-kit/*` as new dependency (already installed).
5. Add explicit save orchestration for unsaved maintenance flows.
6. Add explicit failure handling when tree save succeeds but schedule save fails.
## Phase 0: Scope Lock
### Task 0.1 - Confirm invariants and UX ownership
**Decisions to lock in code before implementation:**
1. `procedure_end` remains fixed, non-draggable, last.
2. Minimum one `procedure_step` remains enforced (recommended).
3. Schedule editing in editor is source-of-truth for create/edit, with detail page as display/secondary entrypoint.
**Files:** none (decision checkpoint)
---
## Phase 1: Layout and Collapsible Sections
### Task 1.1 - Add shared collapsible wrapper
**Files:**
- Create: `frontend/src/components/procedural-editor/CollapsibleEditorSection.tsx`
**Requirements:**
1. Single-row collapsed summary.
2. Keyboard-accessible toggle button.
3. `aria-expanded`, `aria-controls`, and section `id`.
4. Optional `defaultExpanded`.
### Task 1.2 - Convert ProceduralEditorPage to fixed-height editor
**Files:**
- Modify: `frontend/src/pages/ProceduralEditorPage.tsx`
**Changes:**
1. Outer layout becomes `flex h-full flex-col overflow-hidden`.
2. Toolbar becomes sticky.
3. Details and Intake wrapped in `CollapsibleEditorSection`.
4. Steps area becomes `flex-1 min-h-0 overflow-y-auto`.
5. Accordion mode: only one section open at a time (explicit state in page component).
**Summaries:**
1. Details: `"Name" - N tags - Public/Private`.
2. Intake: `N fields: label1, label2...` (truncate).
---
## Phase 2: StepList Behavior and DnD
### Task 2.1 - Align header/empty behavior with current store invariant
**Files:**
- Modify: `frontend/src/components/procedural-editor/StepList.tsx`
- Optional invariant change (if desired): `frontend/src/store/proceduralEditorStore.ts`
**Required behavior (recommended):**
1. Keep minimum one `procedure_step`.
2. Remove/unset any `0 steps` UI paths.
3. Header shows:
- `Steps (N steps - ~M min)` when estimates exist
- `Steps (N steps)` otherwise.
### Task 2.2 - Ensure new step auto-expands + scrolls into view
**Files:**
- Modify: `frontend/src/components/procedural-editor/StepList.tsx`
- Verify existing store behavior in `frontend/src/store/proceduralEditorStore.ts`
**Behavior:**
1. On add step/section, expanded editor opens immediately.
2. Newly inserted row is scrolled into view via stable element refs (prefer scroll target by id over "scroll to bottom").
### Task 2.3 - Implement DnD with current model constraints
**Files:**
- Modify: `frontend/src/components/procedural-editor/StepList.tsx`
- Modify: `frontend/src/store/proceduralEditorStore.ts` (reuse `moveStep`)
**Rules:**
1. Draggable: `procedure_step`, `section_header`.
2. Non-draggable: `procedure_end`.
3. Reorder by array index only.
4. No `display_order` recalculation for steps.
5. Keyboard drag support and visible insertion indicator.
---
## Phase 3: Maintenance Schedule Section (Correct API orchestration)
### Task 3.1 - Add schedule section component
**Files:**
- Create: `frontend/src/components/procedural-editor/MaintenanceScheduleSection.tsx`
- Modify: `frontend/src/pages/ProceduralEditorPage.tsx`
**Behavior:**
1. Render only for `treeType === 'maintenance'`.
2. Capture:
- cron expression
- timezone
- target list id
3. Collapsed summary:
- configured: human-readable cadence + target list status
- unconfigured: `No schedule configured`.
### Task 3.2 - Add schedule draft UI state and save orchestration
**Files:**
- Modify: `frontend/src/store/proceduralEditorStore.ts` (UI draft state only)
- Modify: `frontend/src/pages/ProceduralEditorPage.tsx`
- Use: `frontend/src/api/maintenanceSchedules.ts`
**Save flow:**
1. Save tree first (`create`/`update`).
2. If maintenance and schedule draft present:
- if existing schedule id: `maintenanceSchedulesApi.update`
- else: `maintenanceSchedulesApi.create` with saved tree id.
3. If schedule save fails:
- keep tree save success
- show actionable error toast/banner
- preserve schedule draft as dirty.
### Task 3.3 - Existing flow load
**Files:**
- Modify: `frontend/src/pages/ProceduralEditorPage.tsx`
**Behavior:**
1. On edit maintenance flow, fetch schedule via `getForTree(treeId)`.
2. 404 = no schedule yet (valid state).
3. Hydrate schedule draft state for section UI.
---
## Phase 4: Integration polish and consistency
### Task 4.1 - Clarify MaintenanceFlowDetailPage role
**Files:**
- Modify (if needed): `frontend/src/pages/MaintenanceFlowDetailPage.tsx`
**Decision implementation:**
1. Keep schedule read-only there, with "Edit in Flow Editor" CTA.
2. Avoid split-brain schedule edits in two places unless explicitly desired.
---
## Phase 5: Tests and verification
### Task 5.1 - Automated tests
**Files (new/updated):**
- `frontend/src/components/procedural-editor/StepList.test.tsx`
- `frontend/src/pages/ProceduralEditorPage.test.tsx`
- `frontend/src/store/proceduralEditorStore.test.ts` (if absent, add focused tests)
**Minimum coverage:**
1. Reorder respects `procedure_end` constraints.
2. New steps auto-expand and scroll target call occurs.
3. Accordion open/close state and summaries.
4. Maintenance save orchestration:
- tree create/update then schedule create/update
- schedule failure does not revert tree success.
### Task 5.2 - Manual acceptance checklist
1. Steps list remains primary viewport focus in fixed-height layout.
2. Details/Intake/Schedule sections collapse and summarize correctly.
3. DnD works by mouse and keyboard.
4. End step never drags.
5. New maintenance flow:
- can save draft without schedule
- can save with schedule in one action (tree first, schedule second).
6. Existing maintenance flow loads schedule and saves edits.
### Task 5.3 - Build and lint gates
1. `cd frontend && npm run build`
2. `cd frontend && npm run test`
3. `cd frontend && npm run lint`
---
## File Impact (Revised)
### Create
1. `frontend/src/components/procedural-editor/CollapsibleEditorSection.tsx`
2. `frontend/src/components/procedural-editor/MaintenanceScheduleSection.tsx`
### Modify
1. `frontend/src/pages/ProceduralEditorPage.tsx`
2. `frontend/src/components/procedural-editor/StepList.tsx`
3. `frontend/src/components/procedural-editor/IntakeFormBuilder.tsx`
4. `frontend/src/store/proceduralEditorStore.ts`
5. `frontend/src/pages/MaintenanceFlowDetailPage.tsx` (if ownership adjusted)
### Existing APIs used
1. `frontend/src/api/maintenanceSchedules.ts`
2. target list API module(s) if inline list selection/creation is implemented
---
## Out of Scope (unchanged)
1. Intake field DnD reorder.
2. Procedural undo/redo parity.
3. Step templates/presets.
4. Bulk step operations.
5. Backend schema/model changes for procedural steps.

View File

@@ -0,0 +1,811 @@
# 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 (
<div className="border-b border-border">
{/* Collapsed header — always visible */}
<button
type="button"
onClick={handleToggle}
aria-expanded={isExpanded}
aria-controls={sectionId}
className="flex w-full items-center gap-3 px-4 py-2.5 text-left transition-colors hover:bg-accent/50"
>
<ChevronRight
className={cn(
'h-4 w-4 shrink-0 text-muted-foreground transition-transform duration-200',
isExpanded && 'rotate-90'
)}
/>
<span className="shrink-0 text-muted-foreground">{icon}</span>
<span className="text-sm font-medium text-foreground">{title}</span>
{!isExpanded && (
<span className="min-w-0 truncate text-sm text-muted-foreground">
{summary}
</span>
)}
</button>
{/* Expanded content */}
{isExpanded && (
<div id={sectionId} className="px-4 pb-4 pt-1">
{children}
</div>
)}
</div>
)
}
```
**Step 2: Verify it builds**
Run: `cd frontend && npm run build 2>&1 | tail -5`
Expected: `built in` success message (component is created but not imported anywhere yet)
**Step 3: Commit**
```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) auto-scroll to new steps. Note: the store enforces minimum one `procedure_step`, so remove any "0 steps" UI paths. The step list's outer card wrapper should be removed since ProceduralEditorPage now provides the scrolling container — StepList should just render its header and step items directly.
**Step 1: Read the current file**
Read: `frontend/src/components/procedural-editor/StepList.tsx`
**Step 2: Modify the header and add empty state**
Remove the outer `<div className="bg-card border border-border rounded-2xl p-4 sm:p-6">` card wrapper. The step list now renders directly in the scrolling container.
Update the header to show step count + total estimated time:
```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)`. 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 `<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. 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<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.

View File

@@ -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>
)
}

View File

@@ -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"

View File

@@ -0,0 +1,241 @@
import { useState, useEffect } from 'react'
import { 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
onScheduleLoaded?: (schedule: MaintenanceSchedule | null, targetList: TargetList | null) => void
}
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, onScheduleLoaded }: MaintenanceScheduleSectionProps) {
const [schedule, setSchedule] = useState<MaintenanceSchedule | null>(null)
const [targetLists, setTargetLists] = useState<TargetList[]>([])
const [isLoading, setIsLoading] = useState(false)
const [isSaving, setIsSaving] = useState(false)
// Draft form state (local UI only — not in store)
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)
// Hydrate form from existing schedule
hydrateFromSchedule(existing)
if (existing.target_list_id) {
setSelectedTargetListId(existing.target_list_id)
const tl = lists.find(l => l.id === existing.target_list_id) || null
onScheduleLoaded?.(existing, tl)
} else {
onScheduleLoaded?.(existing, null)
}
} catch {
// 404 = no schedule yet (valid state)
onScheduleLoaded?.(null, null)
}
}
} catch {
// Target lists may not load — non-critical
} finally {
setIsLoading(false)
}
}
load()
}, [treeId, onScheduleLoaded])
const hydrateFromSchedule = (s: MaintenanceSchedule) => {
const parts = s.cron_expression.split(' ')
const min = parseInt(parts[0] || '0', 10)
const hr = parseInt(parts[1] || '9', 10)
setMinute(min)
setHour(hr)
setTimezone(s.timezone)
// Detect frequency from cron
const dayOfMonth = parts[2]
const dayOfWeek = parts[4]
if (dayOfMonth === '1') {
setFrequency('monthly')
} else if (dayOfWeek === '*') {
setFrequency('daily')
} else if (dayOfWeek === '5') {
setFrequency('weekly-fri')
} else {
setFrequency('weekly-mon')
}
}
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)
const tl = targetLists.find(l => l.id === selectedTargetListId) || null
onScheduleLoaded?.(updated, tl)
toast.success('Schedule updated')
} else {
const created = await maintenanceSchedulesApi.create({
tree_id: treeId,
cron_expression: cronExpression,
timezone,
target_list_id: selectedTargetListId || undefined,
})
setSchedule(created)
const tl = targetLists.find(l => l.id === selectedTargetListId) || null
onScheduleLoaded?.(created, tl)
toast.success('Schedule created')
}
} catch {
toast.error('Failed to save schedule. The flow was saved successfully — you can retry the schedule.')
} finally {
setIsSaving(false)
}
}
if (isLoading) {
return (
<div className="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'
: 'cursor-not-allowed border border-border bg-card text-muted-foreground 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>
)
}

View File

@@ -1,4 +1,9 @@
import { useRef, useEffect, useCallback, type ReactNode } from 'react'
import { Plus, GripVertical, Trash2, ChevronDown, CheckCircle2, AlertTriangle, Info, Zap, Shield, SeparatorHorizontal } from 'lucide-react'
import { DndContext, closestCenter, KeyboardSensor, PointerSensor, useSensor, useSensors } from '@dnd-kit/core'
import type { DragEndEvent } from '@dnd-kit/core'
import { SortableContext, sortableKeyboardCoordinates, verticalListSortingStrategy, useSortable } from '@dnd-kit/sortable'
import { CSS } from '@dnd-kit/utilities'
import type { StepContentType } from '@/types'
import { StepEditor } from './StepEditor'
import { useProceduralEditorStore } from '@/store/proceduralEditorStore'
@@ -11,6 +16,29 @@ const contentTypeConfig: Record<StepContentType, { icon: typeof Zap; color: stri
warning: { icon: AlertTriangle, color: 'text-yellow-400', label: 'Warning' },
}
function SortableStepWrapper({
id,
disabled,
children,
}: {
id: string
disabled?: boolean
children: (props: { dragHandleProps: Record<string, unknown> }) => ReactNode
}) {
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 && 'z-10 opacity-50')}>
{children({ dragHandleProps: { ...attributes, ...listeners } })}
</div>
)
}
export function StepList() {
const {
steps,
@@ -21,19 +49,62 @@ export function StepList() {
addSectionHeader,
removeStep,
updateStep,
moveStep,
} = useProceduralEditorStore()
const procedureSteps = steps.filter((s) => s.type === 'procedure_step')
const totalMinutes = steps
.filter(s => s.type === 'procedure_step' && s.estimated_minutes)
.reduce((sum, s) => sum + (s.estimated_minutes || 0), 0)
// Auto-scroll to new steps
const scrollTargetRef = useRef<HTMLDivElement>(null)
const prevStepCount = useRef(steps.length)
useEffect(() => {
if (steps.length > prevStepCount.current) {
setTimeout(() => {
scrollTargetRef.current?.scrollIntoView({ behavior: 'smooth', block: 'nearest' })
}, 50)
}
prevStepCount.current = steps.length
}, [steps.length])
// DnD setup
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])
// Sortable items: everything except procedure_end
const sortableItems = steps.filter(s => s.type !== 'procedure_end').map(s => s.id)
let stepCounter = 0
return (
<div className="bg-card border border-border rounded-2xl p-4 sm:p-6">
<div>
<div className="mb-4 flex items-center justify-between">
<div className="flex items-center gap-2">
<Shield className="h-5 w-5 text-muted-foreground" />
<h2 className="text-lg font-semibold text-foreground">Steps</h2>
<span className="text-sm text-muted-foreground">
({procedureSteps.length} step{procedureSteps.length !== 1 ? 's' : ''})
({procedureSteps.length} step{procedureSteps.length !== 1 ? 's' : ''}
{totalMinutes > 0 ? ` \u00b7 ~${totalMinutes} min` : ''})
</span>
</div>
<div className="flex items-center gap-2">
@@ -54,138 +125,168 @@ export function StepList() {
</div>
</div>
<div className="space-y-2">
{steps.map((step) => {
if (step.type === 'procedure_end') {
return (
<div
key={step.id}
className="flex items-center gap-2 rounded-lg border border-dashed border-border bg-accent/50 px-3 py-2"
>
<CheckCircle2 className="h-4 w-4 text-emerald-400/50" />
<input
type="text"
value={step.title}
onChange={(e) => updateStep(step.id, { title: e.target.value })}
className="flex-1 bg-transparent text-sm text-muted-foreground focus:outline-none"
placeholder="Procedure Complete"
/>
<span className="text-[10px] text-muted-foreground">END</span>
</div>
)
}
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
<SortableContext items={sortableItems} strategy={verticalListSortingStrategy}>
<div className="space-y-2">
{steps.map((step) => {
if (step.type === 'procedure_end') {
// procedure_end: non-draggable, always last
return (
<div
key={step.id}
className="flex items-center gap-2 rounded-lg border border-dashed border-border bg-accent/50 px-3 py-2"
>
<CheckCircle2 className="h-4 w-4 text-emerald-400/50" />
<input
type="text"
value={step.title}
onChange={(e) => updateStep(step.id, { title: e.target.value })}
className="flex-1 bg-transparent text-sm text-muted-foreground focus:outline-none"
placeholder="Procedure Complete"
/>
<span className="text-[10px] text-muted-foreground">END</span>
</div>
)
}
// Section header rendering
if (step.type === 'section_header') {
const isExpanded = expandedStepId === step.id
// Section header rendering
if (step.type === 'section_header') {
const isExpanded = expandedStepId === step.id
if (isExpanded) {
return (
<SortableStepWrapper key={step.id} id={step.id} disabled>
{() => (
<div ref={scrollTargetRef}>
<StepEditor
step={step}
stepNumber={0}
onUpdate={(updates) => updateStep(step.id, updates)}
onCollapse={() => setExpandedStepId(null)}
availableVariables={intakeForm}
/>
</div>
)}
</SortableStepWrapper>
)
}
return (
<SortableStepWrapper key={step.id} id={step.id}>
{({ dragHandleProps }) => (
<div className="group flex items-center gap-2 border-b border-border pb-1 pt-3">
<button
type="button"
className="shrink-0 cursor-grab touch-none text-muted-foreground active:cursor-grabbing"
aria-label={`Drag to reorder section: ${step.title || 'Untitled Section'}`}
{...dragHandleProps}
>
<GripVertical className="h-4 w-4" />
</button>
<span
className="min-w-0 flex-1 cursor-pointer text-xs font-semibold uppercase tracking-wider text-muted-foreground hover:text-muted-foreground"
onClick={() => setExpandedStepId(step.id)}
>
{step.title || 'Untitled Section'}
</span>
<button
onClick={() => removeStep(step.id)}
className="shrink-0 rounded p-1 text-muted-foreground opacity-0 hover:bg-red-500/20 hover:text-red-400 group-hover:opacity-100"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</div>
)}
</SortableStepWrapper>
)
}
// Regular procedure step
stepCounter++
const stepNumber = stepCounter
const isExpanded = expandedStepId === step.id
const contentType = step.content_type || 'action'
const config = contentTypeConfig[contentType]
const Icon = config.icon
if (isExpanded) {
return (
<SortableStepWrapper key={step.id} id={step.id} disabled>
{() => (
<div ref={scrollTargetRef}>
<StepEditor
step={step}
stepNumber={stepNumber}
onUpdate={(updates) => updateStep(step.id, updates)}
onCollapse={() => setExpandedStepId(null)}
availableVariables={intakeForm}
/>
</div>
)}
</SortableStepWrapper>
)
}
if (isExpanded) {
return (
<div key={step.id}>
<StepEditor
step={step}
stepNumber={0}
onUpdate={(updates) => updateStep(step.id, updates)}
onCollapse={() => setExpandedStepId(null)}
availableVariables={intakeForm}
/>
</div>
<SortableStepWrapper key={step.id} id={step.id}>
{({ dragHandleProps }) => (
<div
className={cn(
'group flex items-center gap-2 rounded-xl border border-border px-3 py-2.5 transition-colors',
'hover:border-primary/30 hover:bg-accent/50'
)}
>
<button
type="button"
className="shrink-0 cursor-grab touch-none text-muted-foreground active:cursor-grabbing"
aria-label={`Drag to reorder step ${stepNumber}: ${step.title || 'Untitled step'}`}
{...dragHandleProps}
>
<GripVertical className="h-4 w-4" />
</button>
<span className="flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-accent text-xs font-medium text-muted-foreground">
{stepNumber}
</span>
<span className={cn('shrink-0', config.color)}>
<Icon className="h-3.5 w-3.5" />
</span>
<span
className="min-w-0 flex-1 cursor-pointer truncate text-sm text-foreground"
onClick={() => setExpandedStepId(step.id)}
>
{step.title || 'Untitled step'}
</span>
{step.estimated_minutes && (
<span className="shrink-0 text-[10px] text-muted-foreground">
~{step.estimated_minutes}m
</span>
)}
<button
onClick={() => setExpandedStepId(step.id)}
className="shrink-0 rounded p-1 text-muted-foreground hover:bg-accent hover:text-foreground"
>
<ChevronDown className="h-3.5 w-3.5" />
</button>
<button
onClick={() => removeStep(step.id)}
className="shrink-0 rounded p-1 text-muted-foreground opacity-0 hover:bg-red-500/20 hover:text-red-400 group-hover:opacity-100"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</div>
)}
</SortableStepWrapper>
)
}
return (
<div
key={step.id}
className="group flex items-center gap-2 border-b border-border pb-1 pt-3"
>
<GripVertical className="h-4 w-4 shrink-0 cursor-grab text-muted-foreground group-hover:text-muted-foreground" />
<span
className="min-w-0 flex-1 cursor-pointer text-xs font-semibold uppercase tracking-wider text-muted-foreground hover:text-muted-foreground"
onClick={() => setExpandedStepId(step.id)}
>
{step.title || 'Untitled Section'}
</span>
<button
onClick={() => removeStep(step.id)}
className="shrink-0 rounded p-1 text-muted-foreground opacity-0 hover:bg-red-500/20 hover:text-red-400 group-hover:opacity-100"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</div>
)
}
// Regular procedure step
stepCounter++
const stepNumber = stepCounter
const isExpanded = expandedStepId === step.id
const contentType = step.content_type || 'action'
const config = contentTypeConfig[contentType]
const Icon = config.icon
if (isExpanded) {
return (
<div key={step.id}>
<StepEditor
step={step}
stepNumber={stepNumber}
onUpdate={(updates) => updateStep(step.id, updates)}
onCollapse={() => setExpandedStepId(null)}
availableVariables={intakeForm}
/>
</div>
)
}
return (
<div key={step.id}>
<div
className={cn(
'group flex items-center gap-2 rounded-xl border border-border px-3 py-2.5 transition-colors',
'hover:border-primary/30 hover:bg-accent/50'
)}
>
<GripVertical className="h-4 w-4 shrink-0 cursor-grab text-muted-foreground group-hover:text-muted-foreground" />
<span className="flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-accent text-xs font-medium text-muted-foreground">
{stepNumber}
</span>
<span className={cn('shrink-0', config.color)}>
<Icon className="h-3.5 w-3.5" />
</span>
<span
className="min-w-0 flex-1 cursor-pointer truncate text-sm text-foreground"
onClick={() => setExpandedStepId(step.id)}
>
{step.title || 'Untitled step'}
</span>
{step.estimated_minutes && (
<span className="shrink-0 text-[10px] text-muted-foreground">
~{step.estimated_minutes}m
</span>
)}
<button
onClick={() => setExpandedStepId(step.id)}
className="shrink-0 rounded p-1 text-muted-foreground hover:bg-accent hover:text-foreground"
>
<ChevronDown className="h-3.5 w-3.5" />
</button>
<button
onClick={() => removeStep(step.id)}
className="shrink-0 rounded p-1 text-muted-foreground opacity-0 hover:bg-red-500/20 hover:text-red-400 group-hover:opacity-100"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</div>
</div>
)
})}
</div>
})}
</div>
</SortableContext>
</DndContext>
{/* Add step button at bottom */}
<button
@@ -195,6 +296,8 @@ export function StepList() {
<Plus className="h-3.5 w-3.5" />
Add Step
</button>
<div ref={scrollTargetRef} />
</div>
)
}

View File

@@ -0,0 +1,24 @@
import type { MaintenanceSchedule, TargetList } from '@/types'
export function getScheduleSummary(schedule: MaintenanceSchedule | null, targetList?: TargetList | null): string {
if (!schedule) return 'No schedule configured'
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]
const dayOfMonth = parts[2]
let freqStr = 'Custom'
if (dayOfMonth === '1') {
freqStr = 'Monthly (1st)'
} else if (dayOfMonth === '*' && parts[3] === '*') {
if (dayOfWeek === '*') freqStr = 'Daily'
else if (dayOfWeek === '1') freqStr = 'Every Monday'
else if (dayOfWeek === '5') freqStr = 'Every Friday'
}
const targetStr = targetList ? ` \u00b7 ${targetList.targets.length} target${targetList.targets.length !== 1 ? 's' : ''}` : ''
return `${freqStr} at ${timeStr} ${schedule.timezone}${targetStr}`
}

View File

@@ -1,13 +1,18 @@
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, Calendar } 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 { MaintenanceScheduleSection } from '@/components/procedural-editor/MaintenanceScheduleSection'
import { getScheduleSummary } from '@/components/procedural-editor/scheduleUtils'
import { StepList } from '@/components/procedural-editor/StepList'
import { TagInput } from '@/components/common/TagInput'
import { toast } from '@/lib/toast'
import type { TreeType } from '@/types'
import type { TreeType, MaintenanceSchedule, TargetList } from '@/types'
type SectionKey = 'details' | 'intake' | 'schedule'
export function ProceduralEditorPage() {
const { id } = useParams<{ id: string }>()
@@ -22,6 +27,7 @@ export function ProceduralEditorPage() {
description,
tags,
isPublic,
intakeForm,
isDirty,
isSaving,
isLoading,
@@ -40,6 +46,24 @@ 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'
)
// Schedule state for collapsed summary
const [schedule, setSchedule] = useState<MaintenanceSchedule | null>(null)
const [scheduleTargetList, setScheduleTargetList] = useState<TargetList | null>(null)
const toggleSection = useCallback((key: SectionKey) => {
setExpandedSection(prev => prev === key ? null : key)
}, [])
const handleScheduleLoaded = useCallback((s: MaintenanceSchedule | null, tl: TargetList | null) => {
setSchedule(s)
setScheduleTargetList(tl)
}, [])
// Load tree or init new
useEffect(() => {
if (isEditMode && id) {
@@ -47,6 +71,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 +127,19 @@ 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 scheduleSummary = getScheduleSummary(schedule, scheduleTargetList)
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 +149,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 +163,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 +184,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 +192,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 +243,36 @@ 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>
{/* Step List */}
{isMaintenance && (
<CollapsibleEditorSection
title="Schedule"
icon={<Calendar className="h-4 w-4" />}
summary={scheduleSummary}
expanded={expandedSection === 'schedule'}
onToggle={() => toggleSection('schedule')}
>
<MaintenanceScheduleSection
treeId={treeId}
onScheduleLoaded={handleScheduleLoaded}
/>
</CollapsibleEditorSection>
)}
</div>
{/* Step List — flex-1, scrolls independently */}
<div className="min-h-0 flex-1 overflow-y-auto px-4 py-4">
<StepList />
</div>
</div>