jedmund-svelte/docs/task-3-project-form-refactor-plan.md
Justin Edmund 1339e81bf4 docs: mark Task 3 project form refactor as complete
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>
2025-10-08 00:37:49 -07:00

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.svelte
    • ProjectBrandingForm.svelte
    • ProjectImagesForm.svelte
    • ProjectStylingForm.svelte
    • ProjectGalleryForm.svelte
  • Autosave integrated: Uses createAutoSaveStore from 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 $effect blocks (~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

  1. Not reusable: Same patterns will be copy-pasted to PostForm, EssayForm, etc.
  2. Hard to test: Logic is tightly coupled to component lifecycle
  3. Unclear boundaries: Business logic mixed with UI orchestration
  4. 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

  1. Runes in .svelte.ts files: 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
  2. 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
  3. $derived for computed values: Use $derived instead of manual tracking

    • const isDirty = $derived(original !== fields)
    • Automatically re-evaluates when dependencies change
  4. $effect for side effects: Lifecycle logic in composable functions

    • Event listeners: $effect(() => { addEventListener(); return () => removeListener() })
    • Auto-cleanup via return function
    • Replaces onMount/onDestroy patterns
  5. Type safety with generics: useDraftRecovery<TPayload> for reusability

    • Inferred types from usage
    • ReturnType<typeof factory> for store types
  6. SvelteKit integration: Use beforeNavigate for 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

  1. Create src/lib/stores/project-form.svelte.ts
  2. Extract state, validation, and field mutation logic
  3. Add unit tests for store (future work)
  4. Export TypeScript types

Phase 2: Create Reusable Helpers

  1. Create src/lib/admin/useDraftRecovery.svelte.ts
  2. Create src/lib/admin/useFormGuards.svelte.ts
  3. Document usage patterns

Phase 3: Refactor ProjectForm

  1. Update ProjectForm.svelte to use new store and helpers
  2. Remove duplicated logic
  3. Test create/edit flows (manual QA pending)
  4. Test autosave, draft recovery, navigation guards (manual QA pending)

Phase 4: Extract Draft Prompt UI

  1. Create DraftPrompt.svelte component
  2. Update ProjectForm to use it
  3. Will be reusable by other forms

Testing Strategy

Unit Tests

  • project-form.svelte.ts: Field updates, validation, payload building
  • useDraftRecovery.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:

  1. Apply to PostForm (essays, posts)
  2. Apply to MediaForm (photo editing)
  3. Extract common form shell (header, tabs, actions) into FormShell.svelte
  4. 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