From d34369bd292ee7d9b0bb11b713999efa10c48df1 Mon Sep 17 00:00:00 2001 From: VitekHub Date: Tue, 2 Jun 2026 18:41:54 +0200 Subject: [PATCH 1/5] feat: add auto-save with local draft pattern and sync status indicator --- CLAUDE.md | 1 + docs/implementation-plan/06-phase-6-data.md | 32 +- src/features/auth/lib/.gitkeep | 0 src/features/fields/model/auto-save.test.ts | 453 ++++++++++++++++++ src/features/fields/model/auto-save.ts | 156 ++++++ src/features/fields/model/sync-status.test.ts | 57 +++ src/features/fields/model/sync-status.ts | 45 ++ src/features/fields/model/use-save-field.ts | 1 + src/features/fields/ui/DashboardPage.test.tsx | 46 +- src/features/fields/ui/DashboardPage.tsx | 51 +- src/features/fields/ui/EmailField.test.tsx | 22 +- src/features/fields/ui/EmailField.tsx | 9 +- src/features/fields/ui/FieldCard.tsx | 9 +- src/features/fields/ui/NoteField.test.tsx | 24 +- src/features/fields/ui/NoteField.tsx | 19 +- src/features/fields/ui/SaveIndicator.test.tsx | 50 ++ src/features/fields/ui/SaveIndicator.tsx | 63 +++ src/features/fields/ui/WebsiteField.test.tsx | 22 +- src/features/fields/ui/WebsiteField.tsx | 9 +- src/shared/crypto/.gitkeep | 0 src/shared/i18n/locales/cs/fields.json | 8 +- src/shared/i18n/locales/en/fields.json | 8 +- src/test/setup.ts | 2 + 23 files changed, 1026 insertions(+), 61 deletions(-) delete mode 100644 src/features/auth/lib/.gitkeep create mode 100644 src/features/fields/model/auto-save.test.ts create mode 100644 src/features/fields/model/auto-save.ts create mode 100644 src/features/fields/model/sync-status.test.ts create mode 100644 src/features/fields/model/sync-status.ts create mode 100644 src/features/fields/ui/SaveIndicator.test.tsx create mode 100644 src/features/fields/ui/SaveIndicator.tsx delete mode 100644 src/shared/crypto/.gitkeep diff --git a/CLAUDE.md b/CLAUDE.md index 477c1c2..e44909c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -173,6 +173,7 @@ See `IMPLEMENTATION-PLAN.md` for the full 36-step plan. - Step 23 (Non-Extractable Key Vault + Zustand Store Refactor) — complete - Step 24 (Supabase API Adapter) — complete - Step 25 (Encrypted Field CRUD) — complete +- Step 26 (Auto-Save + Sync Flow) — complete ### Implementation Notes diff --git a/docs/implementation-plan/06-phase-6-data.md b/docs/implementation-plan/06-phase-6-data.md index cdd3fc8..a7316b6 100644 --- a/docs/implementation-plan/06-phase-6-data.md +++ b/docs/implementation-plan/06-phase-6-data.md @@ -70,24 +70,24 @@ --- -## Step 26 — Auto-Save + Sync Flow +## Step 26 — Auto-Save + Sync Flow ✅ -**Goal:** Auto-save encrypted fields with debounce and optimistic updates. +**Goal:** Auto-save encrypted fields with debounce and local draft pattern. **Code:** -- `src/features/fields/model/auto-save.ts`: - - Debounced auto-save: 1-second debounce after user stops typing, using a proper debounce utility (not raw `setTimeout`). Consider `useDebouncedCallback` from `usehooks-ts` or a custom hook that cancels on unmount. - - Optimistic update: update TanStack Query cache immediately, send to server in background - - Revert on error: roll back TanStack Query cache if save fails - - Save status indicator: "Saving...", "Saved", "Error — retry?" -- `src/features/fields/ui/SaveIndicator.tsx` — shows save status next to each field -- `src/features/fields/model/sync-status.ts` — Zustand store for sync status per field -- Handle concurrent edits: last-write-wins with version check +- Auto-save hook: + - Debounced save: 1-second debounce after user stops typing, using `setTimeout` with refs to avoid stale closures (ref to latest mutation function, ref to latest value). Clear timeouts on unmount and on vault lock. + - Local draft pattern: instead of mutating the TanStack Query cache, maintain a local `draft` state that takes priority over query data while the user is editing. When not editing, fall back to query data. This avoids cache-invalidation race conditions in text editors. + - Preserve draft on error: on save failure, keep the draft content and set sync status to `error`. Provide a `retry()` function that re-submits the latest draft immediately (no debounce). Do not roll back to stale server data. + - "Saved" auto-dismiss: transition from `saved` → `idle` after 3 seconds so the check mark doesn't persist forever. + - Vault lock handling: clear debounce/saved timers and reset editing state on vault lock; return empty string as value since query cache is purged. +- Save indicator component — renders nothing for `idle`, spinner + "Saving..." for `saving`, check + "Saved" for `saved`, error icon + "Save failed" + "Retry" button for `error`. Uses static i18n key map so i18next-parser can discover the strings. +- Sync status Zustand store — `Record` with `setStatus`, `resetField`, `resetAll` actions. Uses devtools middleware. `SyncStatus` type: `'idle' | 'saving' | 'saved' | 'error'`. **Tests:** -- Unit: debounce doesn't trigger on rapid keystrokes -- Unit: optimistic update shows saved content immediately -- Unit: error reverts to previous content -- Component test: SaveIndicator shows correct states -- Integration: type content → auto-save → verify saved in Supabase -- Integration: type content → network error → retry → success +- Unit: debounce cancels earlier timers — rapid `setValue` calls trigger only one save +- Unit: local draft updates immediately on `setValue` (optimistic display) +- Unit: error preserves draft content and sets status to `error`; `retry` resubmits without debounce +- Unit: vault lock resets editing state and clears timers +- Component test: SaveIndicator renders correct UI for each status, including retry button +- Unit: sync status store transitions through full lifecycle and resets diff --git a/src/features/auth/lib/.gitkeep b/src/features/auth/lib/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/src/features/fields/model/auto-save.test.ts b/src/features/fields/model/auto-save.test.ts new file mode 100644 index 0000000..17aef61 --- /dev/null +++ b/src/features/fields/model/auto-save.test.ts @@ -0,0 +1,453 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { renderHook, act, waitFor } from '@testing-library/react' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { createElement, type ReactNode } from 'react' +import { useCryptoStore } from '@/shared/crypto/crypto-store' +import { useSyncStatusStore } from '@/features/fields/model/sync-status' + +// --- Hoisted mocks --- + +const { mockLoadField, mockSaveField, mockUseAuth } = vi.hoisted(() => ({ + mockLoadField: vi.fn<(userId: string, fieldName: string) => Promise>(), + mockSaveField: vi.fn<(userId: string, fieldName: string, plaintext: string) => Promise>(), + mockUseAuth: vi.fn<() => { user: { id: string; username: string } | null }>(), +})) + +vi.mock('@/features/fields/model/field-service', () => ({ + fieldService: { loadField: mockLoadField, saveField: mockSaveField }, +})) + +vi.mock('@/shared/auth/auth-context', () => ({ + useAuth: mockUseAuth, +})) + +// --- Import after mocks --- + +import { useAutoSave } from '@/features/fields/model/auto-save' + +const DEBOUNCE_MS = 1000 +const SAVED_DISPLAY_MS = 3000 + +function createQueryClient() { + return new QueryClient({ + defaultOptions: { + queries: { retry: false, gcTime: 0 }, + mutations: { retry: false }, + }, + }) +} + +function createWrapper(queryClient: QueryClient) { + return function wrapper({ children }: { children: ReactNode }) { + return createElement(QueryClientProvider, { client: queryClient }, children) + } +} + +describe('useAutoSave', () => { + let queryClient: QueryClient + + beforeEach(() => { + vi.clearAllMocks() + queryClient = createQueryClient() + useCryptoStore.setState({ + loadedFieldKeys: { note: true, website: true, email: true }, + isVaultLocked: false, + lastActivity: Date.now(), + cachedEnvelope: null, + }) + useSyncStatusStore.getState().resetAll() + mockUseAuth.mockReturnValue({ user: { id: 'user-123', username: 'testuser' } }) + mockLoadField.mockResolvedValue('initial content') + mockSaveField.mockResolvedValue(undefined) + }) + + afterEach(() => { + queryClient.clear() + vi.useRealTimers() + }) + + it('loads initial value from query data', async () => { + const { result } = renderHook(() => useAutoSave('note'), { + wrapper: createWrapper(queryClient), + }) + + await waitFor(() => { + expect(result.current.value).toBe('initial content') + }) + }) + + it('returns empty string when query returns null', async () => { + mockLoadField.mockResolvedValue(null) + const { result } = renderHook(() => useAutoSave('note'), { + wrapper: createWrapper(queryClient), + }) + + await waitFor(() => { + expect(result.current.value).toBe('') + }) + }) + + it('updates value immediately on setValue (optimistic)', async () => { + const { result } = renderHook(() => useAutoSave('note'), { + wrapper: createWrapper(queryClient), + }) + + await waitFor(() => { + expect(result.current.value).toBe('initial content') + }) + + act(() => { + result.current.setValue('new content') + }) + + expect(result.current.value).toBe('new content') + }) + + it('keeps status idle immediately after setValue', async () => { + const { result } = renderHook(() => useAutoSave('note'), { + wrapper: createWrapper(queryClient), + }) + + await waitFor(() => { + expect(result.current.value).toBe('initial content') + }) + + act(() => { + result.current.setValue('new content') + }) + + // Status should still be 'idle' immediately after setValue + // (it will transition to 'saving' only when the debounce fires) + expect(result.current.syncStatus).toBe('idle') + }) + + it('sets sync status to error when save fails', async () => { + mockSaveField.mockRejectedValueOnce(new Error('Network error')) + const { result } = renderHook(() => useAutoSave('note'), { + wrapper: createWrapper(queryClient), + }) + + await waitFor(() => { + expect(result.current.value).toBe('initial content') + }) + + act(() => { + result.current.setValue('new content') + }) + + // Wait for the debounce + mutation to complete + await waitFor( + () => { + expect(result.current.syncStatus).toBe('error') + }, + { timeout: 5000 }, + ) + + // Draft is preserved on error + expect(result.current.value).toBe('new content') + }) + + it('resets draft when vault locks', async () => { + const { result } = renderHook(() => useAutoSave('note'), { + wrapper: createWrapper(queryClient), + }) + + await waitFor(() => { + expect(result.current.value).toBe('initial content') + }) + + act(() => { + result.current.setValue('edited content') + }) + expect(result.current.value).toBe('edited content') + + // Lock vault + act(() => { + useCryptoStore.setState({ isVaultLocked: true, loadedFieldKeys: {} }) + }) + + // After vault lock, draft is reset and query is disabled + await waitFor(() => { + expect(result.current.value).toBe('') + }) + }) + + it('does not call saveField when userId is empty', async () => { + mockUseAuth.mockReturnValue({ user: null }) + const { result } = renderHook(() => useAutoSave('note'), { + wrapper: createWrapper(queryClient), + }) + + // The value should be empty string since the query is disabled (no user) + expect(result.current.value).toBe('') + + act(() => { + result.current.setValue('content') + }) + + // Wait a bit to ensure no save is triggered + await new Promise((resolve) => setTimeout(resolve, 100)) + expect(mockSaveField).not.toHaveBeenCalled() + }) + + it('auto-retries save when browser comes back online', async () => { + mockSaveField.mockRejectedValueOnce(new Error('Network error')) + const { result } = renderHook(() => useAutoSave('note'), { + wrapper: createWrapper(queryClient), + }) + + await waitFor(() => { + expect(result.current.value).toBe('initial content') + }) + + act(() => { + result.current.setValue('offline content') + }) + + // Wait for the debounce + failed mutation + await waitFor( + () => { + expect(result.current.syncStatus).toBe('error') + }, + { timeout: 5000 }, + ) + + // Reset mock so next call succeeds + mockSaveField.mockResolvedValue(undefined) + + // Simulate browser coming back online + act(() => { + window.dispatchEvent(new Event('online')) + }) + + // Wait for retry to succeed + await waitFor( + () => { + expect(result.current.syncStatus).toBe('saved') + }, + { timeout: 5000 }, + ) + + // First call failed, second call succeeded with latest value + expect(mockSaveField).toHaveBeenCalledTimes(2) + expect(mockSaveField).toHaveBeenLastCalledWith('user-123', 'note', 'offline content') + }) + + it('does not auto-retry on online event when status is not error', async () => { + const { result } = renderHook(() => useAutoSave('note'), { + wrapper: createWrapper(queryClient), + }) + + await waitFor(() => { + expect(result.current.value).toBe('initial content') + }) + + // Status is 'idle' — firing online should NOT trigger a save + expect(result.current.syncStatus).toBe('idle') + + act(() => { + window.dispatchEvent(new Event('online')) + }) + + // Wait a bit to ensure no save is triggered + await new Promise((resolve) => setTimeout(resolve, 100)) + expect(mockSaveField).not.toHaveBeenCalled() + }) + + it('removes online listener when status leaves error', async () => { + mockSaveField.mockRejectedValueOnce(new Error('Network error')).mockResolvedValue(undefined) + const { result } = renderHook(() => useAutoSave('note'), { + wrapper: createWrapper(queryClient), + }) + + await waitFor(() => { + expect(result.current.value).toBe('initial content') + }) + + act(() => { + result.current.setValue('offline content') + }) + + // Wait for debounce + failed mutation + await waitFor( + () => { + expect(result.current.syncStatus).toBe('error') + }, + { timeout: 5000 }, + ) + + // Simulate browser coming back online → retry succeeds + act(() => { + window.dispatchEvent(new Event('online')) + }) + + // Wait for retry to succeed + await waitFor( + () => { + expect(result.current.syncStatus).toBe('saved') + }, + { timeout: 5000 }, + ) + + // Fire another online event — should NOT trigger another save + // (listener was removed when status changed from 'error') + act(() => { + window.dispatchEvent(new Event('online')) + }) + + await new Promise((resolve) => setTimeout(resolve, 100)) + // Only 2 calls: one failed, one succeeded via retry + expect(mockSaveField).toHaveBeenCalledTimes(2) + }) +}) + +describe('useAutoSave (debounce)', () => { + let queryClient: QueryClient + + beforeEach(() => { + vi.useFakeTimers() + vi.clearAllMocks() + queryClient = createQueryClient() + useCryptoStore.setState({ + loadedFieldKeys: { note: true, website: true, email: true }, + isVaultLocked: false, + lastActivity: Date.now(), + cachedEnvelope: null, + }) + useSyncStatusStore.getState().resetAll() + mockUseAuth.mockReturnValue({ user: { id: 'user-123', username: 'testuser' } }) + mockLoadField.mockResolvedValue('initial content') + mockSaveField.mockResolvedValue(undefined) + }) + + afterEach(() => { + vi.useRealTimers() + queryClient.clear() + }) + + it('debounces saves — rapid setValue calls trigger only one save', async () => { + const { result } = renderHook(() => useAutoSave('note'), { + wrapper: createWrapper(queryClient), + }) + + // Wait for initial query to resolve + await act(async () => { + vi.advanceTimersByTime(0) + }) + + // Rapid keystrokes + act(() => { + result.current.setValue('a') + }) + act(() => { + vi.advanceTimersByTime(300) + }) + act(() => { + result.current.setValue('ab') + }) + act(() => { + vi.advanceTimersByTime(300) + }) + act(() => { + result.current.setValue('abc') + }) + + // Not yet saved, status still idle + expect(mockSaveField).not.toHaveBeenCalled() + expect(result.current.syncStatus).toBe('idle') + + // After debounce period, save fires and status becomes 'saving' + await act(async () => { + vi.advanceTimersByTime(DEBOUNCE_MS) + }) + + expect(mockSaveField).toHaveBeenCalledTimes(1) + expect(mockSaveField).toHaveBeenCalledWith('user-123', 'note', 'abc') + }) + + it('sets sync status to saving then saved on success', async () => { + const { result } = renderHook(() => useAutoSave('note'), { + wrapper: createWrapper(queryClient), + }) + + // Wait for initial query to resolve + await act(async () => { + vi.advanceTimersByTime(0) + }) + + act(() => { + result.current.setValue('new content') + }) + // Status is still 'idle' immediately after setValue + expect(result.current.syncStatus).toBe('idle') + + // Partway through the debounce period, status is still 'idle' + act(() => { + vi.advanceTimersByTime(300) + }) + expect(result.current.syncStatus).toBe('idle') + + // Advance past debounce: save fires, status becomes 'saving' + await act(async () => { + vi.advanceTimersByTime(DEBOUNCE_MS) + }) + + // Wait for mutation to settle + await act(async () => { + vi.advanceTimersByTime(0) + }) + + expect(result.current.syncStatus).toBe('saved') + + // After SAVED_DISPLAY_MS, auto-transition to idle + act(() => { + vi.advanceTimersByTime(SAVED_DISPLAY_MS) + }) + expect(result.current.syncStatus).toBe('idle') + }) + + it('retry calls save immediately without debounce', async () => { + mockSaveField.mockRejectedValueOnce(new Error('Network error')) + const { result } = renderHook(() => useAutoSave('note'), { + wrapper: createWrapper(queryClient), + }) + + // Wait for initial query + await act(async () => { + vi.advanceTimersByTime(0) + }) + + act(() => { + result.current.setValue('new content') + }) + + // Advance past debounce to trigger the save (which will fail) + await act(async () => { + vi.advanceTimersByTime(DEBOUNCE_MS) + }) + + // Wait for mutation error to settle + await act(async () => { + vi.advanceTimersByTime(0) + }) + + expect(result.current.syncStatus).toBe('error') + + // Reset the mock so next call succeeds + mockSaveField.mockResolvedValue(undefined) + + // Retry should call save immediately (no debounce) + act(() => { + result.current.retry() + }) + + // The retry triggers a mutation directly, so we need to wait for it + await act(async () => { + vi.advanceTimersByTime(0) + }) + + // Second call (first was the failed one) + expect(mockSaveField).toHaveBeenCalledTimes(2) + expect(mockSaveField).toHaveBeenLastCalledWith('user-123', 'note', 'new content') + }) +}) diff --git a/src/features/fields/model/auto-save.ts b/src/features/fields/model/auto-save.ts new file mode 100644 index 0000000..9a6f143 --- /dev/null +++ b/src/features/fields/model/auto-save.ts @@ -0,0 +1,156 @@ +import { useState, useRef, useEffect, useCallback } from 'react' +import { useField } from '@/features/fields/model/use-field' +import { useSaveField } from '@/features/fields/model/use-save-field' +import { useSyncStatusStore } from '@/features/fields/model/sync-status' +import { useCryptoStore } from '@/shared/crypto/crypto-store' +import type { FieldName } from '@/shared/types/entities/field.types' +import type { SyncStatus } from '@/features/fields/model/sync-status' + +const DEBOUNCE_MS = 1000 +const SAVED_DISPLAY_MS = 3000 + +export interface UseAutoSaveResult { + value: string + setValue: (value: string) => void + syncStatus: SyncStatus + retry: () => void +} + +/** + * Auto-save hook for encrypted fields. + * + * Manages a local draft that takes priority over query data while editing, + * debounces saves (4s after last keystroke), and tracks sync status. + */ +function useAutoSave(fieldName: FieldName): UseAutoSaveResult { + const fieldQuery = useField(fieldName) + const saveMutation = useSaveField(fieldName) + const setStatus = useSyncStatusStore((s) => s.setStatus) + const getStatus = useSyncStatusStore((s) => s.status[fieldName]) + const isVaultLocked = useCryptoStore((s) => s.isVaultLocked) + + // Reset stale "saved" status on mount + useEffect(() => { + if (useSyncStatusStore.getState().status[fieldName] === 'saved') { + setStatus(fieldName, 'idle') + } + }, []) // eslint-disable-line react-hooks/exhaustive-deps -- intentional mount-only + + // Local draft: null means "not yet initialized from query data" + const [draft, setDraft] = useState(null) + const isEditingRef = useRef(false) + const latestValueRef = useRef('') + const debounceTimerRef = useRef | null>(null) + const savedTimerRef = useRef | null>(null) + // Ref to avoid stale closures in setTimeout callbacks + const mutateRef = useRef(saveMutation.mutate) + + // Keep mutateRef in sync with the latest mutation function + useEffect(() => { + mutateRef.current = saveMutation.mutate + }, [saveMutation.mutate]) + + // When vault locks, clear timers and reset editing state (no setState — just refs/timers) + useEffect(() => { + if (isVaultLocked) { + if (debounceTimerRef.current !== null) { + clearTimeout(debounceTimerRef.current) + debounceTimerRef.current = null + } + if (savedTimerRef.current !== null) { + clearTimeout(savedTimerRef.current) + savedTimerRef.current = null + } + isEditingRef.current = false + } + }, [isVaultLocked]) + + // Initialize draft from query data when not editing + useEffect(() => { + if (!isEditingRef.current && fieldQuery.data !== undefined) { + setDraft(fieldQuery.data ?? '') + } + }, [fieldQuery.data]) + + // Track mutation success/error for sync status + useEffect(() => { + if (saveMutation.isSuccess) { + setStatus(fieldName, 'saved') + if (savedTimerRef.current !== null) { + clearTimeout(savedTimerRef.current) + } + savedTimerRef.current = setTimeout(() => { + const current = useSyncStatusStore.getState().status[fieldName] + if (current === 'saved') { + setStatus(fieldName, 'idle') + } + savedTimerRef.current = null + }, SAVED_DISPLAY_MS) + } + }, [saveMutation.isSuccess, fieldName, setStatus]) + + useEffect(() => { + if (saveMutation.isError) { + setStatus(fieldName, 'error') + } + }, [saveMutation.isError, fieldName, setStatus]) + + // Cleanup timers on unmount + useEffect(() => { + return () => { + if (debounceTimerRef.current !== null) clearTimeout(debounceTimerRef.current) + if (savedTimerRef.current !== null) clearTimeout(savedTimerRef.current) + } + }, []) + + const triggerSave = useCallback( + (value: string) => { + mutateRef.current(value) + }, + [], // mutateRef is stable via ref pattern + ) + + const setValue = useCallback( + (value: string) => { + setDraft(value) + latestValueRef.current = value + isEditingRef.current = true + + if (debounceTimerRef.current !== null) { + clearTimeout(debounceTimerRef.current) + } + + debounceTimerRef.current = setTimeout(() => { + setStatus(fieldName, 'saving') + triggerSave(value) + debounceTimerRef.current = null + }, DEBOUNCE_MS) + }, + [fieldName, setStatus, triggerSave], + ) + + const retry = useCallback(() => { + triggerSave(latestValueRef.current) + setStatus(fieldName, 'saving') + }, [fieldName, setStatus, triggerSave]) + + // Sync status from store + const syncStatus: SyncStatus = getStatus + + // Auto-retry when the browser regains connectivity + useEffect(() => { + if (syncStatus !== 'error') return + const handleOnline = () => retry() + window.addEventListener('online', handleOnline) + return () => window.removeEventListener('online', handleOnline) + }, [syncStatus, retry]) + + // Value resolution: draft takes priority while editing, otherwise query data. + // When vault is locked, return empty string (query cache is purged by lockVault). + // Stale draft from before lock is acceptable — query data effect refreshes it on unlock. + const value = isVaultLocked ? '' : (draft ?? fieldQuery.data ?? '') + + return { value, setValue, syncStatus, retry } +} + +export { useAutoSave } diff --git a/src/features/fields/model/sync-status.test.ts b/src/features/fields/model/sync-status.test.ts new file mode 100644 index 0000000..338c2af --- /dev/null +++ b/src/features/fields/model/sync-status.test.ts @@ -0,0 +1,57 @@ +import { describe, it, expect, beforeEach } from 'vitest' +import { useSyncStatusStore } from '@/features/fields/model/sync-status' +import { FIELD_NAMES } from '@/shared/types/entities/field.types' + +describe('useSyncStatusStore', () => { + beforeEach(() => { + useSyncStatusStore.getState().resetAll() + }) + + it('starts with all fields idle', () => { + const { status } = useSyncStatusStore.getState() + FIELD_NAMES.forEach((name) => expect(status[name]).toBe('idle')) + }) + + it('setStatus updates a single field without affecting others', () => { + useSyncStatusStore.getState().setStatus('note', 'saving') + const { status } = useSyncStatusStore.getState() + expect(status.note).toBe('saving') + expect(status.website).toBe('idle') + expect(status.email).toBe('idle') + }) + + it('setStatus can transition through the full lifecycle', () => { + const store = useSyncStatusStore.getState() + store.setStatus('note', 'saving') + expect(useSyncStatusStore.getState().status.note).toBe('saving') + + store.setStatus('note', 'saved') + expect(useSyncStatusStore.getState().status.note).toBe('saved') + + store.setStatus('note', 'idle') + expect(useSyncStatusStore.getState().status.note).toBe('idle') + }) + + it('setStatus can set error state', () => { + useSyncStatusStore.getState().setStatus('website', 'error') + expect(useSyncStatusStore.getState().status.website).toBe('error') + }) + + it('resetField resets a specific field to idle', () => { + useSyncStatusStore.getState().setStatus('note', 'saving') + useSyncStatusStore.getState().setStatus('website', 'error') + useSyncStatusStore.getState().resetField('note') + const { status } = useSyncStatusStore.getState() + expect(status.note).toBe('idle') + expect(status.website).toBe('error') + }) + + it('resetAll resets all fields to idle', () => { + useSyncStatusStore.getState().setStatus('note', 'saving') + useSyncStatusStore.getState().setStatus('website', 'saved') + useSyncStatusStore.getState().setStatus('email', 'error') + useSyncStatusStore.getState().resetAll() + const { status } = useSyncStatusStore.getState() + FIELD_NAMES.forEach((name) => expect(status[name]).toBe('idle')) + }) +}) diff --git a/src/features/fields/model/sync-status.ts b/src/features/fields/model/sync-status.ts new file mode 100644 index 0000000..f1fc98b --- /dev/null +++ b/src/features/fields/model/sync-status.ts @@ -0,0 +1,45 @@ +import { create } from 'zustand' +import { devtools } from 'zustand/middleware' +import type { FieldName } from '@/shared/types/entities/field.types' + +export type SyncStatus = 'idle' | 'saving' | 'saved' | 'error' + +interface SyncStatusState { + status: Record +} + +interface SyncStatusActions { + setStatus: (fieldName: FieldName, status: SyncStatus) => void + resetField: (fieldName: FieldName) => void + resetAll: () => void +} + +const initialStatus: Record = { + note: 'idle', + website: 'idle', + email: 'idle', +} + +const useSyncStatusStore = create()( + devtools( + (set) => ({ + status: { ...initialStatus }, + setStatus: (fieldName, status) => + set( + (state) => ({ status: { ...state.status, [fieldName]: status } }), + false, + `syncStatus/setStatus/${fieldName}`, + ), + resetField: (fieldName) => + set( + (state) => ({ status: { ...state.status, [fieldName]: 'idle' } }), + false, + `syncStatus/resetField/${fieldName}`, + ), + resetAll: () => set({ status: { ...initialStatus } }, false, 'syncStatus/resetAll'), + }), + { name: 'SyncStatusStore' }, + ), +) + +export { useSyncStatusStore } diff --git a/src/features/fields/model/use-save-field.ts b/src/features/fields/model/use-save-field.ts index 2024baa..1808e91 100644 --- a/src/features/fields/model/use-save-field.ts +++ b/src/features/fields/model/use-save-field.ts @@ -15,6 +15,7 @@ export function useSaveField(fieldName: FieldName) { const queryKey = ['field', fieldName] as const return useMutation({ + networkMode: 'offlineFirst', // run mutation even when offline, so errors surface immediately mutationFn: (plaintext: string) => { if (!userId) throw new Error('useSaveField requires an authenticated user') return fieldService.saveField(userId, fieldName, plaintext) diff --git a/src/features/fields/ui/DashboardPage.test.tsx b/src/features/fields/ui/DashboardPage.test.tsx index 5d584ad..4c7f3a1 100644 --- a/src/features/fields/ui/DashboardPage.test.tsx +++ b/src/features/fields/ui/DashboardPage.test.tsx @@ -1,10 +1,31 @@ -import { describe, it, expect } from 'vitest' -import { render, screen } from '@/test/utils' +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { render, screen, act } from '@/test/utils' import { useCryptoStore } from '@/shared/crypto/crypto-store' +import { useSyncStatusStore } from '@/features/fields/model/sync-status' import { DashboardPage } from './DashboardPage' +// Mock useAutoSave to avoid needing full TanStack Query + auth setup +vi.mock('@/features/fields/model/auto-save', () => { + return { + useAutoSave: (fieldName: string) => ({ + value: `mock-${fieldName}-value`, + setValue: vi.fn(), + syncStatus: 'idle' as const, + retry: vi.fn(), + }), + } +}) + describe('DashboardPage', () => { + beforeEach(() => { + useCryptoStore.setState({ + isVaultLocked: false, + loadedFieldKeys: { note: true, website: true, email: true }, + }) + useSyncStatusStore.getState().resetAll() + }) + it('renders all three field cards', () => { render() expect(screen.getByText('Note')).toBeInTheDocument() @@ -13,14 +34,13 @@ describe('DashboardPage', () => { }) it('shows locked state for all fields when vault is locked', () => { - useCryptoStore.setState({ isVaultLocked: true }) + useCryptoStore.setState({ isVaultLocked: true, loadedFieldKeys: {} }) render() const lockedMessages = screen.getAllByText('Unlock vault to view') expect(lockedMessages).toHaveLength(3) }) it('shows field editors when vault is unlocked', () => { - useCryptoStore.setState({ isVaultLocked: false }) render() expect(screen.getByPlaceholderText('Write your note...')).toBeInTheDocument() expect(screen.getByPlaceholderText('Enter website URL')).toBeInTheDocument() @@ -31,4 +51,22 @@ describe('DashboardPage', () => { render() expect(screen.getByText('Dashboard')).toBeInTheDocument() }) + + it('resets sync status when vault locks', () => { + useSyncStatusStore.getState().setStatus('note', 'saving') + useSyncStatusStore.getState().setStatus('website', 'saved') + + render() + + // Lock vault — this triggers the useEffect + act(() => { + useCryptoStore.setState({ isVaultLocked: true, loadedFieldKeys: {} }) + }) + + // Sync status should be reset by the useEffect + const { status } = useSyncStatusStore.getState() + expect(status.note).toBe('idle') + expect(status.website).toBe('idle') + expect(status.email).toBe('idle') + }) }) diff --git a/src/features/fields/ui/DashboardPage.tsx b/src/features/fields/ui/DashboardPage.tsx index 116c230..1cc7a70 100644 --- a/src/features/fields/ui/DashboardPage.tsx +++ b/src/features/fields/ui/DashboardPage.tsx @@ -1,45 +1,64 @@ +import { useEffect, type ReactNode } from 'react' import { useTranslation } from 'react-i18next' import { useCryptoStore } from '@/shared/crypto/crypto-store' import { useVaultDialogStore } from '@/shared/crypto/vault-dialog-store' +import { useSyncStatusStore } from '@/features/fields/model/sync-status' +import { useAutoSave } from '@/features/fields/model/auto-save' import { FieldCard } from '@/features/fields/ui/FieldCard' import { NoteField } from '@/features/fields/ui/NoteField' import { WebsiteField } from '@/features/fields/ui/WebsiteField' import { EmailField } from '@/features/fields/ui/EmailField' +import { SaveIndicator } from '@/features/fields/ui/SaveIndicator' import { FIELD_NAMES } from '@/shared/types/entities/field.types' import type { FieldName } from '@/shared/types/entities/field.types' -import type { ReactNode } from 'react' -function getFieldEditor(fieldName: FieldName): ReactNode { +function getFieldEditor(fieldName: FieldName, value: string, onChange: (value: string) => void): ReactNode { switch (fieldName) { case 'note': - return + return case 'website': - return + return case 'email': - return + return } } +function FieldEditorWrapper({ fieldName }: { fieldName: FieldName }) { + const isVaultLocked = useCryptoStore((s) => s.isVaultLocked) + const openUnlockDialog = useVaultDialogStore((s) => s.openUnlockDialog) + const { value, setValue, syncStatus, retry } = useAutoSave(fieldName) + + return ( + } + > + {() => getFieldEditor(fieldName, value, setValue)} + + ) +} + function DashboardPage() { const { t } = useTranslation('common') const isVaultLocked = useCryptoStore((s) => s.isVaultLocked) - const openUnlockDialog = useVaultDialogStore((s) => s.openUnlockDialog) + const resetAllSyncStatus = useSyncStatusStore((s) => s.resetAll) + + // Reset sync status when vault locks + useEffect(() => { + if (isVaultLocked) { + resetAllSyncStatus() + } + }, [isVaultLocked, resetAllSyncStatus]) return (

