diff --git a/backend/app/core/config.py b/backend/app/core/config.py index adc81277..eb073d08 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -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 diff --git a/backend/app/main.py b/backend/app/main.py index 5536e832..91b34c91 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -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...") diff --git a/backend/railway.toml b/backend/railway.toml index 2c439905..a2b6bd6f 100644 --- a/backend/railway.toml +++ b/backend/railway.toml @@ -7,4 +7,4 @@ healthcheckPath = "/health" healthcheckTimeout = 100 restartPolicyType = "on_failure" restartPolicyMaxRetries = 3 -releaseCommand = "alembic upgrade head" +releaseCommand = "python -m scripts.release" diff --git a/backend/requirements.txt b/backend/requirements.txt index 686c481e..47d6eaf8 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -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 diff --git a/backend/scripts/release.py b/backend/scripts/release.py new file mode 100644 index 00000000..6118da0b --- /dev/null +++ b/backend/scripts/release.py @@ -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() diff --git a/docs/plans/2026-02-19-procedural-editor-redesign-design-revisions.md b/docs/plans/2026-02-19-procedural-editor-redesign-design-revisions.md new file mode 100644 index 00000000..7dfe8488 --- /dev/null +++ b/docs/plans/2026-02-19-procedural-editor-redesign-design-revisions.md @@ -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. diff --git a/docs/plans/2026-02-19-procedural-editor-redesign-design.md b/docs/plans/2026-02-19-procedural-editor-redesign-design.md new file mode 100644 index 00000000..2aa83ae1 --- /dev/null +++ b/docs/plans/2026-02-19-procedural-editor-redesign-design.md @@ -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 `` + `` +- 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) diff --git a/docs/plans/2026-02-19-procedural-editor-redesign-impl-revisions.md b/docs/plans/2026-02-19-procedural-editor-redesign-impl-revisions.md new file mode 100644 index 00000000..8f803a99 --- /dev/null +++ b/docs/plans/2026-02-19-procedural-editor-redesign-impl-revisions.md @@ -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. diff --git a/docs/plans/2026-02-19-procedural-editor-redesign-impl.md b/docs/plans/2026-02-19-procedural-editor-redesign-impl.md new file mode 100644 index 00000000..1c6002bf --- /dev/null +++ b/docs/plans/2026-02-19-procedural-editor-redesign-impl.md @@ -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 ( +
+ {/* Collapsed header — always visible */} + + + {/* Expanded content */} + {isExpanded && ( +
+ {children} +
+ )} +
+ ) +} +``` + +**Step 2: Verify it builds** + +Run: `cd frontend && npm run build 2>&1 | tail -5` +Expected: `built in` success message (component is created but not imported anywhere yet) + +**Step 3: Commit** + +```bash +git add frontend/src/components/procedural-editor/CollapsibleEditorSection.tsx +git commit -m "feat: add CollapsibleEditorSection component for procedural editor" +``` + +--- + +## Phase 2: Layout Restructure + +### Task 2: Convert ProceduralEditorPage to Fixed-Height Editor + +**Files:** +- Modify: `frontend/src/pages/ProceduralEditorPage.tsx` + +**Context:** The page currently uses `container mx-auto px-4 py-6` with vertical scrolling. We need to convert it to `flex flex-col h-full overflow-hidden` so the step list can scroll independently. The toolbar becomes a sticky header, and Details + Intake Form become collapsible sections. + +Reference the existing troubleshooting editor pattern: `frontend/src/pages/TreeEditorPage.tsx` lines 409-410 for the `flex h-full flex-col overflow-hidden` pattern. + +**Step 1: Read the current file** + +Read: `frontend/src/pages/ProceduralEditorPage.tsx` +Understand the full structure before making changes. + +**Step 2: Restructure the layout** + +Replace the entire render return (from `return (` to the closing `)`) with the new fixed-height layout. The key changes: + +1. Outer wrapper: `
` (replaces `container mx-auto`) +2. Toolbar: sticky `
` containing the back button, title, and save/publish buttons +3. Collapsible sections zone: `
` containing CollapsibleEditorSection wrappers for Details and IntakeFormBuilder +4. Step list zone: `
` containing StepList + +Import `CollapsibleEditorSection` at the top: +```tsx +import { CollapsibleEditorSection } from '@/components/procedural-editor/CollapsibleEditorSection' +``` + +The Details collapsible section needs a summary string. Build it from store state: +```tsx +const detailsSummary = [ + name ? `"${name}"` : '"Untitled"', + tags.length > 0 ? `${tags.length} tag${tags.length !== 1 ? 's' : ''}` : 'No tags', + isPublic ? 'Public' : 'Private', +].join(' · ') +``` + +The IntakeFormBuilder collapsible section needs a summary too. Read the `intakeForm` array from the store (add it to destructuring if not already there) and build: +```tsx +const intakeSummary = intakeForm.length === 0 + ? 'No fields defined' + : `${intakeForm.length} field${intakeForm.length !== 1 ? 's' : ''}: ${intakeForm.map(f => f.label || f.variable_name).slice(0, 4).join(', ')}${intakeForm.length > 4 ? ', ...' : ''}` +``` + +For the Details section content inside CollapsibleEditorSection, move the existing form fields (name input, description textarea, tags input, public checkbox) directly as children. + +The Details section should `defaultExpanded={!isEditMode}` so new flows start with Details expanded (name is required), while existing flows start collapsed. + +The Intake Form section should `defaultExpanded={false}` always. + +**Step 3: Verify it builds** + +Run: `cd frontend && npm run build 2>&1 | tail -5` +Expected: Clean build + +**Step 4: Commit** + +```bash +git add frontend/src/pages/ProceduralEditorPage.tsx +git commit -m "feat: restructure procedural editor to fixed-height layout with collapsible sections" +``` + +--- + +## Phase 3: Step List Improvements + +### Task 3: Add Empty State and Step Count Header + +**Files:** +- Modify: `frontend/src/components/procedural-editor/StepList.tsx` + +**Context:** The step list needs: (1) a step count + total estimated time in the header, (2) auto-scroll to new steps. Note: the store enforces minimum one `procedure_step`, so remove any "0 steps" UI paths. The step list's outer card wrapper should be removed since ProceduralEditorPage now provides the scrolling container — StepList should just render its header and step items directly. + +**Step 1: Read the current file** + +Read: `frontend/src/components/procedural-editor/StepList.tsx` + +**Step 2: Modify the header and add empty state** + +Remove the outer `
` card wrapper. The step list now renders directly in the scrolling container. + +Update the header to show step count + total estimated time: +```tsx +const totalMinutes = steps + .filter(s => s.type === 'procedure_step' && s.estimated_minutes) + .reduce((sum, s) => sum + (s.estimated_minutes || 0), 0) + +// In the header: + + ({procedureSteps.length} step{procedureSteps.length !== 1 ? 's' : ''} + {totalMinutes > 0 ? ` · ~${totalMinutes} min` : ''}) + +``` + +Add empty state after the header, before the step map: +```tsx +{procedureSteps.length === 0 && ( +
+ +

