feat: procedural editor redesign with collapsible sections and DnD #84
@@ -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
|
||||
|
||||
@@ -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...")
|
||||
|
||||
|
||||
@@ -7,4 +7,4 @@ healthcheckPath = "/health"
|
||||
healthcheckTimeout = 100
|
||||
restartPolicyType = "on_failure"
|
||||
restartPolicyMaxRetries = 3
|
||||
releaseCommand = "alembic upgrade head"
|
||||
releaseCommand = "python -m scripts.release"
|
||||
|
||||
@@ -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
|
||||
|
||||
53
backend/scripts/release.py
Normal file
53
backend/scripts/release.py
Normal 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()
|
||||
@@ -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.
|
||||
247
docs/plans/2026-02-19-procedural-editor-redesign-design.md
Normal file
247
docs/plans/2026-02-19-procedural-editor-redesign-design.md
Normal 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)
|
||||
@@ -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.
|
||||
811
docs/plans/2026-02-19-procedural-editor-redesign-impl.md
Normal file
811
docs/plans/2026-02-19-procedural-editor-redesign-impl.md
Normal 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.
|
||||
@@ -0,0 +1,68 @@
|
||||
import { useState, useId, type ReactNode } from 'react'
|
||||
import { ChevronRight } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface CollapsibleEditorSectionProps {
|
||||
title: string
|
||||
icon: ReactNode
|
||||
summary: string
|
||||
expanded?: boolean
|
||||
onToggle?: () => void
|
||||
defaultExpanded?: boolean
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
export function CollapsibleEditorSection({
|
||||
title,
|
||||
icon,
|
||||
summary,
|
||||
expanded: controlledExpanded,
|
||||
onToggle,
|
||||
defaultExpanded = false,
|
||||
children,
|
||||
}: CollapsibleEditorSectionProps) {
|
||||
const [internalExpanded, setInternalExpanded] = useState(defaultExpanded)
|
||||
const isExpanded = controlledExpanded ?? internalExpanded
|
||||
const generatedId = useId()
|
||||
const sectionId = `section-${generatedId}`
|
||||
|
||||
const handleToggle = () => {
|
||||
if (onToggle) {
|
||||
onToggle()
|
||||
} else {
|
||||
setInternalExpanded(!internalExpanded)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="border-b border-border">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleToggle}
|
||||
aria-expanded={isExpanded}
|
||||
aria-controls={sectionId}
|
||||
className="flex w-full items-center gap-3 px-4 py-2.5 text-left transition-colors hover:bg-accent/50"
|
||||
>
|
||||
<ChevronRight
|
||||
className={cn(
|
||||
'h-4 w-4 shrink-0 text-muted-foreground transition-transform duration-200',
|
||||
isExpanded && 'rotate-90'
|
||||
)}
|
||||
/>
|
||||
<span className="shrink-0 text-muted-foreground">{icon}</span>
|
||||
<span className="text-sm font-medium text-foreground">{title}</span>
|
||||
{!isExpanded && (
|
||||
<span className="min-w-0 truncate text-sm text-muted-foreground">
|
||||
— {summary}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{isExpanded && (
|
||||
<div id={sectionId} className="px-4 pb-4 pt-1">
|
||||
{children}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -6,15 +6,8 @@ export function IntakeFormBuilder() {
|
||||
const { intakeForm, addField, removeField, updateField } = useProceduralEditorStore()
|
||||
|
||||
return (
|
||||
<div className="bg-card border border-border rounded-2xl p-4 sm:p-6">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<FileText className="h-5 w-5 text-muted-foreground" />
|
||||
<h2 className="text-lg font-semibold text-foreground">Intake Form</h2>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
({intakeForm.length} field{intakeForm.length !== 1 ? 's' : ''})
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<div className="mb-3 flex items-center justify-end">
|
||||
<button
|
||||
onClick={addField}
|
||||
className="flex items-center gap-1.5 rounded-md border border-border px-3 py-1.5 text-sm text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
24
frontend/src/components/procedural-editor/scheduleUtils.ts
Normal file
24
frontend/src/components/procedural-editor/scheduleUtils.ts
Normal 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}`
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user