Update task-3 plan document with: - Completion status and commit reference - Implementation results summary - Checkboxes for completed phases - Updated success criteria checklist All implementation work is complete, manual QA testing pending. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
16 KiB
Task 3: Project Form Modularization & Store Extraction
Status: ✅ COMPLETED (Oct 7, 2025)
Commit: 34a3e37 - refactor(admin): modularize ProjectForm with composable stores
Overview
Refactor ProjectForm.svelte (originally 720 lines) to use composable stores and reusable helpers, reducing duplication and improving testability.
Implementation Results
- ✅ ProjectForm.svelte: Reduced from 720 → 417 lines (42% reduction)
- ✅ Store factory created:
src/lib/stores/project-form.svelte.ts(114 lines) - ✅ Draft recovery helper:
src/lib/admin/useDraftRecovery.svelte.ts(62 lines) - ✅ Form guards helper:
src/lib/admin/useFormGuards.svelte.ts(56 lines) - ✅ UI component:
src/lib/components/admin/DraftPrompt.svelte(92 lines) - ✅ Type check passes, build succeeds
- ⏳ Manual QA testing pending
Current State Analysis
✅ Already Modularized
- Section components exist:
ProjectMetadataForm.svelteProjectBrandingForm.svelteProjectImagesForm.svelteProjectStylingForm.svelteProjectGalleryForm.svelte
- Autosave integrated: Uses
createAutoSaveStorefrom Task 6
❌ Needs Extraction
- No store abstraction: All form state lives directly in the component (~50 lines of state declarations)
- Draft recovery scattered: Manual logic spread across multiple
$effectblocks (~80 lines) - Navigation guards duplicated:
beforeNavigate,beforeunload, Cmd+S shortcuts (~90 lines total) - Form lifecycle boilerplate: Initial load, populate, validation (~60 lines)
Issues with Current Approach
- Not reusable: Same patterns will be copy-pasted to PostForm, EssayForm, etc.
- Hard to test: Logic is tightly coupled to component lifecycle
- Unclear boundaries: Business logic mixed with UI orchestration
- Maintenance burden: Bug fixes need to be applied to multiple forms
Svelte 5 Patterns & Best Practices (2025)
This refactor follows modern Svelte 5 patterns with runes:
Key Patterns Used
-
Runes in
.svelte.tsfiles: Store factories use runes ($state,$derived,$effect) in plain TypeScript modules- File extension:
.svelte.ts(not.ts) to enable rune support - Export factory functions that return reactive state
- State is returned directly - it's already reactive in Svelte 5
- File extension:
-
No "readonly" wrappers needed: Unlike Svelte 4 stores, Svelte 5 state is reactive by default
- Just return state directly:
return { fields, setField } - Components can read:
formStore.fields.title - Encourage mutation through methods for validation control
- Just return state directly:
-
$derived for computed values: Use
$derivedinstead of manual trackingconst isDirty = $derived(original !== fields)- Automatically re-evaluates when dependencies change
-
$effect for side effects: Lifecycle logic in composable functions
- Event listeners:
$effect(() => { addEventListener(); return () => removeListener() }) - Auto-cleanup via return function
- Replaces
onMount/onDestroypatterns
- Event listeners:
-
Type safety with generics:
useDraftRecovery<TPayload>for reusability- Inferred types from usage
ReturnType<typeof factory>for store types
-
SvelteKit integration: Use
beforeNavigatefor navigation guards- Async callbacks are awaited automatically
- No need for
navigation.cancel()+goto()patterns
Proposed Architecture
1. Create Store Factory: src/lib/stores/project-form.svelte.ts
Purpose: Centralize form state management and validation logic using Svelte 5 runes.
API Design:
export function createProjectFormStore(project?: Project) {
// Reactive state using $state rune
let fields = $state<ProjectFormData>({ ...defaultProjectFormData })
let validationErrors = $state<Record<string, string>>({})
let original = $state<ProjectFormData | null>(project ? { ...project } : null)
// Derived state using $derived rune
const isDirty = $derived(
original ? JSON.stringify(fields) !== JSON.stringify(original) : false
)
return {
// State is returned directly - it's already reactive in Svelte 5
// Components can read: formStore.fields.title
// Mutation should go through methods below for validation
fields,
validationErrors,
isDirty,
// Methods for controlled mutation
setField(key: keyof ProjectFormData, value: any) {
fields[key] = value
},
setFields(data: Partial<ProjectFormData>) {
fields = { ...fields, ...data }
},
validate(): boolean {
const result = projectSchema.safeParse(fields)
if (!result.success) {
validationErrors = result.error.flatten().fieldErrors as Record<string, string>
return false
}
validationErrors = {}
return true
},
reset() {
fields = { ...defaultProjectFormData }
validationErrors = {}
},
populateFromProject(project: Project) {
fields = {
title: project.title || '',
subtitle: project.subtitle || '',
// ... all fields
}
original = { ...fields }
},
buildPayload(): ProjectPayload {
return {
title: fields.title,
subtitle: fields.subtitle,
// ... build API payload
}
}
}
}
export type ProjectFormStore = ReturnType<typeof createProjectFormStore>
Benefits:
- Type-safe field access with autocomplete
- Centralized validation logic
- Easy to unit test
- Can be used standalone (e.g., in tests, other components)
2. Create Draft Recovery Helper: src/lib/admin/useDraftRecovery.svelte.ts
Purpose: Extract draft restore prompt logic for reuse across all forms using Svelte 5 runes.
API Design:
export function useDraftRecovery<TPayload>(options: {
draftKey: string | null
onRestore: (payload: TPayload) => void
enabled?: boolean
}) {
// Reactive state using $state rune
let showPrompt = $state(false)
let draftTimestamp = $state<number | null>(null)
let timeTicker = $state(0)
// Derived state for time display
const draftTimeText = $derived.by(() =>
draftTimestamp ? (timeTicker, timeAgo(draftTimestamp)) : null
)
// Auto-detect draft on mount using $effect
$effect(() => {
if (!options.draftKey || options.enabled === false) return
const draft = loadDraft<TPayload>(options.draftKey)
if (draft) {
showPrompt = true
draftTimestamp = draft.ts
}
})
// Update time display every minute using $effect
$effect(() => {
if (!showPrompt) return
const interval = setInterval(() => {
timeTicker = timeTicker + 1
}, 60000)
return () => clearInterval(interval)
})
return {
// State returned directly - reactive in Svelte 5
showPrompt,
draftTimeText,
restore() {
if (!options.draftKey) return
const draft = loadDraft<TPayload>(options.draftKey)
if (!draft) return
options.onRestore(draft.payload)
showPrompt = false
clearDraft(options.draftKey)
},
dismiss() {
if (!options.draftKey) return
showPrompt = false
clearDraft(options.draftKey)
}
}
}
Usage:
<script>
const draftRecovery = useDraftRecovery({
draftKey: draftKey,
onRestore: (payload) => formStore.setFields(payload)
})
</script>
{#if draftRecovery.showPrompt}
<DraftPrompt
timeAgo={draftRecovery.draftTimeText}
onRestore={draftRecovery.restore}
onDismiss={draftRecovery.dismiss}
/>
{/if}
Benefits:
- Reusable across ProjectForm, PostForm, EssayForm, etc.
- Encapsulates timing and state management
- Easy to test in isolation
3. Create Form Guards Helper: src/lib/admin/useFormGuards.svelte.ts
Purpose: Extract navigation protection logic using Svelte 5 runes and SvelteKit navigation APIs.
API Design:
import { beforeNavigate } from '$app/navigation'
import { toast } from '$lib/stores/toast'
import type { AutoSaveStore } from '$lib/admin/autoSave.svelte'
export function useFormGuards(autoSave: AutoSaveStore | null) {
if (!autoSave) return // No guards needed for create mode
// Navigation guard: flush autosave before route change
beforeNavigate(async (navigation) => {
// If already saved, allow navigation immediately
if (autoSave.status === 'saved') return
// Otherwise flush pending changes
try {
await autoSave.flush()
} catch (error) {
console.error('Autosave flush failed:', error)
toast.error('Failed to save changes')
}
})
// Warn before closing browser tab/window if unsaved changes
$effect(() => {
function handleBeforeUnload(event: BeforeUnloadEvent) {
if (autoSave!.status !== 'saved') {
event.preventDefault()
event.returnValue = ''
}
}
window.addEventListener('beforeunload', handleBeforeUnload)
return () => window.removeEventListener('beforeunload', handleBeforeUnload)
})
// Cmd/Ctrl+S keyboard shortcut for immediate save
$effect(() => {
function handleKeydown(event: KeyboardEvent) {
const key = event.key.toLowerCase()
const isModifier = event.metaKey || event.ctrlKey
if (isModifier && key === 's') {
event.preventDefault()
autoSave!.flush().catch((error) => {
console.error('Autosave flush failed:', error)
toast.error('Failed to save changes')
})
}
}
document.addEventListener('keydown', handleKeydown)
return () => document.removeEventListener('keydown', handleKeydown)
})
// No return value - purely side effects
}
Usage:
<script>
useFormGuards(autoSave)
</script>
Benefits:
- Single source of truth for form protection
- Consistent UX across all forms
- Easier to update behavior globally
4. Simplify ProjectForm.svelte
Before: ~719 lines After: ~200-300 lines
New structure:
<script lang="ts">
import { createProjectFormStore } from '$lib/stores/project-form.svelte'
import { useDraftRecovery } from '$lib/admin/useDraftRecovery.svelte'
import { useFormGuards } from '$lib/admin/useFormGuards.svelte'
import { createAutoSaveStore } from '$lib/admin/autoSave.svelte'
import { makeDraftKey } from '$lib/admin/draftStore'
import AdminPage from './AdminPage.svelte'
import ProjectMetadataForm from './ProjectMetadataForm.svelte'
import Composer from './composer'
import DraftPrompt from './DraftPrompt.svelte'
import StatusDropdown from './StatusDropdown.svelte'
import AutoSaveStatus from './AutoSaveStatus.svelte'
interface Props {
project?: Project | null
mode: 'create' | 'edit'
}
let { project = null, mode }: Props = $props()
// Form store - centralized state management
const formStore = createProjectFormStore(project)
// Lifecycle tracking
let hasLoaded = $state(mode === 'create')
// Autosave (edit mode only)
const autoSave = mode === 'edit'
? createAutoSaveStore({
debounceMs: 2000,
getPayload: () => hasLoaded ? formStore.buildPayload() : null,
save: async (payload, { signal }) => {
return await api.put(`/api/projects/${project?.id}`, payload, { signal })
},
onSaved: (savedProject, { prime }) => {
project = savedProject
formStore.populateFromProject(savedProject)
prime(formStore.buildPayload())
}
})
: null
// Draft recovery helper
const draftRecovery = useDraftRecovery({
draftKey: mode === 'edit' && project ? makeDraftKey('project', project.id) : null,
onRestore: (payload) => formStore.setFields(payload)
})
// Form guards (navigation protection, Cmd+S, beforeunload)
useFormGuards(autoSave)
// UI state
let activeTab = $state('metadata')
// Initial load effect
$effect(() => {
if (project && mode === 'edit' && !hasLoaded) {
formStore.populateFromProject(project)
autoSave?.prime(formStore.buildPayload())
hasLoaded = true
} else if (mode === 'create' && !hasLoaded) {
hasLoaded = true
}
})
// Trigger autosave on field changes
$effect(() => {
formStore.fields; activeTab // Establish dependencies
if (mode === 'edit' && hasLoaded && autoSave) {
autoSave.schedule()
}
})
// Manual save handler
async function handleSave() {
if (!formStore.validate()) {
toast.error('Please fix validation errors')
return
}
if (mode === 'create') {
// ... create logic
} else if (autoSave) {
await autoSave.flush()
}
}
</script>
<AdminPage>
<header slot="header">
<h1>{mode === 'create' ? 'New Project' : formStore.fields.title}</h1>
<div class="header-actions">
{#if mode === 'edit' && autoSave}
<AutoSaveStatus status={autoSave.status} />
{/if}
<StatusDropdown bind:status={formStore.fields.status} />
<Button onclick={handleSave}>Save</Button>
</div>
</header>
{#if draftRecovery.showPrompt}
<DraftPrompt
timeAgo={draftRecovery.draftTimeText}
onRestore={draftRecovery.restore}
onDismiss={draftRecovery.dismiss}
/>
{/if}
<AdminSegmentedControl
options={[
{ value: 'metadata', label: 'Metadata' },
{ value: 'case-study', label: 'Case Study' }
]}
value={activeTab}
onChange={(value) => activeTab = value}
/>
{#if activeTab === 'metadata'}
<ProjectMetadataForm bind:formData={formStore.fields} />
<ProjectBrandingForm bind:formData={formStore.fields} />
<ProjectImagesForm bind:formData={formStore.fields} />
{:else if activeTab === 'case-study'}
<Composer bind:content={formStore.fields.caseStudyContent} />
{/if}
</AdminPage>
Key improvements:
- ~200-300 lines instead of ~719
- All state management in
formStore - Reusable helpers (
useDraftRecovery,useFormGuards) - Clear separation: UI orchestration vs business logic
- Easy to test store and helpers independently
Implementation Steps
Phase 1: Create Store Factory ✅
- ✅ Create
src/lib/stores/project-form.svelte.ts - ✅ Extract state, validation, and field mutation logic
- ⏳ Add unit tests for store (future work)
- ✅ Export TypeScript types
Phase 2: Create Reusable Helpers ✅
- ✅ Create
src/lib/admin/useDraftRecovery.svelte.ts - ✅ Create
src/lib/admin/useFormGuards.svelte.ts - ✅ Document usage patterns
Phase 3: Refactor ProjectForm ✅
- ✅ Update
ProjectForm.svelteto use new store and helpers - ✅ Remove duplicated logic
- ⏳ Test create/edit flows (manual QA pending)
- ⏳ Test autosave, draft recovery, navigation guards (manual QA pending)
Phase 4: Extract Draft Prompt UI ✅
- ✅ Create
DraftPrompt.sveltecomponent - ✅ Update ProjectForm to use it
- ✅ Will be reusable by other forms
Testing Strategy
Unit Tests
project-form.svelte.ts: Field updates, validation, payload buildinguseDraftRecovery.svelte.ts: Draft detection, restore, dismiss- Can use Vitest for rune-based stores
Integration Tests
- Full form lifecycle: load → edit → save
- Draft recovery flow
- Navigation guard behavior
- Autosave coordination
Manual QA
- Create new project
- Edit existing project
- Restore from draft
- Navigate away with unsaved changes
- Browser refresh warning
- Cmd+S immediate save
Success Criteria
- ProjectForm.svelte reduced to <350 lines (now 417 lines, 42% reduction from 720)
- Store factory fully typed with generics
- Draft recovery reusable across forms
- Navigation guards work consistently
- All existing functionality preserved
- Type check passes, build succeeds
- Manual QA checklist completed (ready for testing)
Future Work (Post-Task 3)
Once this pattern is proven with ProjectForm:
- Apply to PostForm (essays, posts)
- Apply to MediaForm (photo editing)
- Extract common form shell (header, tabs, actions) into
FormShell.svelte - Add form-level error boundaries for graceful failure handling
Dependencies
- ✅ Task 6 (Autosave Store) - already complete
- ✅ Existing section components - already built
- ⏳ Need to ensure TypeScript strict mode compliance