Add your first step

+

+ Steps define the actions engineers follow during this procedure. +

+
+ + +
+
+)} +``` + +**Step 3: Add auto-scroll to new step** + +When a new step is added, the list should scroll to show it. Add a ref and useEffect: + +```tsx +import { useRef, useEffect } from 'react' + +const listEndRef = useRef(null) +const prevStepCount = useRef(steps.length) + +useEffect(() => { + if (steps.length > prevStepCount.current) { + listEndRef.current?.scrollIntoView({ behavior: 'smooth' }) + } + prevStepCount.current = steps.length +}, [steps.length]) + +// At the bottom of the step list, before the Add Step button: +
+``` + +**Step 4: Verify it builds** + +Run: `cd frontend && npm run build 2>&1 | tail -5` +Expected: Clean build + +**Step 5: Commit** + +```bash +git add frontend/src/components/procedural-editor/StepList.tsx +git commit -m "feat: add empty state, step count header, and auto-scroll to step list" +``` + +--- + +## Phase 4: Drag-to-Reorder Steps + +### Task 4: Add @dnd-kit Drag-to-Reorder to StepList + +**Files:** +- Modify: `frontend/src/components/procedural-editor/StepList.tsx` + +**Context:** @dnd-kit is already installed (`@dnd-kit/core@^6.3.1`, `@dnd-kit/sortable@^10.0.0`). There's an existing pattern in `frontend/src/components/step-library/CategoryRow.tsx` using `useSortable` and in `frontend/src/pages/admin/AdminCategoriesPage.tsx` using `DndContext` + `SortableContext`. The store already has `moveStep(fromIndex, toIndex)`. Reorder is array-index based only — do NOT recalculate `display_order`. `procedure_end` must remain non-draggable and always last. Drag handles must have accessible labels and keyboard support (Enter/Space to pick up, arrow keys to move, Escape to cancel). + +**Step 1: Read the existing @dnd-kit pattern** + +Read: `frontend/src/components/step-library/CategoryRow.tsx` (lines 1-50 for the useSortable pattern) +Read: `frontend/src/pages/admin/AdminCategoriesPage.tsx` (lines 1-10 for imports, lines 110-140 for handleDragEnd) + +**Step 2: Add DndContext to StepList** + +Add imports at the top of StepList.tsx: +```tsx +import { + DndContext, + closestCenter, + KeyboardSensor, + PointerSensor, + useSensor, + useSensors, + type DragEndEvent, +} from '@dnd-kit/core' +import { + SortableContext, + sortableKeyboardCoordinates, + verticalListSortingStrategy, + useSortable, +} from '@dnd-kit/sortable' +import { CSS } from '@dnd-kit/utilities' +``` + +Add sensors and drag handler before the return: +```tsx +const sensors = useSensors( + useSensor(PointerSensor, { activationConstraint: { distance: 8 } }), + useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }) +) + +const handleDragEnd = useCallback((event: DragEndEvent) => { + const { active, over } = event + if (!over || active.id === over.id) return + + const oldIndex = steps.findIndex(s => s.id === active.id) + const newIndex = steps.findIndex(s => s.id === over.id) + if (oldIndex === -1 || newIndex === -1) return + + // Don't allow moving past the procedure_end step + const endIndex = steps.findIndex(s => s.type === 'procedure_end') + if (newIndex >= endIndex) return + + moveStep(oldIndex, newIndex) +}, [steps, moveStep]) +``` + +Wrap the step list `
` with DndContext and SortableContext: +```tsx + + s.type !== 'procedure_end').map(s => s.id)} strategy={verticalListSortingStrategy}> +
+ {steps.map((step) => { + // ... existing rendering logic + })} +
+
+
+``` + +**Step 3: Make each step card sortable** + +For each step card (procedure_step collapsed, section_header collapsed), extract the card div into a `SortableStepCard` wrapper or apply `useSortable` inline. + +The simplest approach: create a small wrapper component inside StepList.tsx: + +```tsx +function SortableStepWrapper({ id, children, disabled }: { id: string; children: ReactNode; disabled?: boolean }) { + const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id, disabled }) + + const style = { + transform: CSS.Transform.toString(transform), + transition, + } + + return ( +
+ {/* Pass drag handle props to children via render prop or context */} + {typeof children === 'function' + ? children({ dragHandleProps: { ...attributes, ...listeners } }) + : children} +
+ ) +} +``` + +Actually, a simpler approach: apply useSortable directly to each card's GripVertical button. Wrap each step's outer `
` with `ref={setNodeRef}` and pass `{...attributes} {...listeners}` to the GripVertical button. + +For collapsed procedure_step cards (the main case), the existing `` button at line 148 gets the sortable props: +```tsx +// Replace the GripVertical span/button: + +``` + +For expanded steps (StepEditor), disable sorting (`disabled: true` in useSortable) since the user is editing. + +The `procedure_end` step should NOT be in the SortableContext items array (already excluded above) and should not have useSortable applied. + +**Step 4: Verify it builds** + +Run: `cd frontend && npm run build 2>&1 | tail -5` +Expected: Clean build + +**Step 5: Commit** + +```bash +git add frontend/src/components/procedural-editor/StepList.tsx +git commit -m "feat: add drag-to-reorder steps with @dnd-kit" +``` + +--- + +## Phase 5: Maintenance Schedule Section + +### Task 5: Create MaintenanceScheduleSection + +**Files:** +- Create: `frontend/src/components/procedural-editor/MaintenanceScheduleSection.tsx` + +**Context:** This component renders only for maintenance flows. Schedule is NOT part of `tree_structure` — it's persisted through separate maintenance schedule API endpoints. Two-stage save: tree first, then schedule. If schedule save fails, tree save remains successful — show actionable error and preserve schedule draft state. It uses the existing `maintenanceSchedulesApi` from `frontend/src/api/maintenanceSchedules.ts` and `targetListsApi` from `frontend/src/api/targetLists.ts`. Types are in `frontend/src/types/maintenance.ts`. Schedule draft state should be local UI state, not stored in proceduralEditorStore. + +Read these files first: +- `frontend/src/api/maintenanceSchedules.ts` +- `frontend/src/api/targetLists.ts` +- `frontend/src/types/maintenance.ts` + +**Step 1: Create the component** + +```tsx +// frontend/src/components/procedural-editor/MaintenanceScheduleSection.tsx +import { useState, useEffect } from 'react' +import { Calendar, Clock } from 'lucide-react' +import { maintenanceSchedulesApi } from '@/api/maintenanceSchedules' +import { targetListsApi } from '@/api/targetLists' +import type { MaintenanceSchedule, TargetList } from '@/types' +import { cn } from '@/lib/utils' +import { toast } from '@/lib/toast' + +interface MaintenanceScheduleSectionProps { + treeId: string | null // null for new flows +} + +const FREQUENCY_OPTIONS = [ + { value: 'daily', label: 'Daily', cron: (hour: number, min: number) => `${min} ${hour} * * *` }, + { value: 'weekly-mon', label: 'Weekly (Monday)', cron: (hour: number, min: number) => `${min} ${hour} * * 1` }, + { value: 'weekly-fri', label: 'Weekly (Friday)', cron: (hour: number, min: number) => `${min} ${hour} * * 5` }, + { value: 'monthly', label: 'Monthly (1st)', cron: (hour: number, min: number) => `${min} ${hour} 1 * *` }, +] as const + +const TIMEZONE_OPTIONS = [ + 'UTC', + 'America/New_York', + 'America/Chicago', + 'America/Denver', + 'America/Los_Angeles', + 'Europe/London', + 'Europe/Berlin', + 'Asia/Tokyo', + 'Australia/Sydney', +] + +export function MaintenanceScheduleSection({ treeId }: MaintenanceScheduleSectionProps) { + const [schedule, setSchedule] = useState(null) + const [targetLists, setTargetLists] = useState([]) + const [isLoading, setIsLoading] = useState(false) + const [isSaving, setIsSaving] = useState(false) + + // Form state + const [frequency, setFrequency] = useState('weekly-mon') + const [hour, setHour] = useState(9) + const [minute, setMinute] = useState(0) + const [timezone, setTimezone] = useState('UTC') + const [selectedTargetListId, setSelectedTargetListId] = useState('') + + // Load existing schedule and target lists + useEffect(() => { + const load = async () => { + setIsLoading(true) + try { + const lists = await targetListsApi.list() + setTargetLists(lists) + + if (treeId) { + try { + const existing = await maintenanceSchedulesApi.getForTree(treeId) + setSchedule(existing) + if (existing.target_list_id) { + setSelectedTargetListId(existing.target_list_id) + } + } catch { + // No schedule yet — that's fine + } + } + } catch { + // Target lists may not load — non-critical + } finally { + setIsLoading(false) + } + } + load() + }, [treeId]) + + const handleSaveSchedule = async () => { + if (!treeId) { + toast.error('Save the flow first before configuring a schedule') + return + } + + setIsSaving(true) + try { + const freqOption = FREQUENCY_OPTIONS.find(f => f.value === frequency) + const cronExpression = freqOption?.cron(hour, minute) ?? `${minute} ${hour} * * 1` + + if (schedule) { + const updated = await maintenanceSchedulesApi.update(schedule.id, { + cron_expression: cronExpression, + timezone, + target_list_id: selectedTargetListId || undefined, + }) + setSchedule(updated) + toast.success('Schedule updated') + } else { + const created = await maintenanceSchedulesApi.create({ + tree_id: treeId, + cron_expression: cronExpression, + timezone, + target_list_id: selectedTargetListId || undefined, + }) + setSchedule(created) + toast.success('Schedule created') + } + } catch { + toast.error('Failed to save schedule') + } finally { + setIsSaving(false) + } + } + + const selectedTargetList = targetLists.find(tl => tl.id === selectedTargetListId) + + if (isLoading) { + return ( +
Loading schedule...
+ ) + } + + return ( +
+ {/* Frequency */} +
+ + +
+ + {/* Time */} +
+
+ + setHour(Number(e.target.value))} + className="w-full rounded-lg border border-border bg-card px-3 py-2 text-foreground focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary/20" + /> +
+
+ + setMinute(Number(e.target.value))} + className="w-full rounded-lg border border-border bg-card px-3 py-2 text-foreground focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary/20" + /> +
+
+ + {/* Timezone */} +
+ + +
+ + {/* Target List */} + {targetLists.length > 0 && ( +
+ + +
+ )} + + {/* Save button */} + + {!treeId && ( +

Save the flow first to configure a schedule.

+ )} +
+ ) +} +``` + +**Step 2: Build a summary string helper** + +Add this exported function at the bottom of the file (used by ProceduralEditorPage for the collapsible section summary): + +```tsx +export function getScheduleSummary(schedule: MaintenanceSchedule | null, targetList?: TargetList | null): string { + if (!schedule) return 'No schedule configured' + + // Parse cron for human-readable display + const parts = schedule.cron_expression.split(' ') + const min = parts[0] ?? '0' + const hour = parts[1] ?? '0' + const timeStr = `${hour.padStart(2, '0')}:${min.padStart(2, '0')}` + + const dayOfWeek = parts[4] + let freqStr = 'Custom' + if (parts[2] === '*' && parts[3] === '*') { + if (dayOfWeek === '*') freqStr = 'Daily' + else if (dayOfWeek === '1') freqStr = 'Every Monday' + else if (dayOfWeek === '5') freqStr = 'Every Friday' + } else if (parts[2] === '1') { + freqStr = 'Monthly (1st)' + } + + const targetStr = targetList ? ` · ${targetList.targets.length} targets` : '' + return `${freqStr} at ${timeStr} ${schedule.timezone}${targetStr}` +} +``` + +**Step 3: Verify it builds** + +Run: `cd frontend && npm run build 2>&1 | tail -5` +Expected: Clean build + +**Step 4: Commit** + +```bash +git add frontend/src/components/procedural-editor/MaintenanceScheduleSection.tsx +git commit -m "feat: add MaintenanceScheduleSection with schedule builder and summary" +``` + +--- + +## Phase 6: Wire Maintenance Schedule into ProceduralEditorPage + +### Task 6: Add Schedule Section to ProceduralEditorPage + +**Files:** +- Modify: `frontend/src/pages/ProceduralEditorPage.tsx` + +**Context:** The MaintenanceScheduleSection should appear as a third collapsible section, only for maintenance flows. It needs access to `treeId` (from store) and renders between Intake Form and the step list. + +**Step 1: Read the current ProceduralEditorPage** + +Read: `frontend/src/pages/ProceduralEditorPage.tsx` + +**Step 2: Add imports and the schedule section** + +Add imports: +```tsx +import { MaintenanceScheduleSection, getScheduleSummary } from '@/components/procedural-editor/MaintenanceScheduleSection' +import { Calendar } from 'lucide-react' +``` + +Add the schedule state (load existing schedule for summary): +```tsx +import { useState, useEffect } from 'react' +import { maintenanceSchedulesApi } from '@/api/maintenanceSchedules' +import { targetListsApi } from '@/api/targetLists' +import type { MaintenanceSchedule, TargetList } from '@/types' + +// Inside the component: +const [schedule, setSchedule] = useState(null) +const [scheduleTargetList, setScheduleTargetList] = useState(null) + +useEffect(() => { + if (!isMaintenance || !treeId) return + const loadSchedule = async () => { + try { + const s = await maintenanceSchedulesApi.getForTree(treeId) + setSchedule(s) + if (s.target_list_id) { + const tl = await targetListsApi.get(s.target_list_id) + setScheduleTargetList(tl) + } + } catch { + // No schedule — fine + } + } + loadSchedule() +}, [isMaintenance, treeId]) + +const scheduleSummary = getScheduleSummary(schedule, scheduleTargetList) +``` + +Add the collapsible schedule section after the Intake Form section and before the step list: +```tsx +{isMaintenance && ( + } + summary={scheduleSummary} + defaultExpanded={!isEditMode || !schedule} + > + + +)} +``` + +**Step 3: Verify it builds** + +Run: `cd frontend && npm run build 2>&1 | tail -5` +Expected: Clean build + +**Step 4: Commit** + +```bash +git add frontend/src/pages/ProceduralEditorPage.tsx +git commit -m "feat: wire maintenance schedule section into procedural editor" +``` + +--- + +## Phase 7: Final Verification + +### Task 7: Build and Manual Testing Checklist + +**Step 1: Run full build** + +Run: `cd frontend && npm run build 2>&1 | tail -10` +Expected: Clean build with no TypeScript or lint errors + +**Step 2: Manual testing checklist** + +Start the dev server: `cd frontend && npm run dev` + +Test each scenario: + +**Procedural flow — new:** +- [ ] Navigate to `/flows/new?type=procedural` +- [ ] Page uses fixed-height layout (no page scrolling) +- [ ] Details section is expanded by default (name field visible) +- [ ] Intake Form section is collapsed, shows "No fields defined" +- [ ] No Schedule section visible (procedural, not maintenance) +- [ ] Step list shows empty state with "Add your first step" +- [ ] Click "Add Step" — new step appears and auto-expands +- [ ] Step list scrolls independently from the toolbar +- [ ] Fill in name, collapse Details — summary shows `"Name" · No tags · Private` + +**Procedural flow — existing:** +- [ ] Edit an existing procedural flow +- [ ] Details section is collapsed with rich summary +- [ ] Intake Form section is collapsed with field names +- [ ] Steps visible immediately with count and estimated time +- [ ] Drag a step by its grip handle — reorders correctly +- [ ] Expanded step cannot be dragged +- [ ] Section headers can be dragged +- [ ] procedure_end step stays at the bottom (cannot be dragged above it) + +**Maintenance flow — new:** +- [ ] Navigate to `/flows/new?type=maintenance` +- [ ] Schedule section is visible and expanded +- [ ] Can select frequency, time, timezone +- [ ] "Save the flow first" message shown (no treeId yet) +- [ ] After saving, schedule can be created + +**Maintenance flow — existing with schedule:** +- [ ] Schedule section shows collapsed summary: "Every Monday at 09:00 UTC · 5 targets" +- [ ] Expand to modify schedule +- [ ] Update saves correctly + +**Step 3: Commit any fixes found during testing** + +```bash +git add -A +git commit -m "fix: address issues found during manual testing" +``` + +**Step 4: Final commit message for the complete feature** + +If all tests pass and no fixes needed, the branch is ready for PR. diff --git a/frontend/src/components/procedural-editor/CollapsibleEditorSection.tsx b/frontend/src/components/procedural-editor/CollapsibleEditorSection.tsx new file mode 100644 index 00000000..2325e4ff --- /dev/null +++ b/frontend/src/components/procedural-editor/CollapsibleEditorSection.tsx @@ -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 ( +
+ + + {isExpanded && ( +
+ {children} +
+ )} +
+ ) +} diff --git a/frontend/src/components/procedural-editor/IntakeFormBuilder.tsx b/frontend/src/components/procedural-editor/IntakeFormBuilder.tsx index eb3969fa..bc28e34b 100644 --- a/frontend/src/components/procedural-editor/IntakeFormBuilder.tsx +++ b/frontend/src/components/procedural-editor/IntakeFormBuilder.tsx @@ -6,15 +6,8 @@ export function IntakeFormBuilder() { const { intakeForm, addField, removeField, updateField } = useProceduralEditorStore() return ( -
-
-
- -

Intake Form

- - ({intakeForm.length} field{intakeForm.length !== 1 ? 's' : ''}) - -
+
+
+ {!treeId && ( +

Save the flow first to configure a schedule.

+ )} +
+ ) +} diff --git a/frontend/src/components/procedural-editor/StepList.tsx b/frontend/src/components/procedural-editor/StepList.tsx index 066843b0..a9cb93f1 100644 --- a/frontend/src/components/procedural-editor/StepList.tsx +++ b/frontend/src/components/procedural-editor/StepList.tsx @@ -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 }) => ReactNode +}) { + const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id, disabled }) + + const style = { + transform: CSS.Transform.toString(transform), + transition, + } + + return ( +
+ {children({ dragHandleProps: { ...attributes, ...listeners } })} +
+ ) +} + 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(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 ( -
+

Steps

- ({procedureSteps.length} step{procedureSteps.length !== 1 ? 's' : ''}) + ({procedureSteps.length} step{procedureSteps.length !== 1 ? 's' : ''} + {totalMinutes > 0 ? ` \u00b7 ~${totalMinutes} min` : ''})
@@ -54,138 +125,168 @@ export function StepList() {
-
- {steps.map((step) => { - if (step.type === 'procedure_end') { - return ( -
- - updateStep(step.id, { title: e.target.value })} - className="flex-1 bg-transparent text-sm text-muted-foreground focus:outline-none" - placeholder="Procedure Complete" - /> - END -
- ) - } + + +
+ {steps.map((step) => { + if (step.type === 'procedure_end') { + // procedure_end: non-draggable, always last + return ( +
+ + updateStep(step.id, { title: e.target.value })} + className="flex-1 bg-transparent text-sm text-muted-foreground focus:outline-none" + placeholder="Procedure Complete" + /> + END +
+ ) + } - // 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 ( + + {() => ( +
+ updateStep(step.id, updates)} + onCollapse={() => setExpandedStepId(null)} + availableVariables={intakeForm} + /> +
+ )} +
+ ) + } + + return ( + + {({ dragHandleProps }) => ( +
+ + setExpandedStepId(step.id)} + > + {step.title || 'Untitled Section'} + + +
+ )} +
+ ) + } + + // 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 ( + + {() => ( +
+ updateStep(step.id, updates)} + onCollapse={() => setExpandedStepId(null)} + availableVariables={intakeForm} + /> +
+ )} +
+ ) + } - if (isExpanded) { return ( -
- updateStep(step.id, updates)} - onCollapse={() => setExpandedStepId(null)} - availableVariables={intakeForm} - /> -
+ + {({ dragHandleProps }) => ( +
+ + + + {stepNumber} + + + + + + + setExpandedStepId(step.id)} + > + {step.title || 'Untitled step'} + + + {step.estimated_minutes && ( + + ~{step.estimated_minutes}m + + )} + + + + +
+ )} +
) - } - - return ( -
- - setExpandedStepId(step.id)} - > - {step.title || 'Untitled Section'} - - -
- ) - } - - // 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 ( -
- updateStep(step.id, updates)} - onCollapse={() => setExpandedStepId(null)} - availableVariables={intakeForm} - /> -
- ) - } - - return ( -
-
- - - - {stepNumber} - - - - - - - setExpandedStepId(step.id)} - > - {step.title || 'Untitled step'} - - - {step.estimated_minutes && ( - - ~{step.estimated_minutes}m - - )} - - - - -
-
- ) - })} -
+ })} +
+ + {/* Add step button at bottom */} + +
) } diff --git a/frontend/src/components/procedural-editor/scheduleUtils.ts b/frontend/src/components/procedural-editor/scheduleUtils.ts new file mode 100644 index 00000000..501c99b2 --- /dev/null +++ b/frontend/src/components/procedural-editor/scheduleUtils.ts @@ -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}` +} diff --git a/frontend/src/pages/ProceduralEditorPage.tsx b/frontend/src/pages/ProceduralEditorPage.tsx index de8fa565..2bf6c9d2 100644 --- a/frontend/src/pages/ProceduralEditorPage.tsx +++ b/frontend/src/pages/ProceduralEditorPage.tsx @@ -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( + isEditMode ? null : 'details' + ) + + // Schedule state for collapsed summary + const [schedule, setSchedule] = useState(null) + const [scheduleTargetList, setScheduleTargetList] = useState(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 (
@@ -110,9 +149,9 @@ export function ProceduralEditorPage() { } return ( -
- {/* Header */} -
+
+ {/* Toolbar — sticky */} +
@@ -144,7 +184,7 @@ export function ProceduralEditorPage() {
- {/* Content */} -
- {/* Metadata */} -
-

Details

+ {/* Collapsible sections */} +
+ } + summary={detailsSummary} + expanded={expandedSection === 'details'} + onToggle={() => toggleSection('details')} + >
@@ -199,12 +243,36 @@ export function ProceduralEditorPage() {
-
+ - {/* Intake Form Builder */} - + } + summary={intakeSummary} + expanded={expandedSection === 'intake'} + onToggle={() => toggleSection('intake')} + > + + - {/* Step List */} + {isMaintenance && ( + } + summary={scheduleSummary} + expanded={expandedSection === 'schedule'} + onToggle={() => toggleSection('schedule')} + > + + + )} +
+ + {/* Step List — flex-1, scrolls independently */} +