{t('nav.dashboard')}

- {FIELD_NAMES.map((fieldName, index) => ( - - {() => getFieldEditor(fieldName)} - + {FIELD_NAMES.map((fieldName) => ( + ))}
diff --git a/src/features/fields/ui/EmailField.test.tsx b/src/features/fields/ui/EmailField.test.tsx index f4f2b39..4572115 100644 --- a/src/features/fields/ui/EmailField.test.tsx +++ b/src/features/fields/ui/EmailField.test.tsx @@ -1,19 +1,35 @@ -import { describe, it, expect } from 'vitest' +import { describe, it, expect, vi } from 'vitest' import { render, screen } from '@/test/utils' +import userEvent from '@testing-library/user-event' import { EmailField } from './EmailField' describe('EmailField', () => { it('renders an input with type email', () => { - render() + render() const input = screen.getByPlaceholderText('Enter email address') expect(input).toBeInTheDocument() expect(input).toHaveAttribute('type', 'email') }) it('has autocomplete email attribute', () => { - render() + render() const input = screen.getByPlaceholderText('Enter email address') expect(input).toHaveAttribute('autocomplete', 'email') }) + + it('reflects the value prop as a controlled input', () => { + render() + const input = screen.getByPlaceholderText('Enter email address') + expect(input).toHaveValue('test@example.com') + }) + + it('calls onChange when user types', async () => { + const user = userEvent.setup() + const onChange = vi.fn() + render() + const input = screen.getByPlaceholderText('Enter email address') + await user.type(input, 'hello') + expect(onChange).toHaveBeenCalled() + }) }) diff --git a/src/features/fields/ui/EmailField.tsx b/src/features/fields/ui/EmailField.tsx index 6bf9149..89a9aa6 100644 --- a/src/features/fields/ui/EmailField.tsx +++ b/src/features/fields/ui/EmailField.tsx @@ -2,7 +2,12 @@ import { useTranslation } from 'react-i18next' import { Input } from '@/shared/ui/input' -function EmailField() { +interface EmailFieldProps { + value: string + onChange: (value: string) => void +} + +function EmailField({ value, onChange }: EmailFieldProps) { const { t } = useTranslation('fields') return ( @@ -12,6 +17,8 @@ function EmailField() { aria-label={t('email.label')} spellCheck={false} placeholder={t('email.placeholder')} + value={value} + onChange={(e) => onChange(e.target.value)} /> ) } diff --git a/src/features/fields/ui/FieldCard.tsx b/src/features/fields/ui/FieldCard.tsx index 92b8af7..5e88a1b 100644 --- a/src/features/fields/ui/FieldCard.tsx +++ b/src/features/fields/ui/FieldCard.tsx @@ -19,10 +19,11 @@ interface FieldCardProps { isLocked: boolean children: () => ReactNode onUnlock?: () => void + statusIndicator?: ReactNode entranceIndex?: number } -function FieldCard({ fieldName, isLocked, children, onUnlock, entranceIndex }: FieldCardProps) { +function FieldCard({ fieldName, isLocked, children, onUnlock, statusIndicator, entranceIndex }: FieldCardProps) { const { t } = useTranslation('fields') const keys = FIELD_I18N_KEYS[fieldName] @@ -34,11 +35,7 @@ function FieldCard({ fieldName, isLocked, children, onUnlock, entranceIndex }: F > {t(keys.label)} - {isLocked && ( - - - - )} + {isLocked ? : statusIndicator} {isLocked ? ( diff --git a/src/features/fields/ui/NoteField.test.tsx b/src/features/fields/ui/NoteField.test.tsx index 604c254..e7936a0 100644 --- a/src/features/fields/ui/NoteField.test.tsx +++ b/src/features/fields/ui/NoteField.test.tsx @@ -1,19 +1,37 @@ -import { describe, it, expect } from 'vitest' +import { describe, it, expect, vi } from 'vitest' import { render, screen } from '@/test/utils' +import userEvent from '@testing-library/user-event' import { NoteField } from './NoteField' describe('NoteField', () => { it('renders a textarea with correct placeholder', () => { - render() + const onChange = vi.fn() + render() const textarea = screen.getByPlaceholderText('Write your note...') expect(textarea).toBeInTheDocument() expect(textarea.tagName).toBe('TEXTAREA') }) it('textarea has correct initial rows', () => { - render() + render() const textarea = screen.getByPlaceholderText('Write your note...') expect(textarea).toHaveAttribute('rows', '6') }) + + it('reflects the value prop as a controlled input', () => { + const onChange = vi.fn() + render() + const textarea = screen.getByPlaceholderText('Write your note...') + expect(textarea).toHaveValue('Hello world') + }) + + it('calls onChange when user types', async () => { + const user = userEvent.setup() + const onChange = vi.fn() + render() + const textarea = screen.getByPlaceholderText('Write your note...') + await user.type(textarea, 'Hello') + expect(onChange).toHaveBeenCalled() + }) }) diff --git a/src/features/fields/ui/NoteField.tsx b/src/features/fields/ui/NoteField.tsx index b72217b..1664f6a 100644 --- a/src/features/fields/ui/NoteField.tsx +++ b/src/features/fields/ui/NoteField.tsx @@ -1,23 +1,28 @@ import { useCallback, useEffect, useRef } from 'react' import { useTranslation } from 'react-i18next' +interface NoteFieldProps { + value: string + onChange: (value: string) => void +} + function autoResize(textarea: HTMLTextAreaElement) { textarea.style.height = 'auto' textarea.style.height = `${textarea.scrollHeight}px` } -function NoteField() { +function NoteField({ value, onChange }: NoteFieldProps) { const { t } = useTranslation('fields') const textareaRef = useRef(null) - const handleInput = useCallback(() => { + // Resize on value changes (e.g. initial load from server) + useEffect(() => { if (textareaRef.current) { autoResize(textareaRef.current) } - }, []) + }, [value]) - // Resize on mount so pre-filled content (edit mode) gets correct height - useEffect(() => { + const handleInput = useCallback(() => { if (textareaRef.current) { autoResize(textareaRef.current) } @@ -26,11 +31,13 @@ function NoteField() { return (