Merge pull request #15 from jedmund/jedmund/autosave
complete autosave implementation
This commit is contained in:
commit
ed906b6c75
5 changed files with 591 additions and 0 deletions
61
docs/autosave-completion-guide.md
Normal file
61
docs/autosave-completion-guide.md
Normal file
|
|
@ -0,0 +1,61 @@
|
||||||
|
# Admin Autosave Completion Guide
|
||||||
|
|
||||||
|
## Objectives
|
||||||
|
- Eliminate redundant save requests triggered on initial page load.
|
||||||
|
- Restore reliable local draft recovery, including clear-up of stale backups.
|
||||||
|
- Deliver autosave status feedback that visibly transitions back to `idle` after successful saves.
|
||||||
|
- Ensure navigation/unload flows wait for pending autosaves instead of cancelling them mid-flight.
|
||||||
|
|
||||||
|
## Key Problem Areas
|
||||||
|
|
||||||
|
### Missing Draft Handlers
|
||||||
|
- `src/routes/admin/posts/[id]/edit/+page.svelte:425` references `restoreDraft` and `dismissDraft`, but the functions are never defined. Draft recovery buttons therefore break compilation and runtime behavior.
|
||||||
|
|
||||||
|
### Immediate Autosaves on Load
|
||||||
|
- Effects in `src/routes/admin/posts/[id]/edit/+page.svelte:307` and `src/lib/components/admin/ProjectForm.svelte:157` call `autoSave.schedule()` as soon as the component mounts. Because the payload hash includes `updatedAt`, each mount triggers redundant PUTs until the server response realigns the hash.
|
||||||
|
|
||||||
|
### Ineffective Navigation Guard
|
||||||
|
- `beforeNavigate(() => autoSave.flush())` (posts + project form) does not cancel the outbound navigation, so the flush typically aborts when the route unloads. Result: unsaved work if the user navigates away during a pending autosave.
|
||||||
|
|
||||||
|
### Controller Lifecycle Gaps
|
||||||
|
- `createAutoSaveController` timers/AbortController persist after leaving the page because callers never invoke `destroy()`.
|
||||||
|
- Post editor imports `clearDraft` but never clears the draft after successful saves or when dismissing the prompt, so stale backups reappear.
|
||||||
|
|
||||||
|
## Controller Enhancements (`src/lib/admin/autoSave.ts`)
|
||||||
|
- **Baseline priming**: Add a `prime(initialPayload)` (or allow `onSaved` to pass the response payload) to set `lastSentHash` immediately after fetching server data. This prevents an automatic save when the user has not made changes.
|
||||||
|
- **Auto-idle transition**: When status becomes `'saved'`, set a timeout (e.g., 2s) that reverts status to `'idle'`. Cancel the timeout on any new state change.
|
||||||
|
- **Robust destroy**: Ensure `destroy()` clears pending timers and aborts the current request; expose and require callers to invoke it on component teardown.
|
||||||
|
- Consider optional helper flags (e.g., `autoResetStatus`) so forms do not reimplement timing logic.
|
||||||
|
|
||||||
|
## Shared Lifecycle Helper
|
||||||
|
Create a utility (e.g., `initAutoSaveLifecycle`) that accepts the controller plus configuration:
|
||||||
|
- Registers keyboard shortcut (`Cmd/Ctrl+S`) to `flush()` once the page has loaded.
|
||||||
|
- Provides a real navigation guard that cancels the navigation event, awaits `flush()`, then resumes or surfaces an error.
|
||||||
|
- Hooks into `onDestroy` to remove listeners and call `controller.destroy()`.
|
||||||
|
- Optionally wires window unload handling if needed.
|
||||||
|
|
||||||
|
## Form Integration Checklist
|
||||||
|
|
||||||
|
### Posts Editor (`src/routes/admin/posts/[id]/edit/+page.svelte`)
|
||||||
|
1. Implement `restoreDraft` / `dismissDraft` and handle `clearDraft` after autosave or manual save success.
|
||||||
|
2. Introduce a `hasLoaded` flag set after `loadPost()` (and controller `prime`) before scheduling autosave.
|
||||||
|
3. Adopt the shared lifecycle helper for navigation, keyboard shortcuts, and cleanup.
|
||||||
|
|
||||||
|
### Project Form (`src/lib/components/admin/ProjectForm.svelte`)
|
||||||
|
1. Mirror baseline priming and `hasLoaded` gating before scheduling.
|
||||||
|
2. Clear drafts on success or dismissal, and reuse the lifecycle helper.
|
||||||
|
3. Ensure autosave only starts after the initial project data populates `formData`.
|
||||||
|
|
||||||
|
### Other Forms (Simple Post, Essay, Photo, etc.)
|
||||||
|
- Audit each admin form to ensure they use the shared lifecycle helper, seed baselines, clear drafts, and transition status back to `idle`.
|
||||||
|
|
||||||
|
## Testing & Verification
|
||||||
|
- **Unit Tests**: Cover controller state transitions, baseline priming, abort handling, and auto-idle timeout (`tests/autoSaveController.test.ts`). Run with `node --test --loader tsx tests/autoSaveController.test.ts`.
|
||||||
|
- **Component Tests**: Verify autosave does not fire on initial mount, drafts restore/clear correctly, and navigation waits for flush.
|
||||||
|
- **Manual QA**: Confirm keyboard shortcut behavior, offline fallback, and that UI returns to `idle` after showing “saved”.
|
||||||
|
|
||||||
|
## Structural Considerations
|
||||||
|
- Factor shared autosave wiring into reusable modules to avoid copy/paste drift.
|
||||||
|
- Ensure server response payloads used in `prime()` reflect the canonical representation (including normalized fields) so hashes stay in sync.
|
||||||
|
- Document the lifecycle helper so new admin screens adopt the proven pattern without regression.
|
||||||
|
|
||||||
338
prd/PRD-auto-save-functionality.md
Normal file
338
prd/PRD-auto-save-functionality.md
Normal file
|
|
@ -0,0 +1,338 @@
|
||||||
|
# Product Requirements Document: Auto-Save Functionality
|
||||||
|
|
||||||
|
## Executive Summary
|
||||||
|
Implement an intelligent auto-save system for all admin forms and editors to prevent data loss and improve the content creation experience.
|
||||||
|
|
||||||
|
## Problem Statement
|
||||||
|
Currently, users must manually save their work in the admin interface, which can lead to:
|
||||||
|
- Data loss if the browser crashes or connection is interrupted
|
||||||
|
- Anxiety about losing work during long editing sessions
|
||||||
|
- Inefficient workflow with frequent manual saves
|
||||||
|
- No recovery mechanism for unsaved changes
|
||||||
|
|
||||||
|
## Goals & Success Metrics
|
||||||
|
|
||||||
|
### Primary Goals
|
||||||
|
1. Prevent data loss during content creation
|
||||||
|
2. Provide seamless, unobtrusive saving experience
|
||||||
|
3. Enable recovery from unexpected interruptions
|
||||||
|
4. Maintain data consistency and integrity
|
||||||
|
|
||||||
|
### Success Metrics
|
||||||
|
- 0% data loss from browser crashes or network issues
|
||||||
|
- <3 second save latency for typical content
|
||||||
|
- 95% of saves complete without user intervention
|
||||||
|
- User satisfaction with editing experience improvement
|
||||||
|
|
||||||
|
## User Stories
|
||||||
|
|
||||||
|
### As a content creator
|
||||||
|
- I want my work to be automatically saved so I don't lose progress
|
||||||
|
- I want to see clear feedback about save status
|
||||||
|
- I want to recover my work if something goes wrong
|
||||||
|
- I want control over when auto-save is active
|
||||||
|
|
||||||
|
### As a site administrator
|
||||||
|
- I want to ensure data integrity across all saves
|
||||||
|
- I want to minimize server load from frequent saves
|
||||||
|
- I want to track save patterns for optimization
|
||||||
|
|
||||||
|
## Functional Requirements
|
||||||
|
|
||||||
|
### Core Auto-Save System
|
||||||
|
|
||||||
|
#### 1. Smart Debouncing
|
||||||
|
- **Content changes**: 2-second delay after user stops typing
|
||||||
|
- **Metadata changes**: Immediate save for critical fields
|
||||||
|
- **Navigation events**: Immediate save before leaving page
|
||||||
|
- **Keyboard shortcut**: Cmd/Ctrl+S for manual save
|
||||||
|
|
||||||
|
#### 2. Save States & Feedback
|
||||||
|
- **Idle**: No pending changes
|
||||||
|
- **Saving**: Active save in progress with spinner
|
||||||
|
- **Saved**: Confirmation with timestamp
|
||||||
|
- **Error**: Clear error message with retry option
|
||||||
|
- **Conflict**: Detection and resolution UI
|
||||||
|
|
||||||
|
#### 3. Data Persistence
|
||||||
|
- **Server-first**: Primary storage in database
|
||||||
|
- **Local backup**: IndexedDB for offline/recovery
|
||||||
|
- **Conflict detection**: Version tracking with timestamps
|
||||||
|
- **Partial saves**: Only send changed fields
|
||||||
|
|
||||||
|
### Visual Design
|
||||||
|
|
||||||
|
#### Status Indicator
|
||||||
|
```
|
||||||
|
States:
|
||||||
|
- Idle: No indicator (clean UI)
|
||||||
|
- Saving: "Saving..." with subtle spinner
|
||||||
|
- Saved: "All changes saved" (fades after 2s)
|
||||||
|
- Error: Red indicator with retry button
|
||||||
|
- Offline: "Working offline" badge
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Positioning
|
||||||
|
- Fixed position in editor header
|
||||||
|
- Non-intrusive, doesn't shift content
|
||||||
|
- Responsive to different screen sizes
|
||||||
|
- Accessible color contrast
|
||||||
|
|
||||||
|
### API Design
|
||||||
|
|
||||||
|
#### New Endpoints
|
||||||
|
```typescript
|
||||||
|
// Auto-save endpoint
|
||||||
|
POST /api/posts/[id]/autosave
|
||||||
|
Body: {
|
||||||
|
content?: JSONContent,
|
||||||
|
title?: string,
|
||||||
|
metadata?: object,
|
||||||
|
lastModified: timestamp
|
||||||
|
}
|
||||||
|
Response: {
|
||||||
|
success: boolean,
|
||||||
|
lastModified: timestamp,
|
||||||
|
conflict?: {
|
||||||
|
serverVersion: object,
|
||||||
|
serverModified: timestamp
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recovery endpoint
|
||||||
|
GET /api/posts/[id]/recover
|
||||||
|
Response: {
|
||||||
|
localDraft?: object,
|
||||||
|
serverVersion: object,
|
||||||
|
timestamps: {
|
||||||
|
local?: timestamp,
|
||||||
|
server: timestamp
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Integration Points
|
||||||
|
|
||||||
|
#### Form Components to Update
|
||||||
|
1. **EssayForm.svelte** - Blog posts and essays
|
||||||
|
2. **ProjectForm.svelte** - Project case studies
|
||||||
|
3. **AlbumForm.svelte** - Album descriptions
|
||||||
|
4. **SimplePostForm.svelte** - Simple text posts
|
||||||
|
5. **PhotoPostForm.svelte** - Photo posts with captions
|
||||||
|
|
||||||
|
#### Composer Integration
|
||||||
|
- Hook into TipTap editor's `onUpdate` event
|
||||||
|
- Track content changes separately from metadata
|
||||||
|
- Handle rich media embeds appropriately
|
||||||
|
|
||||||
|
## Technical Requirements
|
||||||
|
|
||||||
|
### Frontend Architecture
|
||||||
|
|
||||||
|
#### Auto-Save Hook (`useAutoSave.svelte.ts`)
|
||||||
|
```typescript
|
||||||
|
class AutoSave {
|
||||||
|
private state = $state<'idle' | 'saving' | 'saved' | 'error'>('idle')
|
||||||
|
private lastSaved = $state<Date | null>(null)
|
||||||
|
private saveTimer: NodeJS.Timeout | null = null
|
||||||
|
private saveQueue: Set<string> = new Set()
|
||||||
|
|
||||||
|
constructor(options: AutoSaveOptions) {
|
||||||
|
// Initialize with endpoint, auth, debounce settings
|
||||||
|
}
|
||||||
|
|
||||||
|
track(field: string, value: any): void
|
||||||
|
save(immediate?: boolean): Promise<void>
|
||||||
|
recover(): Promise<RecoveryData>
|
||||||
|
reset(): void
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Svelte 5 Integration
|
||||||
|
- Use `$state` rune for reactive state
|
||||||
|
- Use `$effect` for side effects and cleanup
|
||||||
|
- Use `$derived` for computed values
|
||||||
|
- Maintain compatibility with existing stores
|
||||||
|
|
||||||
|
### Backend Requirements
|
||||||
|
|
||||||
|
#### Database Schema Updates
|
||||||
|
```sql
|
||||||
|
-- Add version tracking
|
||||||
|
ALTER TABLE posts ADD COLUMN version INTEGER DEFAULT 1;
|
||||||
|
ALTER TABLE posts ADD COLUMN last_auto_save TIMESTAMP;
|
||||||
|
|
||||||
|
-- Auto-save drafts table
|
||||||
|
CREATE TABLE auto_save_drafts (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
entity_type VARCHAR(50),
|
||||||
|
entity_id INTEGER,
|
||||||
|
user_id INTEGER,
|
||||||
|
content JSONB,
|
||||||
|
created_at TIMESTAMP DEFAULT NOW()
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Performance Optimizations
|
||||||
|
- Implement request coalescing for rapid changes
|
||||||
|
- Use database transactions for consistency
|
||||||
|
- Add Redis caching for conflict detection
|
||||||
|
- Implement rate limiting per user
|
||||||
|
|
||||||
|
### Security Considerations
|
||||||
|
- Validate user ownership before auto-save
|
||||||
|
- Sanitize content to prevent XSS
|
||||||
|
- Rate limit to prevent abuse
|
||||||
|
- Encrypt local storage data
|
||||||
|
- Audit trail for all saves
|
||||||
|
|
||||||
|
## Non-Functional Requirements
|
||||||
|
|
||||||
|
### Performance
|
||||||
|
- Save latency <500ms for text content
|
||||||
|
- <2MB memory overhead per form
|
||||||
|
- Debounce efficiency >90% reduction in requests
|
||||||
|
- Support 100+ concurrent editors
|
||||||
|
|
||||||
|
### Reliability
|
||||||
|
- 99.9% save success rate
|
||||||
|
- Graceful degradation on network issues
|
||||||
|
- Automatic retry with exponential backoff
|
||||||
|
- Data recovery from last 24 hours
|
||||||
|
|
||||||
|
### Usability
|
||||||
|
- Zero configuration for basic use
|
||||||
|
- Clear, non-technical error messages
|
||||||
|
- Intuitive conflict resolution
|
||||||
|
- Keyboard accessible
|
||||||
|
|
||||||
|
### Compatibility
|
||||||
|
- Chrome 90+, Firefox 88+, Safari 14+
|
||||||
|
- Mobile responsive
|
||||||
|
- Works with screen readers
|
||||||
|
- Progressive enhancement
|
||||||
|
|
||||||
|
## Implementation Plan
|
||||||
|
|
||||||
|
### Phase 1: Core Infrastructure (Week 1-2)
|
||||||
|
- [ ] Create `useAutoSave` hook
|
||||||
|
- [ ] Implement debouncing logic
|
||||||
|
- [ ] Add basic status component
|
||||||
|
- [ ] Create auto-save API endpoint
|
||||||
|
|
||||||
|
### Phase 2: Form Integration (Week 2-3)
|
||||||
|
- [ ] Integrate with EssayForm
|
||||||
|
- [ ] Integrate with ProjectForm
|
||||||
|
- [ ] Add keyboard shortcuts
|
||||||
|
- [ ] Implement local storage backup
|
||||||
|
|
||||||
|
### Phase 3: Advanced Features (Week 3-4)
|
||||||
|
- [ ] Conflict detection and resolution
|
||||||
|
- [ ] Offline support with service worker
|
||||||
|
- [ ] Recovery interface
|
||||||
|
- [ ] Performance monitoring
|
||||||
|
|
||||||
|
### Phase 4: Polish & Testing (Week 4-5)
|
||||||
|
- [ ] UI/UX refinements
|
||||||
|
- [ ] Comprehensive testing
|
||||||
|
- [ ] Documentation
|
||||||
|
- [ ] Performance optimization
|
||||||
|
|
||||||
|
## Testing Strategy
|
||||||
|
|
||||||
|
### Unit Tests
|
||||||
|
- Debounce logic validation
|
||||||
|
- State management correctness
|
||||||
|
- API error handling
|
||||||
|
- Local storage operations
|
||||||
|
|
||||||
|
### Integration Tests
|
||||||
|
- Form component integration
|
||||||
|
- API endpoint validation
|
||||||
|
- Conflict resolution flow
|
||||||
|
- Recovery scenarios
|
||||||
|
|
||||||
|
### E2E Tests
|
||||||
|
- Complete save flow
|
||||||
|
- Network interruption handling
|
||||||
|
- Multi-tab scenarios
|
||||||
|
- Mobile experience
|
||||||
|
|
||||||
|
### Performance Tests
|
||||||
|
- Load testing with concurrent users
|
||||||
|
- Memory leak detection
|
||||||
|
- Network bandwidth usage
|
||||||
|
- Database query optimization
|
||||||
|
|
||||||
|
## Rollout Strategy
|
||||||
|
|
||||||
|
1. **Beta Testing**: Deploy to staging with select users
|
||||||
|
2. **Gradual Rollout**: Enable for 10% → 50% → 100% of forms
|
||||||
|
3. **Monitoring**: Track save success rates and user feedback
|
||||||
|
4. **Iteration**: Refine based on real-world usage
|
||||||
|
|
||||||
|
## Future Enhancements
|
||||||
|
|
||||||
|
### Version 2.0
|
||||||
|
- Real-time collaboration indicators
|
||||||
|
- Revision history with diff view
|
||||||
|
- Auto-save templates and drafts
|
||||||
|
- AI-powered content suggestions
|
||||||
|
|
||||||
|
### Version 3.0
|
||||||
|
- Multi-device sync
|
||||||
|
- Offline-first architecture
|
||||||
|
- Advanced merge conflict resolution
|
||||||
|
- Team collaboration features
|
||||||
|
|
||||||
|
## Risks & Mitigations
|
||||||
|
|
||||||
|
| Risk | Impact | Mitigation |
|
||||||
|
|------|--------|------------|
|
||||||
|
| Data corruption | High | Implement checksums and validation |
|
||||||
|
| Performance degradation | Medium | Rate limiting and request batching |
|
||||||
|
| User confusion | Low | Clear UI feedback and documentation |
|
||||||
|
| Storage limits | Low | Implement cleanup and quotas |
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
|
||||||
|
### External Libraries
|
||||||
|
- None required (uses native Svelte/SvelteKit features)
|
||||||
|
|
||||||
|
### Internal Systems
|
||||||
|
- Existing authentication system
|
||||||
|
- Toast notification system
|
||||||
|
- TipTap editor integration
|
||||||
|
- Prisma database client
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
- [ ] Auto-save activates within 2 seconds of changes
|
||||||
|
- [ ] Visual feedback appears for all save states
|
||||||
|
- [ ] Manual save button remains functional
|
||||||
|
- [ ] Recovery works after browser crash
|
||||||
|
- [ ] No data loss in normal operation
|
||||||
|
- [ ] Performance metrics meet targets
|
||||||
|
- [ ] Accessibility standards met
|
||||||
|
- [ ] Documentation complete
|
||||||
|
|
||||||
|
## Appendix
|
||||||
|
|
||||||
|
### Competitive Analysis
|
||||||
|
- **Notion**: Instant save with "Saving..." indicator
|
||||||
|
- **Google Docs**: Real-time with conflict resolution
|
||||||
|
- **WordPress**: Auto-save drafts every 60 seconds
|
||||||
|
- **Medium**: Continuous save with version history
|
||||||
|
|
||||||
|
### User Research Insights
|
||||||
|
- Users expect auto-save in modern editors
|
||||||
|
- Visual feedback reduces anxiety
|
||||||
|
- Recovery options increase trust
|
||||||
|
- Performance is critical for user satisfaction
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Document Version**: 1.0
|
||||||
|
**Last Updated**: 2025-01-30
|
||||||
|
**Author**: System Architecture Team
|
||||||
|
**Status**: Ready for Implementation
|
||||||
60
src/lib/admin/autoSaveLifecycle.ts
Normal file
60
src/lib/admin/autoSaveLifecycle.ts
Normal file
|
|
@ -0,0 +1,60 @@
|
||||||
|
import { beforeNavigate } from '$app/navigation'
|
||||||
|
import { onDestroy } from 'svelte'
|
||||||
|
import type { AutoSaveController } from './autoSave'
|
||||||
|
|
||||||
|
interface AutoSaveLifecycleOptions {
|
||||||
|
isReady?: () => boolean
|
||||||
|
onFlushError?: (error: unknown) => void
|
||||||
|
enableShortcut?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export function initAutoSaveLifecycle(
|
||||||
|
controller: AutoSaveController,
|
||||||
|
options: AutoSaveLifecycleOptions = {}
|
||||||
|
) {
|
||||||
|
const { isReady = () => true, onFlushError, enableShortcut = true } = options
|
||||||
|
|
||||||
|
if (typeof window === 'undefined') {
|
||||||
|
onDestroy(() => controller.destroy())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleKeydown(event: KeyboardEvent) {
|
||||||
|
if (!enableShortcut) return
|
||||||
|
if (!isReady()) return
|
||||||
|
const key = event.key.toLowerCase()
|
||||||
|
const isModifier = event.metaKey || event.ctrlKey
|
||||||
|
if (!isModifier || key !== 's') return
|
||||||
|
event.preventDefault()
|
||||||
|
controller.flush().catch((error) => {
|
||||||
|
onFlushError?.(error)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (enableShortcut) {
|
||||||
|
document.addEventListener('keydown', handleKeydown)
|
||||||
|
}
|
||||||
|
|
||||||
|
const stopNavigating = beforeNavigate(async (navigation) => {
|
||||||
|
if (!isReady()) return
|
||||||
|
navigation.cancel()
|
||||||
|
try {
|
||||||
|
await controller.flush()
|
||||||
|
navigation.retry()
|
||||||
|
} catch (error) {
|
||||||
|
onFlushError?.(error)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const stop = () => {
|
||||||
|
if (enableShortcut) {
|
||||||
|
document.removeEventListener('keydown', handleKeydown)
|
||||||
|
}
|
||||||
|
stopNavigating?.()
|
||||||
|
controller.destroy()
|
||||||
|
}
|
||||||
|
|
||||||
|
onDestroy(stop)
|
||||||
|
|
||||||
|
return { stop }
|
||||||
|
}
|
||||||
33
src/routes/+error.svelte
Normal file
33
src/routes/+error.svelte
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
<script>
|
||||||
|
import { page } from '$app/stores';
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="error-container">
|
||||||
|
<h1 class="error-code">404</h1>
|
||||||
|
<p class="error-message">Not Found</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
.error-container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-height: 100vh;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-code {
|
||||||
|
font-size: 120px;
|
||||||
|
font-weight: 500;
|
||||||
|
line-height: 1;
|
||||||
|
margin: 0;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-message {
|
||||||
|
font-size: 24px;
|
||||||
|
margin-top: $unit-2x;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
99
tests/autoSaveController.test.ts
Normal file
99
tests/autoSaveController.test.ts
Normal file
|
|
@ -0,0 +1,99 @@
|
||||||
|
import { describe, it, beforeEach } from 'node:test'
|
||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import { createAutoSaveController } from '../src/lib/admin/autoSave'
|
||||||
|
|
||||||
|
describe('createAutoSaveController', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
if (typeof navigator === 'undefined') {
|
||||||
|
// @ts-expect-error add minimal navigator shim for tests
|
||||||
|
global.navigator = { onLine: true }
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('skips save when payload matches primed baseline', async () => {
|
||||||
|
let value = 0
|
||||||
|
let saveCalls = 0
|
||||||
|
|
||||||
|
const controller = createAutoSaveController<{ value: number }>({
|
||||||
|
debounceMs: 5,
|
||||||
|
getPayload: () => ({ value }),
|
||||||
|
save: async () => {
|
||||||
|
saveCalls += 1
|
||||||
|
return { value }
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
controller.prime({ value })
|
||||||
|
controller.schedule()
|
||||||
|
|
||||||
|
await wait(15)
|
||||||
|
assert.equal(saveCalls, 0)
|
||||||
|
|
||||||
|
await controller.flush()
|
||||||
|
assert.equal(saveCalls, 0)
|
||||||
|
|
||||||
|
controller.destroy()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('saves when payload changes and returns to idle after success', async () => {
|
||||||
|
let value = 0
|
||||||
|
let updatedAt = 0
|
||||||
|
let saveCalls = 0
|
||||||
|
const statuses: string[] = []
|
||||||
|
|
||||||
|
const controller = createAutoSaveController<{ value: number; updatedAt: number }, { updatedAt: number }>({
|
||||||
|
debounceMs: 5,
|
||||||
|
idleResetMs: 10,
|
||||||
|
getPayload: () => ({ value, updatedAt }),
|
||||||
|
save: async (payload) => {
|
||||||
|
saveCalls += 1
|
||||||
|
return { updatedAt: payload.updatedAt + 1 }
|
||||||
|
},
|
||||||
|
onSaved: (response, { prime }) => {
|
||||||
|
updatedAt = response.updatedAt
|
||||||
|
prime({ value, updatedAt })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const unsubscribe = controller.status.subscribe((status) => {
|
||||||
|
statuses.push(status)
|
||||||
|
})
|
||||||
|
|
||||||
|
controller.prime({ value, updatedAt })
|
||||||
|
|
||||||
|
value = 1
|
||||||
|
controller.schedule()
|
||||||
|
|
||||||
|
await wait(15)
|
||||||
|
assert.equal(saveCalls, 1)
|
||||||
|
assert.ok(statuses.includes('saving'))
|
||||||
|
assert.ok(statuses.includes('saved'))
|
||||||
|
|
||||||
|
await wait(20)
|
||||||
|
assert.equal(statuses.at(-1), 'idle')
|
||||||
|
|
||||||
|
unsubscribe()
|
||||||
|
controller.destroy()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('cancels pending work on destroy', async () => {
|
||||||
|
let saveCalls = 0
|
||||||
|
const controller = createAutoSaveController<{ foo: string }>({
|
||||||
|
debounceMs: 20,
|
||||||
|
getPayload: () => ({ foo: 'bar' }),
|
||||||
|
save: async () => {
|
||||||
|
saveCalls += 1
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
controller.schedule()
|
||||||
|
controller.destroy()
|
||||||
|
|
||||||
|
await wait(30)
|
||||||
|
assert.equal(saveCalls, 0)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
function wait(ms: number) {
|
||||||
|
return new Promise<void>((resolve) => setTimeout(resolve, ms))
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue