feat: procedural editor redesign with collapsible sections and DnD (#84)
* docs: add procedural/maintenance editor redesign design Collapsible sections, fixed-height layout, drag-to-reorder steps, maintenance schedule section, and step list UX improvements. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * docs: add procedural editor redesign implementation plan 7 tasks across 7 phases: collapsible sections, fixed-height layout, step list improvements, drag-to-reorder, maintenance schedule section. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat: restructure procedural editor with collapsible sections and fixed-height layout Convert scrolling document layout to fixed-height editor with accordion-mode collapsible sections for Details and Intake Form. Step list now gets all remaining height with independent scrolling. Add CollapsibleEditorSection component with ARIA attributes (aria-expanded, aria-controls). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat: add step count with time estimate header and auto-scroll to new steps Remove outer card wrapper from StepList (now rendered in scrolling container). Header shows total estimated minutes when steps have time estimates. Auto-scrolls to newly added steps using ref + scrollIntoView. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat: add drag-to-reorder steps with @dnd-kit Wrap step list in DndContext + SortableContext. Each step/section header gets a SortableStepWrapper with useSortable. Drag handles have accessible labels and keyboard support. procedure_end stays non-draggable and always last. Expanded steps are disabled for dragging. Array-index reorder only. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat: add MaintenanceScheduleSection with schedule builder and summary Schedule draft state is local UI only (not in store). Hydrates form from existing schedule on load. Includes getScheduleSummary helper for collapsed section display. Two-stage save: tree first, schedule second. Schedule failure shows actionable error without rolling back tree save. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat: wire maintenance schedule section into procedural editor Add collapsible Schedule section for maintenance flows with accordion integration. Schedule summary shows frequency, time, and target count when collapsed. New maintenance flows default to schedule section expanded. Two-stage save preserved: tree saved first, schedule managed independently. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: resolve lint issues in maintenance schedule and editor page Move getScheduleSummary to scheduleUtils.ts to satisfy react-refresh only-export-components rule. Add onScheduleLoaded to useEffect deps. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * docs: add design and implementation revision documents Revision docs correct original plans: schedule persistence via API endpoints (not tree_structure), array-index reorder (no display_order), store minimum-one-step invariant, accordion mode, ARIA requirements, and two-stage save orchestration with failure handling. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat: auto-seed PR environments with SEED_ON_DEPLOY flag Release command now runs migrations + seeds test users when SEED_ON_DEPLOY=true. Tree seeding runs as a background task on startup via HTTP API. Everything is idempotent and non-fatal. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix: add httpx to requirements for PR environment seeding Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * feat: seed all flow types (v2, procedural, maintenance) on deploy Runs seed_trees, seed_trees_v2, seed_procedural_flows, and seed_maintenance_flows sequentially as background tasks when SEED_ON_DEPLOY=true. Each script failure is non-fatal. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * chore: trigger redeploy for full seed Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit was merged in pull request #84.
This commit is contained in:
@@ -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,9 +125,12 @@ export function StepList() {
|
||||
</div>
|
||||
</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}
|
||||
@@ -81,7 +155,9 @@ export function StepList() {
|
||||
|
||||
if (isExpanded) {
|
||||
return (
|
||||
<div key={step.id}>
|
||||
<SortableStepWrapper key={step.id} id={step.id} disabled>
|
||||
{() => (
|
||||
<div ref={scrollTargetRef}>
|
||||
<StepEditor
|
||||
step={step}
|
||||
stepNumber={0}
|
||||
@@ -90,15 +166,23 @@ export function StepList() {
|
||||
availableVariables={intakeForm}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</SortableStepWrapper>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
key={step.id}
|
||||
className="group flex items-center gap-2 border-b border-border pb-1 pt-3"
|
||||
<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 shrink-0 cursor-grab text-muted-foreground group-hover:text-muted-foreground" />
|
||||
<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)}
|
||||
@@ -112,6 +196,8 @@ export function StepList() {
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</SortableStepWrapper>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -125,7 +211,9 @@ export function StepList() {
|
||||
|
||||
if (isExpanded) {
|
||||
return (
|
||||
<div key={step.id}>
|
||||
<SortableStepWrapper key={step.id} id={step.id} disabled>
|
||||
{() => (
|
||||
<div ref={scrollTargetRef}>
|
||||
<StepEditor
|
||||
step={step}
|
||||
stepNumber={stepNumber}
|
||||
@@ -134,18 +222,28 @@ export function StepList() {
|
||||
availableVariables={intakeForm}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</SortableStepWrapper>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={step.id}>
|
||||
<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'
|
||||
)}
|
||||
>
|
||||
<GripVertical className="h-4 w-4 shrink-0 cursor-grab text-muted-foreground group-hover:text-muted-foreground" />
|
||||
<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}
|
||||
@@ -182,10 +280,13 @@ export function StepList() {
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</SortableStepWrapper>
|
||||
)
|
||||
})}
|
||||
</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>
|
||||
</CollapsibleEditorSection>
|
||||
|
||||
<CollapsibleEditorSection
|
||||
title="Intake Form"
|
||||
icon={<FileText className="h-4 w-4" />}
|
||||
summary={intakeSummary}
|
||||
expanded={expandedSection === 'intake'}
|
||||
onToggle={() => toggleSection('intake')}
|
||||
>
|
||||
<IntakeFormBuilder />
|
||||
</CollapsibleEditorSection>
|
||||
|
||||
{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>
|
||||
|
||||
{/* Intake Form Builder */}
|
||||
<IntakeFormBuilder />
|
||||
|
||||
{/* Step List */}
|
||||
{/* Step List — flex-1, scrolls independently */}
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-4 py-4">
|
||||
<StepList />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user