docs: add Step Library and Procedural Custom Steps design/plan docs
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
178
docs/plans/2026-02-24-procedural-custom-steps-design.md
Normal file
178
docs/plans/2026-02-24-procedural-custom-steps-design.md
Normal file
@@ -0,0 +1,178 @@
|
|||||||
|
# Procedural Custom Steps — Design
|
||||||
|
|
||||||
|
> **Date:** February 24, 2026
|
||||||
|
> **Status:** Approved
|
||||||
|
> **Phase:** 2.5 — Feature 2 of 3
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What We're Building
|
||||||
|
|
||||||
|
Add "Add Custom Step" support to `ProceduralNavigationPage` so engineers can insert ad-hoc steps between any existing checklist items during execution. The inserted step appears inline in the checklist and detail panel — worked through just like a regular step before proceeding.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Key Difference from Troubleshooting Custom Steps
|
||||||
|
|
||||||
|
`useCustomStepFlow` is built for tree-graph navigation: `currentNodeId`, `findNode`, `setCurrentNodeId`, path traversal, continuation modal for picking descendants, custom branch mode. **None of that applies here.**
|
||||||
|
|
||||||
|
Procedural flows are linear arrays. A custom step is just a new `ProceduralStep`-shaped object injected at a position in the array. No new hook needed — all state lives in `ProceduralNavigationPage`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Data Model
|
||||||
|
|
||||||
|
Custom steps are stored in session `custom_steps` (already a JSONB array on `Session`). The existing `CustomStep` type is:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
interface CustomStep {
|
||||||
|
id: string
|
||||||
|
inserted_after_node_id: string // step ID it was inserted after
|
||||||
|
step_data: Step | CustomStepDraft
|
||||||
|
timestamp: string
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
For procedural flows, `inserted_after_node_id` is the `ProceduralStep.id` of the step it follows.
|
||||||
|
|
||||||
|
A custom step is represented in the runtime `ProceduralStep[]` as:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
{
|
||||||
|
id: customStep.id, // the CustomStep UUID
|
||||||
|
type: 'procedure_step',
|
||||||
|
title: step_data.title,
|
||||||
|
description: step_data.content.instructions,
|
||||||
|
content_type: 'action',
|
||||||
|
commands: step_data.content.commands (mapped),
|
||||||
|
// marker for custom steps:
|
||||||
|
_isCustom: true // not in ProceduralStep type — use a local union
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Rather than mutating `ProceduralStep`, we use a local discriminated union:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
type RuntimeStep = ProceduralStep | CustomProceduralStep
|
||||||
|
|
||||||
|
interface CustomProceduralStep {
|
||||||
|
id: string
|
||||||
|
type: 'procedure_step'
|
||||||
|
title: string
|
||||||
|
description?: string
|
||||||
|
content_type: 'action'
|
||||||
|
commands?: CommandBlock[]
|
||||||
|
isCustom: true // discriminant
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The `procedureSteps` array used for rendering becomes `RuntimeStep[]` instead of `ProceduralStep[]`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## User Flow
|
||||||
|
|
||||||
|
1. Engineer is on any step in a procedural flow
|
||||||
|
2. Clicks **"+ Add Step"** button below the current step detail
|
||||||
|
3. `CustomStepModal` opens (existing component — Create tab + Browse Library tab)
|
||||||
|
4. Engineer creates or selects a step → `PostStepActionModal` appears
|
||||||
|
5. **"Use Now"**: inserts after current step, closes modals, advances to the new step
|
||||||
|
6. **"Save for Later"**: saves to library only, no insertion
|
||||||
|
7. **"Do Both"**: saves to library + inserts
|
||||||
|
|
||||||
|
The inserted step renders in `StepDetail` and `StepChecklist` with a visual "Custom" badge. The engineer marks it complete the same way as any other step (the "Mark Complete" button).
|
||||||
|
|
||||||
|
No continuation modal. No custom branch mode. No fork flow.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What Changes
|
||||||
|
|
||||||
|
### ProceduralNavigationPage.tsx
|
||||||
|
|
||||||
|
**New state:**
|
||||||
|
```ts
|
||||||
|
const [runtimeSteps, setRuntimeSteps] = useState<RuntimeStep[]>([])
|
||||||
|
const [showCustomStepModal, setShowCustomStepModal] = useState(false)
|
||||||
|
const [showPostStepModal, setShowPostStepModal] = useState(false)
|
||||||
|
const [pendingCustomStep, setPendingCustomStep] = useState<Step | CustomStepDraft | null>(null)
|
||||||
|
const [pendingIsFromLibrary, setPendingIsFromLibrary] = useState(false)
|
||||||
|
const [isSavingStep, setIsSavingStep] = useState(false)
|
||||||
|
const [sessionCustomSteps, setSessionCustomSteps] = useState<CustomStep[]>([])
|
||||||
|
```
|
||||||
|
|
||||||
|
**`runtimeSteps`** is initialized from `tree.tree_structure.steps` and updated when custom steps are inserted. `procedureSteps` (currently `steps.filter(s => s.type === 'procedure_step')`) becomes `runtimeSteps.filter(...)`.
|
||||||
|
|
||||||
|
**`handleInsertCustomStep(step, isFromLibrary)`:**
|
||||||
|
- Builds a `CustomProceduralStep` from the draft/step
|
||||||
|
- Inserts it into `runtimeSteps` after `currentStepIndex`
|
||||||
|
- Adds a `CustomStep` entry to `sessionCustomSteps`
|
||||||
|
- Calls `sessionsApi.update(session.id, { custom_steps: newCustomSteps })`
|
||||||
|
- Advances `currentStepIndex` by 1 (focus moves to the new step)
|
||||||
|
|
||||||
|
**`handleStepCreated(step, isFromLibrary)`:** — same pattern as troubleshooting
|
||||||
|
- Sets `pendingCustomStep`, `pendingIsFromLibrary`
|
||||||
|
- Closes `CustomStepModal`, opens `PostStepActionModal`
|
||||||
|
|
||||||
|
**Add `handleSaveForLater`, `handleUseNow`, `handleBoth`** — same logic as `useCustomStepFlow` but without path/node navigation.
|
||||||
|
|
||||||
|
**"+ Add Step" button:** Renders in the right panel below `StepDetail`, above `StepFeedback`. Only shown on the current active (incomplete) step. Not shown on already-completed steps or on custom steps (no nesting).
|
||||||
|
|
||||||
|
**Session resume:** On `resumeSession`, initialize `runtimeSteps` from the tree steps then inject custom steps from `sessionData.custom_steps` at the correct positions.
|
||||||
|
|
||||||
|
### StepChecklist.tsx
|
||||||
|
|
||||||
|
Accept `RuntimeStep[]` instead of `ProceduralStep[]`. Render a small "Custom" badge (amber dot or label) next to custom step titles.
|
||||||
|
|
||||||
|
### StepDetail.tsx
|
||||||
|
|
||||||
|
Accept `RuntimeStep` instead of `ProceduralStep`. When `step.isCustom === true`, render slightly differently: no `content_type` badge (use a plain "Custom Step" label instead), show instructions as description, render commands if present using the existing command block renderer.
|
||||||
|
|
||||||
|
### New type: `RuntimeStep`
|
||||||
|
|
||||||
|
Add to `frontend/src/types/tree.ts` (or a separate `procedural.ts`):
|
||||||
|
|
||||||
|
```ts
|
||||||
|
export interface CustomProceduralStep {
|
||||||
|
id: string
|
||||||
|
type: 'procedure_step'
|
||||||
|
title: string
|
||||||
|
description?: string
|
||||||
|
content_type: 'action'
|
||||||
|
commands?: CommandBlock[]
|
||||||
|
isCustom: true
|
||||||
|
}
|
||||||
|
|
||||||
|
export type RuntimeStep = ProceduralStep | CustomProceduralStep
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What Does NOT Change
|
||||||
|
|
||||||
|
- `useCustomStepFlow` — not used at all in procedural flows
|
||||||
|
- `ContinuationModal` — not used (no branching)
|
||||||
|
- Fork flow — not used
|
||||||
|
- `CustomStepModal` — reused as-is (already works for both create and browse)
|
||||||
|
- `PostStepActionModal` — reused as-is
|
||||||
|
- `sessionsApi.update` with `custom_steps` — already supports this
|
||||||
|
- Backend — no changes needed
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Checklist render (sidebar)
|
||||||
|
|
||||||
|
Custom steps show in the checklist with an amber `✦` or small "Custom" chip:
|
||||||
|
|
||||||
|
```
|
||||||
|
✅ 1. Check service status
|
||||||
|
✅ 2. Restart broker agent
|
||||||
|
3. [✦ Custom] Verify VDA re-registration ← custom step
|
||||||
|
4. Check event log
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Completion behavior
|
||||||
|
|
||||||
|
When the engineer marks the last custom step complete and it was inserted before the last regular step, `currentStepIndex` increments normally — they continue with the remaining regular steps. The total step count in the progress bar updates when custom steps are inserted.
|
||||||
578
docs/plans/2026-02-24-procedural-custom-steps-plan.md
Normal file
578
docs/plans/2026-02-24-procedural-custom-steps-plan.md
Normal file
@@ -0,0 +1,578 @@
|
|||||||
|
# Procedural Custom Steps — Implementation Plan
|
||||||
|
|
||||||
|
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
||||||
|
|
||||||
|
**Goal:** Add "Add Custom Step" support to `ProceduralNavigationPage` so engineers can insert ad-hoc steps between existing checklist items during a session.
|
||||||
|
|
||||||
|
**Architecture:** Introduce a `RuntimeStep = ProceduralStep | CustomProceduralStep` discriminated union type. `ProceduralNavigationPage` grows new state + handlers for the custom-step modal flow (reusing `CustomStepModal` and `PostStepActionModal` as-is). `StepChecklist` and `StepDetail` accept `RuntimeStep` instead of `ProceduralStep`. No backend changes needed.
|
||||||
|
|
||||||
|
**Tech Stack:** React 19, TypeScript, Tailwind CSS, existing `sessionsApi.update`, `CustomStepModal`, `PostStepActionModal`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: Add `RuntimeStep` union type
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `frontend/src/types/tree.ts`
|
||||||
|
- Modify: `frontend/src/types/index.ts` (re-export if needed)
|
||||||
|
|
||||||
|
**Context:** `ProceduralStep` is already in `tree.ts` along with `CommandBlock`. The new `CustomProceduralStep` interface lives in the same file. No hook needed — this is purely a type.
|
||||||
|
|
||||||
|
**Step 1: Add the types at the bottom of `frontend/src/types/tree.ts`**
|
||||||
|
|
||||||
|
After the existing `ProceduralStep` interface (line ~124), add:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
export interface CustomProceduralStep {
|
||||||
|
id: string
|
||||||
|
type: 'procedure_step'
|
||||||
|
title: string
|
||||||
|
description?: string
|
||||||
|
content_type: 'action'
|
||||||
|
commands?: CommandBlock[]
|
||||||
|
isCustom: true
|
||||||
|
}
|
||||||
|
|
||||||
|
export type RuntimeStep = ProceduralStep | CustomProceduralStep
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: Export from `frontend/src/types/index.ts`**
|
||||||
|
|
||||||
|
Check what is currently re-exported from `tree.ts` in `index.ts` and add `CustomProceduralStep` and `RuntimeStep` to the export list.
|
||||||
|
|
||||||
|
Run: `cd frontend && npm run build 2>&1 | tail -20`
|
||||||
|
Expected: no new type errors
|
||||||
|
|
||||||
|
**Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add frontend/src/types/tree.ts frontend/src/types/index.ts
|
||||||
|
git commit -m "feat: add RuntimeStep union type for procedural custom steps
|
||||||
|
|
||||||
|
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 2: Update `StepChecklist` to accept `RuntimeStep[]`
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `frontend/src/components/procedural/StepChecklist.tsx`
|
||||||
|
|
||||||
|
**Context:** Currently `StepChecklistProps.steps` is `ProceduralStep[]`. It filters internally for `type === 'procedure_step'`. Custom steps already have `type: 'procedure_step'` so the filter still works. We need to:
|
||||||
|
1. Change prop type to `RuntimeStep[]`
|
||||||
|
2. Render an amber "Custom" badge next to custom step titles
|
||||||
|
|
||||||
|
**Step 1: Update the import and prop type**
|
||||||
|
|
||||||
|
Change:
|
||||||
|
```tsx
|
||||||
|
import type { ProceduralStep } from '@/types'
|
||||||
|
```
|
||||||
|
To:
|
||||||
|
```tsx
|
||||||
|
import type { RuntimeStep } from '@/types'
|
||||||
|
```
|
||||||
|
|
||||||
|
Change `StepChecklistProps`:
|
||||||
|
```tsx
|
||||||
|
interface StepChecklistProps {
|
||||||
|
steps: RuntimeStep[]
|
||||||
|
currentStepIndex: number
|
||||||
|
completedStepIds: Set<string>
|
||||||
|
onStepClick: (index: number) => void
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: Add the "Custom" badge in the step row**
|
||||||
|
|
||||||
|
In the button's `<span className="min-w-0 flex-1 truncate">` row, add a badge after the title when `'isCustom' in step && step.isCustom`:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<span className="min-w-0 flex-1 flex items-center gap-1.5 truncate">
|
||||||
|
<span className="truncate">{step.title || 'Untitled step'}</span>
|
||||||
|
{'isCustom' in step && step.isCustom && (
|
||||||
|
<span className="shrink-0 rounded-full bg-amber-400/15 px-1.5 py-0.5 text-[9px] font-semibold uppercase tracking-wide text-amber-400">
|
||||||
|
Custom
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 3: Build check**
|
||||||
|
|
||||||
|
Run: `cd frontend && npm run build 2>&1 | tail -20`
|
||||||
|
Expected: no new errors
|
||||||
|
|
||||||
|
**Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add frontend/src/components/procedural/StepChecklist.tsx
|
||||||
|
git commit -m "feat: StepChecklist accepts RuntimeStep[], renders amber Custom badge
|
||||||
|
|
||||||
|
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 3: Update `StepDetail` to accept `RuntimeStep`
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `frontend/src/components/procedural/StepDetail.tsx`
|
||||||
|
|
||||||
|
**Context:** `StepDetail` currently only accepts `ProceduralStep`. For `CustomProceduralStep`, we skip the `content_type` badge (show "Custom Step" label instead), hide `warning_text`/`expected_outcome`/`verification`/`reference_url` sections (they don't exist on custom steps), and show description + commands normally. The `canComplete()` check and Mark Complete button stay the same.
|
||||||
|
|
||||||
|
**Step 1: Update import and prop type**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import type { RuntimeStep, CommandBlock } from '@/types'
|
||||||
|
```
|
||||||
|
|
||||||
|
Change `StepDetailProps`:
|
||||||
|
```tsx
|
||||||
|
interface StepDetailProps {
|
||||||
|
step: RuntimeStep
|
||||||
|
// ... rest unchanged
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: Handle custom step header (replace content_type badge block)**
|
||||||
|
|
||||||
|
The existing header block uses `step.content_type` to pick a config. Add a guard:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// At top of component body, after existing state:
|
||||||
|
const isCustom = 'isCustom' in step && step.isCustom
|
||||||
|
|
||||||
|
// In the header JSX, replace the content_type badge span:
|
||||||
|
{isCustom ? (
|
||||||
|
<span className="inline-flex items-center gap-1 rounded-full bg-amber-400/15 px-2 py-0.5 text-xs text-amber-400">
|
||||||
|
✦ Custom Step
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className={cn('inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs', config.bg, config.color)}>
|
||||||
|
<Icon className="h-3 w-3" />
|
||||||
|
{config.label}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
```
|
||||||
|
|
||||||
|
Note: `contentType` and `config` are still computed but only used in the non-custom branch. TypeScript will be happy because `ProceduralStep` has `content_type` and custom step skips that branch.
|
||||||
|
|
||||||
|
**Step 3: Guard sections that don't exist on custom steps**
|
||||||
|
|
||||||
|
Sections that need guarding (wrap with `!isCustom &&`):
|
||||||
|
- `{step.warning_text && ...}` — already implicitly guarded since `CustomProceduralStep` has no `warning_text`, but TypeScript may complain → add `!isCustom &&` before the expression
|
||||||
|
- `{step.expected_outcome && ...}` — same
|
||||||
|
- `{verificationPrompt && ...}` — same (derive `verificationPrompt` with `!isCustom &&` check)
|
||||||
|
- `{step.reference_url && ...}` — same
|
||||||
|
|
||||||
|
The description block, commandBlocks block, and notes block all work fine as-is (custom steps have `description?` and `commands?` matching the normalized shapes).
|
||||||
|
|
||||||
|
Specifically, for `verificationPrompt` / `verificationType`:
|
||||||
|
```tsx
|
||||||
|
const verificationPrompt = isCustom ? undefined : (step.verification_prompt || step.verification?.prompt)
|
||||||
|
const verificationType = isCustom ? undefined : (step.verification_type || step.verification?.type)
|
||||||
|
```
|
||||||
|
|
||||||
|
For `commandBlocks` normalization — `CustomProceduralStep.commands` is `CommandBlock[]` already, so the existing ternary handles it fine.
|
||||||
|
|
||||||
|
**Step 4: Build check**
|
||||||
|
|
||||||
|
Run: `cd frontend && npm run build 2>&1 | tail -20`
|
||||||
|
Expected: no errors
|
||||||
|
|
||||||
|
**Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add frontend/src/components/procedural/StepDetail.tsx
|
||||||
|
git commit -m "feat: StepDetail accepts RuntimeStep, renders Custom Step badge for custom steps
|
||||||
|
|
||||||
|
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 4: Wire custom step flow into `ProceduralNavigationPage`
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `frontend/src/pages/ProceduralNavigationPage.tsx`
|
||||||
|
|
||||||
|
**Context:** This is the main task. Current page has no custom step support. We add:
|
||||||
|
- `runtimeSteps: RuntimeStep[]` state (replaces `procedureSteps` derived value)
|
||||||
|
- `sessionCustomSteps: CustomStep[]` state
|
||||||
|
- `showCustomStepModal`, `showPostStepModal`, `pendingCustomStep`, `pendingIsFromLibrary`, `isSavingStep` state
|
||||||
|
- `handleStepCreated` — closes CustomStepModal, opens PostStepActionModal
|
||||||
|
- `handleInsertCustomStep` — builds CustomProceduralStep, inserts into runtimeSteps, persists
|
||||||
|
- `handleSaveForLater` / `handleUseNow` / `handleBoth` — wire PostStepActionModal buttons
|
||||||
|
- "Add Step" button below StepDetail, above StepFeedback
|
||||||
|
- Resume: inject custom steps at correct positions
|
||||||
|
|
||||||
|
**Step 1: Add new imports**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { CustomStepModal } from '@/components/step-library/CustomStepModal'
|
||||||
|
import { PostStepActionModal } from '@/components/session/PostStepActionModal'
|
||||||
|
import type { RuntimeStep, CustomProceduralStep } from '@/types'
|
||||||
|
import type { CustomStep } from '@/types/session'
|
||||||
|
import type { Step } from '@/types/step'
|
||||||
|
import type { CustomStepDraft } from '@/components/step-library/CustomStepModal'
|
||||||
|
import { Plus } from 'lucide-react'
|
||||||
|
import { v4 as uuidv4 } from 'uuid'
|
||||||
|
```
|
||||||
|
|
||||||
|
Check if `uuid` package is already available: `grep -r "from 'uuid'" frontend/src/`. If not, use `crypto.randomUUID()` instead (available in all modern browsers and Node 16+).
|
||||||
|
|
||||||
|
**Step 2: Add new state (after existing state declarations)**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
const [runtimeSteps, setRuntimeSteps] = useState<RuntimeStep[]>([])
|
||||||
|
const [sessionCustomSteps, setSessionCustomSteps] = useState<CustomStep[]>([])
|
||||||
|
const [showCustomStepModal, setShowCustomStepModal] = useState(false)
|
||||||
|
const [showPostStepModal, setShowPostStepModal] = useState(false)
|
||||||
|
const [pendingCustomStep, setPendingCustomStep] = useState<Step | CustomStepDraft | null>(null)
|
||||||
|
const [pendingIsFromLibrary, setPendingIsFromLibrary] = useState(false)
|
||||||
|
const [isSavingStep, setIsSavingStep] = useState(false)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 3: Replace `procedureSteps` derivation**
|
||||||
|
|
||||||
|
Currently:
|
||||||
|
```tsx
|
||||||
|
const procedureSteps = steps.filter((s) => s.type === 'procedure_step')
|
||||||
|
```
|
||||||
|
|
||||||
|
Replace with:
|
||||||
|
```tsx
|
||||||
|
// runtimeSteps is the authoritative list; filter for rendering (excludes section_headers)
|
||||||
|
const procedureSteps = runtimeSteps.filter((s) => s.type === 'procedure_step')
|
||||||
|
```
|
||||||
|
|
||||||
|
Also update `estimatedTotalMinutes` — it only sums `estimated_minutes` which only exists on `ProceduralStep`. Cast safely:
|
||||||
|
```tsx
|
||||||
|
const estimatedTotalMinutes = procedureSteps.reduce(
|
||||||
|
(sum, step) => sum + (('estimated_minutes' in step ? step.estimated_minutes : undefined) || 0),
|
||||||
|
0
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 4: Initialize `runtimeSteps` in `startSession`**
|
||||||
|
|
||||||
|
After `setSession(newSession)`, add:
|
||||||
|
```tsx
|
||||||
|
// Initialize runtimeSteps from tree steps
|
||||||
|
const allSteps = getStepsFromTree(tree!)
|
||||||
|
setRuntimeSteps(allSteps)
|
||||||
|
setSessionCustomSteps([])
|
||||||
|
```
|
||||||
|
|
||||||
|
The existing step state initialization loop stays the same — custom steps will add to it when inserted.
|
||||||
|
|
||||||
|
**Step 5: Initialize `runtimeSteps` in `resumeSession`**
|
||||||
|
|
||||||
|
After loading `sessionData`, before computing `pSteps`:
|
||||||
|
```tsx
|
||||||
|
// Build runtimeSteps: start with tree steps, then inject custom steps
|
||||||
|
const allSteps = getStepsFromTree(treeData)
|
||||||
|
const customSteps = sessionData.custom_steps || []
|
||||||
|
setSessionCustomSteps(customSteps)
|
||||||
|
|
||||||
|
// Inject custom steps at correct positions
|
||||||
|
const hydrated = buildRuntimeSteps(allSteps, customSteps)
|
||||||
|
setRuntimeSteps(hydrated)
|
||||||
|
```
|
||||||
|
|
||||||
|
And update the `pSteps` / `firstIncomplete` calculation to use `hydrated` (not `allSteps`):
|
||||||
|
```tsx
|
||||||
|
const pSteps = hydrated.filter((s) => s.type === 'procedure_step')
|
||||||
|
const firstIncomplete = pSteps.findIndex((s) => !initialStates.get(s.id)?.completedAt)
|
||||||
|
setCurrentStepIndex(firstIncomplete >= 0 ? firstIncomplete : pSteps.length - 1)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 6: Add `buildRuntimeSteps` helper**
|
||||||
|
|
||||||
|
Add this function before the component (or as a module-level utility):
|
||||||
|
|
||||||
|
```ts
|
||||||
|
function buildRuntimeSteps(baseSteps: ProceduralStep[], customSteps: CustomStep[]): RuntimeStep[] {
|
||||||
|
const result: RuntimeStep[] = [...baseSteps]
|
||||||
|
// Sort custom steps by timestamp so earlier insertions come first if multiple
|
||||||
|
const sorted = [...customSteps].sort((a, b) => a.timestamp.localeCompare(b.timestamp))
|
||||||
|
for (const cs of sorted) {
|
||||||
|
// Find the index of the step this was inserted after
|
||||||
|
const afterIdx = result.findIndex((s) => s.id === cs.inserted_after_node_id)
|
||||||
|
const insertAt = afterIdx >= 0 ? afterIdx + 1 : result.length
|
||||||
|
const runtimeCustom: CustomProceduralStep = {
|
||||||
|
id: cs.id,
|
||||||
|
type: 'procedure_step',
|
||||||
|
title: cs.step_data.title,
|
||||||
|
description: cs.step_data.content?.instructions,
|
||||||
|
content_type: 'action',
|
||||||
|
commands: cs.step_data.content?.commands?.map((c) => ({
|
||||||
|
code: c.command,
|
||||||
|
label: c.label,
|
||||||
|
})),
|
||||||
|
isCustom: true,
|
||||||
|
}
|
||||||
|
result.splice(insertAt, 0, runtimeCustom)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Note: `ProceduralStep` needs to be imported in scope here. Since it's used in `getStepsFromTree` already, it's available.
|
||||||
|
|
||||||
|
**Step 7: Add `handleStepCreated`**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
const handleStepCreated = (step: Step | CustomStepDraft, isFromLibrary: boolean) => {
|
||||||
|
setPendingCustomStep(step)
|
||||||
|
setPendingIsFromLibrary(isFromLibrary)
|
||||||
|
setShowCustomStepModal(false)
|
||||||
|
setShowPostStepModal(true)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 8: Add `handleInsertCustomStep`**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
const handleInsertCustomStep = async (step: Step | CustomStepDraft) => {
|
||||||
|
if (!session) return
|
||||||
|
|
||||||
|
const id = crypto.randomUUID()
|
||||||
|
const currentStep = procedureSteps[currentStepIndex]
|
||||||
|
const insertedAfterId = currentStep?.id ?? ''
|
||||||
|
|
||||||
|
// Build the runtime representation
|
||||||
|
const runtimeCustom: CustomProceduralStep = {
|
||||||
|
id,
|
||||||
|
type: 'procedure_step',
|
||||||
|
title: step.title,
|
||||||
|
description: step.content?.instructions,
|
||||||
|
content_type: 'action',
|
||||||
|
commands: step.content?.commands?.map((c) => ({
|
||||||
|
code: c.command,
|
||||||
|
label: c.label,
|
||||||
|
})),
|
||||||
|
isCustom: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Insert after currentStepIndex in runtimeSteps
|
||||||
|
setRuntimeSteps((prev) => {
|
||||||
|
const next = [...prev]
|
||||||
|
// Find the global index of the current procedureStep in runtimeSteps
|
||||||
|
const globalIdx = next.findIndex((s) => s.id === insertedAfterId)
|
||||||
|
const insertAt = globalIdx >= 0 ? globalIdx + 1 : next.length
|
||||||
|
next.splice(insertAt, 0, runtimeCustom)
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
|
||||||
|
// Initialize step state for the new step
|
||||||
|
setStepStates((prev) => {
|
||||||
|
const next = new Map(prev)
|
||||||
|
next.set(id, { notes: '', verificationValue: '', completedAt: null })
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
|
||||||
|
// Persist to session
|
||||||
|
const newCustomStep: CustomStep = {
|
||||||
|
id,
|
||||||
|
inserted_after_node_id: insertedAfterId,
|
||||||
|
step_data: step,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
}
|
||||||
|
const newCustomSteps = [...sessionCustomSteps, newCustomStep]
|
||||||
|
setSessionCustomSteps(newCustomSteps)
|
||||||
|
|
||||||
|
try {
|
||||||
|
await sessionsApi.update(session.id, { custom_steps: newCustomSteps })
|
||||||
|
} catch {
|
||||||
|
toast.error('Failed to save custom step')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Advance to the new step (it's now at currentStepIndex + 1)
|
||||||
|
setCurrentStepIndex(currentStepIndex + 1)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 9: Add `handleSaveForLater`, `handleUseNow`, `handleBoth`**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
const handleSaveForLater = async () => {
|
||||||
|
if (!pendingCustomStep || pendingIsFromLibrary) return
|
||||||
|
setIsSavingStep(true)
|
||||||
|
try {
|
||||||
|
await stepsApi.create({
|
||||||
|
title: pendingCustomStep.title,
|
||||||
|
step_type: pendingCustomStep.step_type,
|
||||||
|
content: pendingCustomStep.content,
|
||||||
|
visibility: 'private',
|
||||||
|
})
|
||||||
|
toast.success('Step saved to library')
|
||||||
|
} catch {
|
||||||
|
toast.error('Failed to save step')
|
||||||
|
} finally {
|
||||||
|
setIsSavingStep(false)
|
||||||
|
setShowPostStepModal(false)
|
||||||
|
setPendingCustomStep(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleUseNow = async () => {
|
||||||
|
if (!pendingCustomStep) return
|
||||||
|
setShowPostStepModal(false)
|
||||||
|
await handleInsertCustomStep(pendingCustomStep)
|
||||||
|
setPendingCustomStep(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleBoth = async () => {
|
||||||
|
if (!pendingCustomStep || pendingIsFromLibrary) return
|
||||||
|
setIsSavingStep(true)
|
||||||
|
try {
|
||||||
|
await stepsApi.create({
|
||||||
|
title: pendingCustomStep.title,
|
||||||
|
step_type: pendingCustomStep.step_type,
|
||||||
|
content: pendingCustomStep.content,
|
||||||
|
visibility: 'private',
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
toast.error('Failed to save step to library')
|
||||||
|
} finally {
|
||||||
|
setIsSavingStep(false)
|
||||||
|
}
|
||||||
|
setShowPostStepModal(false)
|
||||||
|
await handleInsertCustomStep(pendingCustomStep)
|
||||||
|
setPendingCustomStep(null)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Add `stepsApi` import at the top: `import { stepsApi } from '@/api/steps'`
|
||||||
|
|
||||||
|
**Step 10: Update `handleMarkComplete` to use `runtimeSteps`-based `procedureSteps`**
|
||||||
|
|
||||||
|
The existing `handleMarkComplete` reads `procedureSteps[currentStepIndex]` — since `procedureSteps` is now derived from `runtimeSteps`, this Just Works. But the completion check `currentStepIndex >= procedureSteps.length - 1` also works correctly.
|
||||||
|
|
||||||
|
No changes needed to `handleMarkComplete` itself.
|
||||||
|
|
||||||
|
**Step 11: Update `StepChecklist` call in JSX**
|
||||||
|
|
||||||
|
Change `steps={steps}` to `steps={runtimeSteps}`:
|
||||||
|
```tsx
|
||||||
|
<StepChecklist
|
||||||
|
steps={runtimeSteps}
|
||||||
|
currentStepIndex={currentStepIndex}
|
||||||
|
completedStepIds={completedStepIds}
|
||||||
|
onStepClick={setCurrentStepIndex}
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 12: Update `StepDetail` call + add "Add Step" button**
|
||||||
|
|
||||||
|
Change `step={currentStep}` prop type is now `RuntimeStep` (TypeScript satisfied since `procedureSteps` is `RuntimeStep[]`).
|
||||||
|
|
||||||
|
Change `totalSteps={procedureSteps.length}` — still correct.
|
||||||
|
|
||||||
|
After the `StepDetail` closing tag and before `StepFeedback`, add the "Add Step" button:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
{/* Add Custom Step button — only on current active (incomplete) step, not on custom steps */}
|
||||||
|
{currentStep && !completedStepIds.has(currentStep.id) && !('isCustom' in currentStep && currentStep.isCustom) && (
|
||||||
|
<div className="mt-4">
|
||||||
|
<button
|
||||||
|
onClick={() => setShowCustomStepModal(true)}
|
||||||
|
className="flex w-full items-center justify-center gap-2 rounded-lg border border-dashed border-border px-4 py-2.5 text-sm text-muted-foreground transition-colors hover:border-primary/50 hover:text-foreground"
|
||||||
|
>
|
||||||
|
<Plus className="h-4 w-4" />
|
||||||
|
Add Step
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 13: Add modals at the bottom of the JSX (before closing `</div>`)**
|
||||||
|
|
||||||
|
After the `ConfirmDialog` and parameters popover, add:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
{/* Custom Step Modal */}
|
||||||
|
<CustomStepModal
|
||||||
|
isOpen={showCustomStepModal}
|
||||||
|
onClose={() => setShowCustomStepModal(false)}
|
||||||
|
onInsertStep={handleStepCreated}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Post Step Action Modal */}
|
||||||
|
{pendingCustomStep && (
|
||||||
|
<PostStepActionModal
|
||||||
|
isOpen={showPostStepModal}
|
||||||
|
onClose={() => { setShowPostStepModal(false); setPendingCustomStep(null) }}
|
||||||
|
step={pendingCustomStep}
|
||||||
|
onSaveForLater={handleSaveForLater}
|
||||||
|
onUseNow={handleUseNow}
|
||||||
|
onBoth={handleBoth}
|
||||||
|
isFromLibrary={pendingIsFromLibrary}
|
||||||
|
isSaving={isSavingStep}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 14: ProgressBar total count update**
|
||||||
|
|
||||||
|
The `ProgressBar` already uses `procedureSteps.length` as `totalSteps` which is derived from `runtimeSteps` — so the progress bar total automatically updates when custom steps are inserted.
|
||||||
|
|
||||||
|
**Step 15: Build check**
|
||||||
|
|
||||||
|
Run: `cd frontend && npm run build 2>&1 | tail -30`
|
||||||
|
Expected: no errors. Watch especially for:
|
||||||
|
- `CustomStep` import (it's in `@/types/session`, not `@/types` — import directly if needed)
|
||||||
|
- `buildRuntimeSteps` needing `ProceduralStep` type explicitly imported
|
||||||
|
|
||||||
|
**Step 16: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add frontend/src/pages/ProceduralNavigationPage.tsx
|
||||||
|
git commit -m "feat: custom step insertion in procedural flow sessions
|
||||||
|
|
||||||
|
Engineers can add custom steps inline during execution. Steps are
|
||||||
|
persisted to session.custom_steps and restored on resume.
|
||||||
|
|
||||||
|
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 5: Manual verification checklist
|
||||||
|
|
||||||
|
**No code changes — verification only.**
|
||||||
|
|
||||||
|
Start a procedural session (or maintenance session). Verify:
|
||||||
|
|
||||||
|
1. **Add Step button appears** below the current step detail on an active, incomplete, non-custom step
|
||||||
|
2. **Add Step button is hidden** on completed steps
|
||||||
|
3. **Clicking Add Step** opens `CustomStepModal` with Create + Browse Library tabs
|
||||||
|
4. **Creating a step** → `PostStepActionModal` appears
|
||||||
|
5. **"Use Now"** → closes modal, custom step appears as next step in checklist with amber "Custom" badge, current view advances to it
|
||||||
|
6. **"Save for Later"** → closes modal, nothing inserted, no crash
|
||||||
|
7. **"Do Both"** → saves to library AND inserts
|
||||||
|
8. **Mark Complete on custom step** → advances to next step normally
|
||||||
|
9. **Custom step in StepDetail** → shows "Custom Step" amber badge instead of content_type badge
|
||||||
|
10. **Progress bar total** increments when custom step is inserted
|
||||||
|
11. **Resume session** (navigate away and back with `{ state: { sessionId } }`) → custom step reappears at correct position in checklist
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 6: Final build validation
|
||||||
|
|
||||||
|
Run: `cd frontend && npm run build`
|
||||||
|
Expected: build succeeds with zero errors (pre-existing chunk-size warnings are fine).
|
||||||
|
|
||||||
|
Commit build validation result if no issues:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add -p # stage any incidental fixes
|
||||||
|
git commit -m "chore: final build validation for procedural custom steps
|
||||||
|
|
||||||
|
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>"
|
||||||
|
```
|
||||||
|
|
||||||
|
Only commit if there were actual changes to stage.
|
||||||
175
docs/plans/2026-02-24-step-library-page-design.md
Normal file
175
docs/plans/2026-02-24-step-library-page-design.md
Normal file
@@ -0,0 +1,175 @@
|
|||||||
|
# Step Library Page — Design
|
||||||
|
|
||||||
|
> **Date:** February 24, 2026
|
||||||
|
> **Status:** Approved
|
||||||
|
> **Phase:** 2.5 — Feature 1 of 3
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What We're Building
|
||||||
|
|
||||||
|
Replace the "Coming Soon" stub in `StepLibraryPage` with a fully functional standalone Step Library. Users can browse, search, create, edit, delete, preview, and save steps to their own library.
|
||||||
|
|
||||||
|
All backend endpoints and frontend components are already built. This is primarily a wiring and UX integration task.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
**In scope:**
|
||||||
|
- Full-page Step Library with header, filters, grouped results
|
||||||
|
- Create step flow (modal with `StepForm`)
|
||||||
|
- Edit step flow (same modal, pre-filled)
|
||||||
|
- Delete step with confirmation
|
||||||
|
- "Save to My Library" for steps you don't own (copies step as private)
|
||||||
|
- Preview via `StepDetailModal`
|
||||||
|
- `StepCard` actions adapted for library context (vs. session insert context)
|
||||||
|
|
||||||
|
**Out of scope:**
|
||||||
|
- Rating/reviewing steps from this page (deferred — will come when steps are used in sessions)
|
||||||
|
- Admin category management (already exists in Admin Panel)
|
||||||
|
- Session custom step insertion (Feature 2 of 3)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Card Actions by Context
|
||||||
|
|
||||||
|
| Ownership | Actions |
|
||||||
|
|-----------|---------|
|
||||||
|
| Your own step | Preview · Edit · Delete |
|
||||||
|
| Team or public step | Preview · Save to My Library |
|
||||||
|
|
||||||
|
"Save to My Library" POSTs a new step copying the title, step_type, content, category, and tags — with `visibility: 'private'` and the current user as owner.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Page Layout
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────┐
|
||||||
|
│ Step Library [+ Create Step] │
|
||||||
|
│ Reusable steps you can insert into any flow │
|
||||||
|
├─────────────────────────────────────────────────────────┤
|
||||||
|
│ [Search...] [Category ▼] [Type ▼] [Rating ▼] [Sort▼]│
|
||||||
|
│ Popular Tags: [powershell] [dns] [quick-fix] ... │
|
||||||
|
├─────────────────────────────────────────────────────────┤
|
||||||
|
│ MY STEPS (3) [▲] │
|
||||||
|
│ ┌────────────┐ ┌────────────┐ │
|
||||||
|
│ │ StepCard │ │ StepCard │ (Preview · Edit · Del) │
|
||||||
|
│ └────────────┘ └────────────┘ │
|
||||||
|
│ │
|
||||||
|
│ TEAM STEPS (12) [▲] │
|
||||||
|
│ ┌────────────┐ ┌────────────┐ │
|
||||||
|
│ │ StepCard │ │ StepCard │ (Preview · Save) │
|
||||||
|
│ └────────────┘ └────────────┘ │
|
||||||
|
│ │
|
||||||
|
│ COMMUNITY (47) [▲] │
|
||||||
|
│ ┌────────────┐ ┌────────────┐ │
|
||||||
|
│ │ StepCard │ │ StepCard │ (Preview · Save) │
|
||||||
|
│ └────────────┘ └────────────┘ │
|
||||||
|
└─────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Component Architecture
|
||||||
|
|
||||||
|
### StepLibraryPage (rewritten)
|
||||||
|
- Owns modal state: `createOpen`, `editingStep`, `deletingStepId`
|
||||||
|
- Renders page header with "+ Create Step" button
|
||||||
|
- Renders `StepLibraryBrowser` with page-context props
|
||||||
|
- Renders `StepFormModal` (create/edit)
|
||||||
|
- Renders delete confirmation dialog
|
||||||
|
|
||||||
|
### StepLibraryBrowser (extend props)
|
||||||
|
Add props:
|
||||||
|
```ts
|
||||||
|
interface StepLibraryBrowserProps {
|
||||||
|
onInsert: (step: Step) => void // existing (used in session context)
|
||||||
|
onEdit?: (step: Step) => void // new — library page only
|
||||||
|
onDelete?: (stepId: string) => void // new — library page only
|
||||||
|
onSave?: (step: Step) => void // new — save to my library
|
||||||
|
onCreateNew?: () => void // existing
|
||||||
|
showCreateButton?: boolean // existing
|
||||||
|
currentUserId?: string // new — to determine ownership
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### StepCard (extend props)
|
||||||
|
Add props mirroring the browser:
|
||||||
|
```ts
|
||||||
|
interface StepCardProps {
|
||||||
|
step: StepListItem
|
||||||
|
onPreview: (step: StepListItem) => void
|
||||||
|
onInsert?: (step: StepListItem) => void // optional — session context
|
||||||
|
onEdit?: (step: StepListItem) => void // library context
|
||||||
|
onDelete?: (stepId: string) => void // library context
|
||||||
|
onSave?: (step: StepListItem) => void // library context
|
||||||
|
currentUserId?: string
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`StepCard` renders actions based on which callbacks are present and whether `step.created_by === currentUserId`.
|
||||||
|
|
||||||
|
### StepFormModal (new wrapper component)
|
||||||
|
Thin modal shell around the existing `StepForm`:
|
||||||
|
- Fixed header ("Create Step" or "Edit Step")
|
||||||
|
- Scrollable body with `StepForm`
|
||||||
|
- Handles create (`stepsApi.create`) and update (`stepsApi.update`) API calls
|
||||||
|
- Calls `onSuccess(step)` so the browser list can refresh
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Data Flow
|
||||||
|
|
||||||
|
### Create
|
||||||
|
1. User clicks "+ Create Step"
|
||||||
|
2. `StepFormModal` opens in create mode
|
||||||
|
3. On submit → `stepsApi.create(data)` → `onSuccess` → reload steps
|
||||||
|
|
||||||
|
### Edit
|
||||||
|
1. User clicks Edit on their own step card
|
||||||
|
2. `stepsApi.get(stepId)` fetches full step (need content fields)
|
||||||
|
3. `StepFormModal` opens pre-filled with step data
|
||||||
|
4. On submit → `stepsApi.update(id, data)` → `onSuccess` → reload steps
|
||||||
|
|
||||||
|
### Delete
|
||||||
|
1. User clicks Delete on their own step card
|
||||||
|
2. Confirmation dialog: "Delete '[title]'? This cannot be undone."
|
||||||
|
3. On confirm → `stepsApi.delete(id)` → remove from local state
|
||||||
|
|
||||||
|
### Save to My Library
|
||||||
|
1. User clicks "Save to My Library" on a team/public step
|
||||||
|
2. No confirmation needed — silent copy
|
||||||
|
3. `stepsApi.create({ ...step fields, visibility: 'private' })` → toast "Saved to My Steps"
|
||||||
|
4. Reload steps so the copy appears under "My Steps"
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Files to Change
|
||||||
|
|
||||||
|
| File | Change |
|
||||||
|
|------|--------|
|
||||||
|
| `pages/StepLibraryPage.tsx` | Rewrite — wire up browser + modals |
|
||||||
|
| `components/step-library/StepCard.tsx` | Add `onEdit`, `onDelete`, `onSave`, `currentUserId` props; render contextual buttons |
|
||||||
|
| `components/step-library/StepLibraryBrowser.tsx` | Add `onEdit`, `onDelete`, `onSave`, `currentUserId` props; pass through to cards; expose refresh trigger |
|
||||||
|
| `components/step-library/StepFormModal.tsx` | Create — thin modal wrapper around `StepForm` |
|
||||||
|
|
||||||
|
No backend changes required.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## StepCard Button Layout
|
||||||
|
|
||||||
|
**Own step:**
|
||||||
|
```
|
||||||
|
[Preview] [Edit] [🗑]
|
||||||
|
```
|
||||||
|
Edit and Preview are full-width-ish buttons; delete is an icon-only button in red on hover.
|
||||||
|
|
||||||
|
**Others' step:**
|
||||||
|
```
|
||||||
|
[Preview] [Save to My Library]
|
||||||
|
```
|
||||||
|
|
||||||
|
Both full-width buttons, same pattern as current Insert layout.
|
||||||
705
docs/plans/2026-02-24-step-library-page-plan.md
Normal file
705
docs/plans/2026-02-24-step-library-page-plan.md
Normal file
@@ -0,0 +1,705 @@
|
|||||||
|
# Step Library Page Implementation Plan
|
||||||
|
|
||||||
|
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
||||||
|
|
||||||
|
**Goal:** Wire up the existing Step Library components into a fully functional standalone page — replacing the "Coming Soon" stub — with create, edit, delete, preview, and save-to-library actions.
|
||||||
|
|
||||||
|
**Architecture:** `StepLibraryPage` owns all modal state and orchestrates four components: `StepLibraryBrowser` (list + filters), `StepFormModal` (new wrapper for create/edit), `StepDetailModal` (already exists), and a delete confirmation dialog. `StepCard` and `StepLibraryBrowser` get new optional props for library-page-specific actions. No new API calls beyond what already exists in `stepsApi`.
|
||||||
|
|
||||||
|
**Tech Stack:** React 19, TypeScript, Tailwind CSS, Zustand (`useAuthStore` for current user ID), `stepsApi` + `stepCategoriesApi` (all endpoints already wired to backend).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Key Files Reference
|
||||||
|
|
||||||
|
- `frontend/src/pages/StepLibraryPage.tsx` — currently a "Coming Soon" stub; will be rewritten
|
||||||
|
- `frontend/src/components/step-library/StepLibraryBrowser.tsx` — list + filters component
|
||||||
|
- `frontend/src/components/step-library/StepCard.tsx` — individual step card
|
||||||
|
- `frontend/src/components/step-library/StepDetailModal.tsx` — preview modal (already complete)
|
||||||
|
- `frontend/src/components/step-library/StepForm.tsx` — create/edit form (already complete)
|
||||||
|
- `frontend/src/api/steps.ts` — `stepsApi.create`, `.get`, `.update`, `.delete` already implemented
|
||||||
|
- `frontend/src/types/step.ts` — `Step`, `StepListItem`, `StepCreate`, `StepUpdate` types
|
||||||
|
- `frontend/src/store/authStore.ts` — use `useAuthStore((s) => s.user)` to get current user
|
||||||
|
- `frontend/src/hooks/usePermissions.ts` — `canCreateSteps` already defined
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## How to Get Current User ID
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { useAuthStore } from '@/store/authStore'
|
||||||
|
const user = useAuthStore((s) => s.user)
|
||||||
|
// user.id is the current user's UUID string
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 1: Extend StepCard with library-page actions
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `frontend/src/components/step-library/StepCard.tsx`
|
||||||
|
|
||||||
|
This task adds `onEdit`, `onDelete`, `onSave`, and `currentUserId` props. When on the library page (these props are present), the action buttons change based on ownership.
|
||||||
|
|
||||||
|
**Step 1: Read the current file**
|
||||||
|
|
||||||
|
Already read above. Current interface:
|
||||||
|
```ts
|
||||||
|
interface StepCardProps {
|
||||||
|
step: StepListItem
|
||||||
|
onPreview: (step: StepListItem) => void
|
||||||
|
onInsert: (step: StepListItem) => void
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: Update the interface and button logic**
|
||||||
|
|
||||||
|
Replace the `StepCardProps` interface and the Actions section at the bottom of `StepCard.tsx`.
|
||||||
|
|
||||||
|
New interface (all new props are optional so existing `CustomStepModal` usage is unchanged):
|
||||||
|
```tsx
|
||||||
|
interface StepCardProps {
|
||||||
|
step: StepListItem
|
||||||
|
onPreview: (step: StepListItem) => void
|
||||||
|
onInsert?: (step: StepListItem) => void // session context (existing)
|
||||||
|
onEdit?: (step: StepListItem) => void // library page
|
||||||
|
onDelete?: (stepId: string) => void // library page
|
||||||
|
onSave?: (step: StepListItem) => void // library page (save copy)
|
||||||
|
currentUserId?: string // to determine ownership
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Replace the Actions section (the `<div className="flex gap-2">` at the bottom) with:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{/* Library page context */}
|
||||||
|
{(onEdit || onDelete || onSave) ? (
|
||||||
|
isOwn ? (
|
||||||
|
// Own step: Preview + Edit + Delete icon
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
onClick={() => onPreview(step)}
|
||||||
|
className="flex flex-1 items-center justify-center gap-2 rounded-md border border-border px-3 py-2 text-sm font-medium text-muted-foreground hover:bg-accent hover:text-foreground transition-colors"
|
||||||
|
>
|
||||||
|
<Eye className="h-4 w-4" />
|
||||||
|
Preview
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => onEdit?.(step)}
|
||||||
|
className="flex flex-1 items-center justify-center gap-2 rounded-md border border-border px-3 py-2 text-sm font-medium text-muted-foreground hover:bg-accent hover:text-foreground transition-colors"
|
||||||
|
>
|
||||||
|
<Pencil className="h-4 w-4" />
|
||||||
|
Edit
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => onDelete?.(step.id)}
|
||||||
|
className="flex items-center justify-center rounded-md border border-border px-3 py-2 text-muted-foreground hover:bg-red-400/10 hover:text-red-400 hover:border-red-400/30 transition-colors"
|
||||||
|
aria-label="Delete step"
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
// Others' step: Preview + Save
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
onClick={() => onPreview(step)}
|
||||||
|
className="flex flex-1 items-center justify-center gap-2 rounded-md border border-border px-3 py-2 text-sm font-medium text-muted-foreground hover:bg-accent hover:text-foreground transition-colors"
|
||||||
|
>
|
||||||
|
<Eye className="h-4 w-4" />
|
||||||
|
Preview
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => onSave?.(step)}
|
||||||
|
className="flex flex-1 items-center justify-center gap-2 rounded-md bg-gradient-brand text-white shadow-lg shadow-primary/20 px-3 py-2 text-sm font-medium hover:opacity-90 transition-colors"
|
||||||
|
>
|
||||||
|
<Bookmark className="h-4 w-4" />
|
||||||
|
Save
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
// Session context (original): Preview + Insert
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
onClick={() => onPreview(step)}
|
||||||
|
className="flex flex-1 items-center justify-center gap-2 rounded-md border border-border px-3 py-2 text-sm font-medium text-muted-foreground hover:bg-accent hover:text-foreground transition-colors"
|
||||||
|
>
|
||||||
|
<Eye className="h-4 w-4" />
|
||||||
|
Preview
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => onInsert?.(step)}
|
||||||
|
className="flex flex-1 items-center justify-center gap-2 rounded-md bg-gradient-brand text-white shadow-lg shadow-primary/20 px-3 py-2 text-sm font-medium hover:opacity-90 transition-colors"
|
||||||
|
>
|
||||||
|
<Plus className="h-4 w-4" />
|
||||||
|
Insert
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
Add `isOwn` derived value near top of the component function (before the return):
|
||||||
|
```tsx
|
||||||
|
const isOwn = currentUserId ? step.created_by === currentUserId : false
|
||||||
|
```
|
||||||
|
|
||||||
|
Add new imports at top of file:
|
||||||
|
```tsx
|
||||||
|
import { Star, User, Calendar, TrendingUp, Eye, Plus, HelpCircle, Zap, CheckCircle, Pencil, Trash2, Bookmark } from 'lucide-react'
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 3: Verify TypeScript compiles**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /home/michaelchihlas/dev/patherly/frontend && npx tsc --noEmit 2>&1 | head -30
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: no errors related to StepCard.
|
||||||
|
|
||||||
|
**Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /home/michaelchihlas/dev/patherly
|
||||||
|
git add frontend/src/components/step-library/StepCard.tsx
|
||||||
|
git commit -m "feat: add library-page action props to StepCard (edit/delete/save)
|
||||||
|
|
||||||
|
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 2: Extend StepLibraryBrowser with library-page props
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `frontend/src/components/step-library/StepLibraryBrowser.tsx`
|
||||||
|
|
||||||
|
Pass the new `onEdit`, `onDelete`, `onSave`, `currentUserId` props through from browser to each `StepCard`. Also expose a `refreshKey` prop so the page can trigger a reload after create/edit/delete/save.
|
||||||
|
|
||||||
|
**Step 1: Update the interface**
|
||||||
|
|
||||||
|
Current interface:
|
||||||
|
```ts
|
||||||
|
interface StepLibraryBrowserProps {
|
||||||
|
onInsert: (step: Step) => void
|
||||||
|
onCreateNew?: () => void
|
||||||
|
showCreateButton?: boolean
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
New interface:
|
||||||
|
```ts
|
||||||
|
interface StepLibraryBrowserProps {
|
||||||
|
onInsert?: (step: Step) => void // now optional (not needed on library page)
|
||||||
|
onCreateNew?: () => void
|
||||||
|
showCreateButton?: boolean
|
||||||
|
onEdit?: (step: StepListItem) => void
|
||||||
|
onDelete?: (stepId: string) => void
|
||||||
|
onSave?: (step: StepListItem) => void
|
||||||
|
currentUserId?: string
|
||||||
|
refreshKey?: number // increment to trigger reload
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: Wire refreshKey into the steps useEffect**
|
||||||
|
|
||||||
|
In the existing `useEffect` that calls `loadSteps`, add `refreshKey` to the dependency array:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
useEffect(() => {
|
||||||
|
const loadSteps = async () => { ... }
|
||||||
|
loadSteps()
|
||||||
|
}, [searchQuery, selectedCategoryId, selectedStepType, minRating, sortBy, selectedTag, refreshKey])
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 3: Pass new props to StepCard**
|
||||||
|
|
||||||
|
In all three `groupedSteps.private/team/public` map blocks, update `StepCard` usage:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<StepCard
|
||||||
|
key={step.id}
|
||||||
|
step={step}
|
||||||
|
onPreview={handlePreview}
|
||||||
|
onInsert={onInsert ? handleInsertFromCard : undefined}
|
||||||
|
onEdit={onEdit}
|
||||||
|
onDelete={onDelete}
|
||||||
|
onSave={onSave}
|
||||||
|
currentUserId={currentUserId}
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 4: Verify TypeScript compiles**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /home/michaelchihlas/dev/patherly/frontend && npx tsc --noEmit 2>&1 | head -30
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /home/michaelchihlas/dev/patherly
|
||||||
|
git add frontend/src/components/step-library/StepLibraryBrowser.tsx
|
||||||
|
git commit -m "feat: pass library-page action props through StepLibraryBrowser + refreshKey
|
||||||
|
|
||||||
|
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 3: Create StepFormModal
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `frontend/src/components/step-library/StepFormModal.tsx`
|
||||||
|
|
||||||
|
A thin modal wrapper around the existing `StepForm`. Handles both create and edit modes.
|
||||||
|
|
||||||
|
**Step 1: Create the file**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { X } from 'lucide-react'
|
||||||
|
import { stepsApi } from '@/api/steps'
|
||||||
|
import { StepForm } from './StepForm'
|
||||||
|
import type { Step, StepCreate, StepListItem } from '@/types/step'
|
||||||
|
|
||||||
|
interface StepFormModalProps {
|
||||||
|
isOpen: boolean
|
||||||
|
onClose: () => void
|
||||||
|
onSuccess: (step: Step) => void
|
||||||
|
editingStep?: StepListItem | null // if set, edit mode; if null/undefined, create mode
|
||||||
|
}
|
||||||
|
|
||||||
|
export function StepFormModal({ isOpen, onClose, onSuccess, editingStep }: StepFormModalProps) {
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
if (!isOpen) return null
|
||||||
|
|
||||||
|
const isEditMode = !!editingStep
|
||||||
|
|
||||||
|
const handleSubmit = async (data: StepCreate) => {
|
||||||
|
setIsSubmitting(true)
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
let result: Step
|
||||||
|
if (isEditMode && editingStep) {
|
||||||
|
result = await stepsApi.update(editingStep.id, data)
|
||||||
|
} else {
|
||||||
|
result = await stepsApi.create(data)
|
||||||
|
}
|
||||||
|
onSuccess(result)
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to save step:', err)
|
||||||
|
setError('Failed to save step. Please try again.')
|
||||||
|
} finally {
|
||||||
|
setIsSubmitting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build initialData from editingStep for edit mode
|
||||||
|
// StepListItem doesn't have `content`, so for edit we need to fetch full step
|
||||||
|
// This is handled by the parent (StepLibraryPage fetches full step before opening modal)
|
||||||
|
const initialData = editingStep ? {
|
||||||
|
title: editingStep.title,
|
||||||
|
step_type: editingStep.step_type as 'decision' | 'action' | 'solution',
|
||||||
|
visibility: editingStep.visibility as 'private' | 'team' | 'public',
|
||||||
|
category_id: editingStep.category_id,
|
||||||
|
tags: editingStep.tags,
|
||||||
|
} : undefined
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/80 backdrop-blur-sm p-4">
|
||||||
|
<div className="relative flex h-[90vh] w-full max-w-2xl flex-col bg-card border border-border rounded-2xl shadow-lg">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between border-b border-border p-6 pb-4">
|
||||||
|
<h2 className="text-lg font-semibold text-foreground">
|
||||||
|
{isEditMode ? 'Edit Step' : 'Create Step'}
|
||||||
|
</h2>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
className="rounded-md p-1.5 text-muted-foreground hover:bg-accent hover:text-foreground disabled:opacity-50"
|
||||||
|
aria-label="Close"
|
||||||
|
>
|
||||||
|
<X className="h-5 w-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Error */}
|
||||||
|
{error && (
|
||||||
|
<div className="mx-6 mt-4 rounded-lg border border-red-400/20 bg-red-400/10 p-3 text-sm text-red-400">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Body */}
|
||||||
|
<div className="flex-1 overflow-y-auto p-6">
|
||||||
|
<StepForm
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
onCancel={onClose}
|
||||||
|
initialData={initialData}
|
||||||
|
submitLabel={isEditMode ? 'Save Changes' : 'Create Step'}
|
||||||
|
isSubmitting={isSubmitting}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: Update StepForm to accept `submitLabel` and `isSubmitting` props**
|
||||||
|
|
||||||
|
`StepForm` currently has a hardcoded submit button label ("Insert Step") and no loading state. Add these two optional props:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
interface StepFormProps {
|
||||||
|
onSubmit: (data: StepCreate) => void
|
||||||
|
onCancel: () => void
|
||||||
|
initialData?: Partial<StepCreate>
|
||||||
|
submitLabel?: string // default: 'Insert Step'
|
||||||
|
isSubmitting?: boolean // default: false
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
In `StepForm`, use them on the submit button:
|
||||||
|
```tsx
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={isSubmitting}
|
||||||
|
className="flex-1 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"
|
||||||
|
>
|
||||||
|
{isSubmitting ? 'Saving...' : (submitLabel ?? 'Insert Step')}
|
||||||
|
</button>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 3: Verify TypeScript compiles**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /home/michaelchihlas/dev/patherly/frontend && npx tsc --noEmit 2>&1 | head -30
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /home/michaelchihlas/dev/patherly
|
||||||
|
git add frontend/src/components/step-library/StepFormModal.tsx \
|
||||||
|
frontend/src/components/step-library/StepForm.tsx
|
||||||
|
git commit -m "feat: StepFormModal wrapper + submitLabel/isSubmitting props on StepForm
|
||||||
|
|
||||||
|
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 4: Rewrite StepLibraryPage
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `frontend/src/pages/StepLibraryPage.tsx`
|
||||||
|
|
||||||
|
This is the main wiring task. Replace the stub with the full page.
|
||||||
|
|
||||||
|
**Step 1: Write the new page**
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { Bookmark, Trash2 } from 'lucide-react'
|
||||||
|
import { useAuthStore } from '@/store/authStore'
|
||||||
|
import { usePermissions } from '@/hooks/usePermissions'
|
||||||
|
import { stepsApi } from '@/api/steps'
|
||||||
|
import { StepLibraryBrowser } from '@/components/step-library/StepLibraryBrowser'
|
||||||
|
import { StepFormModal } from '@/components/step-library/StepFormModal'
|
||||||
|
import type { Step, StepListItem } from '@/types/step'
|
||||||
|
|
||||||
|
export default function StepLibraryPage() {
|
||||||
|
const user = useAuthStore((s) => s.user)
|
||||||
|
const { canCreateSteps } = usePermissions()
|
||||||
|
|
||||||
|
// Modal state
|
||||||
|
const [createOpen, setCreateOpen] = useState(false)
|
||||||
|
const [editingStep, setEditingStep] = useState<StepListItem | null>(null)
|
||||||
|
const [deletingStep, setDeletingStep] = useState<StepListItem | null>(null)
|
||||||
|
const [isDeleting, setIsDeleting] = useState(false)
|
||||||
|
const [deleteError, setDeleteError] = useState<string | null>(null)
|
||||||
|
const [saveToast, setSaveToast] = useState<string | null>(null)
|
||||||
|
|
||||||
|
// Increment to trigger StepLibraryBrowser reload
|
||||||
|
const [refreshKey, setRefreshKey] = useState(0)
|
||||||
|
const refresh = () => setRefreshKey(k => k + 1)
|
||||||
|
|
||||||
|
const handleEdit = (step: StepListItem) => {
|
||||||
|
setEditingStep(step)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDeleteRequest = (stepId: string) => {
|
||||||
|
// Find the step in order to show its title in the confirmation
|
||||||
|
// We store the StepListItem via the browser's onDelete callback
|
||||||
|
// The step object is passed from StepCard which has the full StepListItem
|
||||||
|
setDeletingStep({ id: stepId } as StepListItem)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDeleteConfirm = async () => {
|
||||||
|
if (!deletingStep) return
|
||||||
|
setIsDeleting(true)
|
||||||
|
setDeleteError(null)
|
||||||
|
try {
|
||||||
|
await stepsApi.delete(deletingStep.id)
|
||||||
|
setDeletingStep(null)
|
||||||
|
refresh()
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to delete step:', err)
|
||||||
|
setDeleteError('Failed to delete step. Please try again.')
|
||||||
|
} finally {
|
||||||
|
setIsDeleting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSave = async (step: StepListItem) => {
|
||||||
|
try {
|
||||||
|
// Fetch full step to get content fields
|
||||||
|
const full = await stepsApi.get(step.id)
|
||||||
|
await stepsApi.create({
|
||||||
|
title: full.title,
|
||||||
|
step_type: full.step_type,
|
||||||
|
content: full.content,
|
||||||
|
visibility: 'private',
|
||||||
|
category_id: full.category_id,
|
||||||
|
tags: full.tags,
|
||||||
|
})
|
||||||
|
setSaveToast(`"${full.title}" saved to My Steps`)
|
||||||
|
setTimeout(() => setSaveToast(null), 3000)
|
||||||
|
refresh()
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to save step:', err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleFormSuccess = (_step: Step) => {
|
||||||
|
setCreateOpen(false)
|
||||||
|
setEditingStep(null)
|
||||||
|
refresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full flex-col">
|
||||||
|
{/* Page Header */}
|
||||||
|
<div className="flex items-center justify-between border-b border-border px-6 py-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span title="Step Library">
|
||||||
|
<Bookmark className="h-6 w-6 text-muted-foreground" />
|
||||||
|
</span>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-xl font-bold font-heading text-foreground">Step Library</h1>
|
||||||
|
<p className="text-sm text-muted-foreground">Reusable steps you can insert into any flow</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{canCreateSteps && (
|
||||||
|
<button
|
||||||
|
onClick={() => setCreateOpen(true)}
|
||||||
|
className="rounded-md bg-gradient-brand px-4 py-2 text-sm font-medium text-white shadow-lg shadow-primary/20 hover:opacity-90"
|
||||||
|
>
|
||||||
|
+ Create Step
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Browser fills remaining height */}
|
||||||
|
<div className="flex-1 overflow-hidden">
|
||||||
|
<StepLibraryBrowser
|
||||||
|
onEdit={handleEdit}
|
||||||
|
onDelete={(stepId) => {
|
||||||
|
// We need the full StepListItem for the confirmation title.
|
||||||
|
// Pass a minimal object; the title will show as "this step" if not available.
|
||||||
|
setDeletingStep({ id: stepId, title: '' } as StepListItem)
|
||||||
|
}}
|
||||||
|
onSave={handleSave}
|
||||||
|
currentUserId={user?.id}
|
||||||
|
refreshKey={refreshKey}
|
||||||
|
showCreateButton={false}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Create / Edit Modal */}
|
||||||
|
<StepFormModal
|
||||||
|
isOpen={createOpen || !!editingStep}
|
||||||
|
onClose={() => { setCreateOpen(false); setEditingStep(null) }}
|
||||||
|
onSuccess={handleFormSuccess}
|
||||||
|
editingStep={editingStep}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Delete Confirmation Dialog */}
|
||||||
|
{deletingStep && (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/80 backdrop-blur-sm p-4">
|
||||||
|
<div className="w-full max-w-sm rounded-xl bg-card border border-border p-6 shadow-lg">
|
||||||
|
<div className="mb-4 flex items-center gap-3">
|
||||||
|
<div className="rounded-full bg-red-400/10 p-2">
|
||||||
|
<Trash2 className="h-5 w-5 text-red-400" />
|
||||||
|
</div>
|
||||||
|
<h2 className="text-base font-semibold text-foreground">Delete Step</h2>
|
||||||
|
</div>
|
||||||
|
<p className="mb-2 text-sm text-muted-foreground">
|
||||||
|
{deletingStep.title
|
||||||
|
? <>Are you sure you want to delete <span className="font-medium text-foreground">"{deletingStep.title}"</span>?</>
|
||||||
|
: 'Are you sure you want to delete this step?'
|
||||||
|
}
|
||||||
|
</p>
|
||||||
|
<p className="mb-6 text-xs text-muted-foreground">This cannot be undone.</p>
|
||||||
|
{deleteError && (
|
||||||
|
<p className="mb-4 text-sm text-red-400">{deleteError}</p>
|
||||||
|
)}
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => { setDeletingStep(null); setDeleteError(null) }}
|
||||||
|
disabled={isDeleting}
|
||||||
|
className="flex-1 rounded-md border border-border px-4 py-2 text-sm font-medium text-muted-foreground hover:bg-accent hover:text-foreground disabled:opacity-50"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleDeleteConfirm}
|
||||||
|
disabled={isDeleting}
|
||||||
|
className="flex-1 rounded-md bg-red-500 px-4 py-2 text-sm font-medium text-white hover:bg-red-600 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{isDeleting ? 'Deleting...' : 'Delete'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Save Toast */}
|
||||||
|
{saveToast && (
|
||||||
|
<div className="fixed bottom-6 left-1/2 z-50 -translate-x-1/2 rounded-lg border border-border bg-card px-4 py-2 text-sm text-foreground shadow-lg">
|
||||||
|
{saveToast}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**NOTE on delete title:** The `onDelete` callback from `StepCard` only passes `stepId: string`, not the full `StepListItem`. To show the step title in the confirmation dialog, change the `StepLibraryBrowser`'s `onDelete` prop type to pass the full `StepListItem` instead:
|
||||||
|
|
||||||
|
In `StepLibraryBrowser.tsx`, change:
|
||||||
|
```ts
|
||||||
|
onDelete?: (stepId: string) => void
|
||||||
|
```
|
||||||
|
to:
|
||||||
|
```ts
|
||||||
|
onDelete?: (step: StepListItem) => void
|
||||||
|
```
|
||||||
|
|
||||||
|
And update where it calls `onDelete` from cards — pass the full `step` object. Update `StepCard` similarly: change `onDelete?: (stepId: string) => void` to `onDelete?: (step: StepListItem) => void` and call `onDelete?.(step)` instead of `onDelete?.(step.id)`.
|
||||||
|
|
||||||
|
Then in `StepLibraryPage`, use `handleDeleteRequest(step: StepListItem)` and set `setDeletingStep(step)` directly — no need to pass a minimal object.
|
||||||
|
|
||||||
|
**Step 2: Run TypeScript check**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /home/michaelchihlas/dev/patherly/frontend && npx tsc --noEmit 2>&1 | head -40
|
||||||
|
```
|
||||||
|
|
||||||
|
Fix any type errors before proceeding.
|
||||||
|
|
||||||
|
**Step 3: Run build**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /home/michaelchihlas/dev/patherly/frontend && npm run build 2>&1 | tail -20
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: build succeeds with no errors.
|
||||||
|
|
||||||
|
**Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /home/michaelchihlas/dev/patherly
|
||||||
|
git add frontend/src/pages/StepLibraryPage.tsx \
|
||||||
|
frontend/src/components/step-library/StepLibraryBrowser.tsx \
|
||||||
|
frontend/src/components/step-library/StepCard.tsx
|
||||||
|
git commit -m "feat: Step Library page — create, edit, delete, save-to-library
|
||||||
|
|
||||||
|
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 5: Manual verification checklist
|
||||||
|
|
||||||
|
Start the dev server and verify these flows work end-to-end:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker start patherly_postgres
|
||||||
|
cd /home/michaelchihlas/dev/patherly/backend && source venv/bin/activate && uvicorn app.main:app --reload &
|
||||||
|
cd /home/michaelchihlas/dev/patherly/frontend && npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Navigate to `http://localhost:5173/step-library` and verify:
|
||||||
|
|
||||||
|
- [ ] Page loads without errors (not "Coming Soon")
|
||||||
|
- [ ] "+ Create Step" button appears (login as engineer or admin)
|
||||||
|
- [ ] Creating a step via the modal saves it and it appears under "My Steps" on reload
|
||||||
|
- [ ] "Edit" button appears on your own step cards
|
||||||
|
- [ ] Editing a step opens the form pre-filled (note: `content` fields won't pre-fill since `StepListItem` doesn't have content — this is acceptable for now; see note below)
|
||||||
|
- [ ] "Delete" button appears on your own step cards
|
||||||
|
- [ ] Delete confirmation shows step title; confirming removes it from the list
|
||||||
|
- [ ] "Save" button appears on team/community step cards
|
||||||
|
- [ ] Saving a step copies it to "My Steps" and shows toast
|
||||||
|
- [ ] "Preview" opens `StepDetailModal` correctly on all card types
|
||||||
|
- [ ] Filters (category, type, rating, sort) work
|
||||||
|
- [ ] Popular tags clickable and filter results
|
||||||
|
|
||||||
|
**Note on edit pre-fill:** `StepListItem` does not include `content`. The `StepFormModal` passes `initialData` from `editingStep`, but `content` will be missing. For a full pre-fill, `StepLibraryPage.handleEdit` should fetch the full step via `stepsApi.get(step.id)` before opening the modal, and store the result as a `Step` (not `StepListItem`) in `editingStep` state. Update `editingStep` state type to `Step | null` and fetch in `handleEdit`:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
const [editingStep, setEditingStep] = useState<Step | null>(null)
|
||||||
|
|
||||||
|
const handleEdit = async (step: StepListItem) => {
|
||||||
|
try {
|
||||||
|
const full = await stepsApi.get(step.id)
|
||||||
|
setEditingStep(full)
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to load step for edit:', err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Update `StepFormModal`'s `editingStep` prop type to accept `Step | null` and build `initialData` from the full `Step` including `content`:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
editingStep?: Step | null
|
||||||
|
|
||||||
|
const initialData = editingStep ? {
|
||||||
|
title: editingStep.title,
|
||||||
|
step_type: editingStep.step_type,
|
||||||
|
content: editingStep.content,
|
||||||
|
visibility: editingStep.visibility,
|
||||||
|
category_id: editingStep.category_id,
|
||||||
|
tags: editingStep.tags,
|
||||||
|
} : undefined
|
||||||
|
```
|
||||||
|
|
||||||
|
This should be done as part of Task 4 before verifying.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 6: Final build validation and commit
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /home/michaelchihlas/dev/patherly/frontend && npm run build 2>&1 | tail -20
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: clean build, no TypeScript errors, no warnings about missing exports.
|
||||||
|
|
||||||
|
If clean:
|
||||||
|
```bash
|
||||||
|
cd /home/michaelchihlas/dev/patherly
|
||||||
|
git add -A
|
||||||
|
git status # confirm only expected files changed
|
||||||
|
git commit -m "chore: step library page final build validation
|
||||||
|
|
||||||
|
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>"
|
||||||
|
```
|
||||||
Reference in New Issue
Block a user