Files
resolutionflow/docs/plans/2026-02-24-step-library-page-plan.md
2026-02-26 08:09:12 -05:00

706 lines
24 KiB
Markdown

# 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>"
```