feat: Add custom step creation and backend support (Phase 3A: B.8-B.10, B.13)

Implements custom step creation forms and backend persistence:

Task B.8 - StepForm Component:
- Comprehensive form for creating custom steps
- Step type selection (decision/action/solution) with descriptions
- Required fields: title, instructions (markdown supported)
- Optional fields: help text, commands (dynamic array), category, tags
- Visibility control (private/team/public)
- Save to library checkbox
- Full validation with error display
- Dynamic command management (add/remove, label + command)
- Tag input with Enter key support

Task B.9 - CustomStepModal:
- Tabbed modal interface
- Tab 1: "Type My Own" - embeds StepForm
- Tab 2: "Browse Library" - embeds StepLibraryBrowser
- Handles both saved steps (API) and drafts (no save)
- Loading states during step creation
- Error handling with user feedback
- Returns Step or CustomStepDraft to parent

Task B.10 - Backend Custom Steps Support:
- Database migration: add custom_steps JSONB column to sessions
- Updated Session model with custom_steps field
- Updated SessionResponse schema with custom_steps
- Updated SessionUpdate schema to accept custom_steps
- Migration ready to run: 4cdb5cba1aff

Task B.13 - Session Types Updates:
- Added CustomStep and CustomStepDraft interfaces
- Updated Session interface with custom_steps field
- Updated SessionUpdate interface
- Exported step types from types/index.ts
- Full TypeScript support for custom step integration

Remaining tasks: B.11 (TreeNavigationPage integration), B.12 (Export)
Build tested successfully.

Related: Issues #8, #9, #10

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
Michael Chihlas
2026-02-03 19:15:36 -05:00
parent fc7fa1a17c
commit 009c60fbc3
7 changed files with 602 additions and 0 deletions

View File

@@ -0,0 +1,148 @@
import { useState } from 'react'
import { X } from 'lucide-react'
import { cn } from '@/lib/utils'
import { stepsApi } from '@/api'
import { StepForm } from './StepForm'
import { StepLibraryBrowser } from './StepLibraryBrowser'
import type { Step, StepCreate } from '@/types/step'
export interface CustomStepDraft {
title: string
step_type: 'decision' | 'action' | 'solution'
content: {
instructions: string
help_text?: string
commands?: Array<{
label: string
command: string
command_type?: string
}>
}
category_id?: string
tags?: string[]
}
interface CustomStepModalProps {
isOpen: boolean
onClose: () => void
onInsertStep: (step: Step | CustomStepDraft) => void
}
type Tab = 'create' | 'browse'
export function CustomStepModal({ isOpen, onClose, onInsertStep }: CustomStepModalProps) {
const [activeTab, setActiveTab] = useState<Tab>('create')
const [isSubmitting, setIsSubmitting] = useState(false)
const [error, setError] = useState<string | null>(null)
if (!isOpen) return null
const handleFormSubmit = async (data: StepCreate, saveToLibrary: boolean) => {
setIsSubmitting(true)
setError(null)
try {
if (saveToLibrary) {
// Save to library first, then return the saved step
const savedStep = await stepsApi.create(data)
onInsertStep(savedStep)
} else {
// Return as draft (not saved to library)
const draft: CustomStepDraft = {
title: data.title,
step_type: data.step_type,
content: data.content,
category_id: data.category_id,
tags: data.tags
}
onInsertStep(draft)
}
} catch (err) {
console.error('Failed to create step:', err)
setError('Failed to create step. Please try again.')
setIsSubmitting(false)
}
}
const handleBrowserInsert = (step: Step) => {
onInsertStep(step)
}
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-background/80 backdrop-blur-sm">
<div className="relative flex h-[90vh] w-full max-w-4xl flex-col rounded-lg border border-border bg-card shadow-lg">
{/* Header */}
<div className="flex items-center justify-between border-b border-border p-4">
<h2 className="text-lg font-semibold">Add Custom Step</h2>
<button
onClick={onClose}
className="rounded-md p-1 hover:bg-accent"
aria-label="Close"
>
<X className="h-5 w-5" />
</button>
</div>
{/* Tabs */}
<div className="flex border-b border-border">
<button
onClick={() => setActiveTab('create')}
className={cn(
'flex-1 px-4 py-3 text-sm font-medium transition-colors',
activeTab === 'create'
? 'border-b-2 border-primary bg-primary/5 text-primary'
: 'text-muted-foreground hover:bg-muted/50 hover:text-foreground'
)}
>
Type My Own
</button>
<button
onClick={() => setActiveTab('browse')}
className={cn(
'flex-1 px-4 py-3 text-sm font-medium transition-colors',
activeTab === 'browse'
? 'border-b-2 border-primary bg-primary/5 text-primary'
: 'text-muted-foreground hover:bg-muted/50 hover:text-foreground'
)}
>
Browse Library
</button>
</div>
{/* Error Display */}
{error && (
<div className="mx-4 mt-4 rounded-lg border border-destructive/50 bg-destructive/10 p-3 text-sm text-destructive">
{error}
</div>
)}
{/* Tab Content */}
<div className="flex-1 overflow-hidden">
{activeTab === 'create' ? (
<div className="h-full overflow-y-auto p-6">
<StepForm
onSubmit={handleFormSubmit}
onCancel={onClose}
/>
</div>
) : (
<StepLibraryBrowser
onInsert={handleBrowserInsert}
showCreateButton={false}
/>
)}
</div>
{/* Loading Overlay */}
{isSubmitting && (
<div className="absolute inset-0 flex items-center justify-center bg-background/80 backdrop-blur-sm">
<div className="flex flex-col items-center gap-3">
<div className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent" />
<p className="text-sm text-muted-foreground">Creating step...</p>
</div>
</div>
)}
</div>
</div>
)
}