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..6e9e587 100644 --- a/docs/implementation-plan/06-phase-6-data.md +++ b/docs/implementation-plan/06-phase-6-data.md @@ -53,8 +53,8 @@ - `loadAllFields(): Promise>` — load all three fields in parallel - Gets user ID from auth store, gets CryptoKey from KeyVault (no separate hook needed) - Wire into TanStack Query hooks: - - `useField(fieldName)` — query + cache decrypted field content, disabled while vault locked or field key not loaded - - `useSaveField(fieldName)` — mutation for saving field content, invalidates field query on success + - `useFieldQuery(fieldName)` — query + cache decrypted field content, disabled while vault locked or field key not loaded + - `useFieldMutation(fieldName)` — mutation for saving field content, invalidates field query on success - `src/shared/types/entities/field.types.ts`: - `FieldName` type (`'note' | 'website' | 'email'`), `FIELD_NAMES` canonical list - `EncryptedField` and `DecryptedField` interfaces for server/client representations @@ -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/encryption/ui/VaultIndicator.tsx b/src/features/encryption/ui/VaultIndicator.tsx index 339e8e3..04ecff7 100644 --- a/src/features/encryption/ui/VaultIndicator.tsx +++ b/src/features/encryption/ui/VaultIndicator.tsx @@ -17,7 +17,7 @@ function VaultIndicator() { onClick={openUnlockDialog} className={cn( 'flex items-center gap-1.5 text-sm transition-colors duration-300', - 'text-muted-foreground hover:text-foreground', + 'text-muted-foreground hover:text-foreground cursor-pointer', )} aria-label={t('vault.unlock')} > diff --git a/src/features/fields/model/sync-status-store.test.ts b/src/features/fields/model/sync-status-store.test.ts new file mode 100644 index 0000000..a146e8f --- /dev/null +++ b/src/features/fields/model/sync-status-store.test.ts @@ -0,0 +1,57 @@ +import { describe, it, expect, beforeEach } from 'vitest' +import { useSyncStatusStore } from '@/features/fields/model/sync-status-store' +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-store.ts b/src/features/fields/model/sync-status-store.ts new file mode 100644 index 0000000..f1fc98b --- /dev/null +++ b/src/features/fields/model/sync-status-store.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-field-editor.test.ts b/src/features/fields/model/use-field-editor.test.ts new file mode 100644 index 0000000..67dbf7d --- /dev/null +++ b/src/features/fields/model/use-field-editor.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-store' + +// --- 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 { useFieldEditor } from '@/features/fields/model/use-field-editor' + +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('useFieldEditor', () => { + 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 fieldValue from query data', async () => { + const { result } = renderHook(() => useFieldEditor('note'), { + wrapper: createWrapper(queryClient), + }) + + await waitFor(() => { + expect(result.current.fieldValue).toBe('initial content') + }) + }) + + it('returns empty string when query returns null', async () => { + mockLoadField.mockResolvedValue(null) + const { result } = renderHook(() => useFieldEditor('note'), { + wrapper: createWrapper(queryClient), + }) + + await waitFor(() => { + expect(result.current.fieldValue).toBe('') + }) + }) + + it('updates fieldValue immediately on setFieldValue (optimistic)', async () => { + const { result } = renderHook(() => useFieldEditor('note'), { + wrapper: createWrapper(queryClient), + }) + + await waitFor(() => { + expect(result.current.fieldValue).toBe('initial content') + }) + + act(() => { + result.current.saveFieldValue('new content') + }) + + expect(result.current.fieldValue).toBe('new content') + }) + + it('keeps status idle immediately after setFieldValue', async () => { + const { result } = renderHook(() => useFieldEditor('note'), { + wrapper: createWrapper(queryClient), + }) + + await waitFor(() => { + expect(result.current.fieldValue).toBe('initial content') + }) + + act(() => { + result.current.saveFieldValue('new content') + }) + + // Status should still be 'idle' immediately after setFieldValue + // (it will transition to 'saving' only when the debounce fires) + expect(result.current.fieldSyncStatus).toBe('idle') + }) + + it('sets sync status to error when save fails', async () => { + mockSaveField.mockRejectedValueOnce(new Error('Network error')) + const { result } = renderHook(() => useFieldEditor('note'), { + wrapper: createWrapper(queryClient), + }) + + await waitFor(() => { + expect(result.current.fieldValue).toBe('initial content') + }) + + act(() => { + result.current.saveFieldValue('new content') + }) + + // Wait for the debounce + mutation to complete + await waitFor( + () => { + expect(result.current.fieldSyncStatus).toBe('error') + }, + { timeout: 5000 }, + ) + + // Draft is preserved on error + expect(result.current.fieldValue).toBe('new content') + }) + + it('resets draft when vault locks', async () => { + const { result } = renderHook(() => useFieldEditor('note'), { + wrapper: createWrapper(queryClient), + }) + + await waitFor(() => { + expect(result.current.fieldValue).toBe('initial content') + }) + + act(() => { + result.current.saveFieldValue('edited content') + }) + expect(result.current.fieldValue).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.fieldValue).toBe('') + }) + }) + + it('does not call saveField when userId is empty', async () => { + mockUseAuth.mockReturnValue({ user: null }) + const { result } = renderHook(() => useFieldEditor('note'), { + wrapper: createWrapper(queryClient), + }) + + // The fieldValue should be empty string since the query is disabled (no user) + expect(result.current.fieldValue).toBe('') + + act(() => { + result.current.saveFieldValue('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(() => useFieldEditor('note'), { + wrapper: createWrapper(queryClient), + }) + + await waitFor(() => { + expect(result.current.fieldValue).toBe('initial content') + }) + + act(() => { + result.current.saveFieldValue('offline content') + }) + + // Wait for the debounce + failed mutation + await waitFor( + () => { + expect(result.current.fieldSyncStatus).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.fieldSyncStatus).toBe('saved') + }, + { timeout: 5000 }, + ) + + // First call failed, second call succeeded with latest fieldValue + 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(() => useFieldEditor('note'), { + wrapper: createWrapper(queryClient), + }) + + await waitFor(() => { + expect(result.current.fieldValue).toBe('initial content') + }) + + // Status is 'idle' — firing online should NOT trigger a save + expect(result.current.fieldSyncStatus).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('does not retry on online event after error is resolved', async () => { + mockSaveField.mockRejectedValueOnce(new Error('Network error')).mockResolvedValue(undefined) + const { result } = renderHook(() => useFieldEditor('note'), { + wrapper: createWrapper(queryClient), + }) + + await waitFor(() => { + expect(result.current.fieldValue).toBe('initial content') + }) + + act(() => { + result.current.saveFieldValue('offline content') + }) + + // Wait for debounce + failed mutation + await waitFor( + () => { + expect(result.current.fieldSyncStatus).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.fieldSyncStatus).toBe('saved') + }, + { timeout: 5000 }, + ) + + // Fire another online event — should NOT trigger another save + // (handler checks status imperatively; status is now 'saved', not '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('useFieldEditor (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 setFieldValue calls trigger only one save', async () => { + const { result } = renderHook(() => useFieldEditor('note'), { + wrapper: createWrapper(queryClient), + }) + + // Wait for initial query to resolve + await act(async () => { + vi.advanceTimersByTime(0) + }) + + // Rapid keystrokes + act(() => { + result.current.saveFieldValue('a') + }) + act(() => { + vi.advanceTimersByTime(300) + }) + act(() => { + result.current.saveFieldValue('ab') + }) + act(() => { + vi.advanceTimersByTime(300) + }) + act(() => { + result.current.saveFieldValue('abc') + }) + + // Not yet saved, status still idle + expect(mockSaveField).not.toHaveBeenCalled() + expect(result.current.fieldSyncStatus).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(() => useFieldEditor('note'), { + wrapper: createWrapper(queryClient), + }) + + // Wait for initial query to resolve + await act(async () => { + vi.advanceTimersByTime(0) + }) + + act(() => { + result.current.saveFieldValue('new content') + }) + // Status is still 'idle' immediately after setFieldValue + expect(result.current.fieldSyncStatus).toBe('idle') + + // Partway through the debounce period, status is still 'idle' + act(() => { + vi.advanceTimersByTime(300) + }) + expect(result.current.fieldSyncStatus).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.fieldSyncStatus).toBe('saved') + + // After SAVED_DISPLAY_MS, auto-transition to idle + act(() => { + vi.advanceTimersByTime(SAVED_DISPLAY_MS) + }) + expect(result.current.fieldSyncStatus).toBe('idle') + }) + + it('retry calls save immediately without debounce', async () => { + mockSaveField.mockRejectedValueOnce(new Error('Network error')) + const { result } = renderHook(() => useFieldEditor('note'), { + wrapper: createWrapper(queryClient), + }) + + // Wait for initial query + await act(async () => { + vi.advanceTimersByTime(0) + }) + + act(() => { + result.current.saveFieldValue('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.fieldSyncStatus).toBe('error') + + // Reset the mock so next call succeeds + mockSaveField.mockResolvedValue(undefined) + + // Retry should call save immediately (no debounce) + act(() => { + result.current.retrySave() + }) + + // 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/use-field-editor.ts b/src/features/fields/model/use-field-editor.ts new file mode 100644 index 0000000..8ccbcee --- /dev/null +++ b/src/features/fields/model/use-field-editor.ts @@ -0,0 +1,87 @@ +import { useState, useEffect, useCallback } from 'react' +import { useFieldQuery } from '@/features/fields/model/use-field-query' +import { useFieldMutation } from '@/features/fields/model/use-field-mutation' +import { useSyncStatusStore } from '@/features/fields/model/sync-status-store' +import { useCryptoStore } from '@/shared/crypto/crypto-store' +import { useSaveScheduler } from '@/features/fields/model/use-save-scheduler' +import type { FieldName } from '@/shared/types/entities/field.types' +import type { SyncStatus } from '@/features/fields/model/sync-status-store' + +export interface UseFieldEditorResult { + fieldValue: string + saveFieldValue: (value: string) => void + fieldSyncStatus: SyncStatus + retrySave: () => void +} + +/** + * Field editor hook for encrypted fields. + * + * Manages a local draft that takes priority over query data while editing, + * debounces saves (1s after last keystroke), and tracks sync status. + */ +function useFieldEditor(fieldName: FieldName): UseFieldEditorResult { + const fieldQuery = useFieldQuery(fieldName) + const saveMutation = useFieldMutation(fieldName) + const setSyncStatus = useSyncStatusStore((s) => s.setStatus) + const syncStatus = useSyncStatusStore((s) => s.status[fieldName]) + const isVaultLocked = useCryptoStore((s) => s.isVaultLocked) + + // Local draft for optimistic editing. + // - null: not editing → display server data (fieldQuery.data) + // - non-null: user is typing → display draft (overrides server data) + // Cleared on successful save or vault lock. + const [draft, setDraft] = useState(null) + + // Clear draft when vault locks or save succeeds (derive resets during render) + const [prevIsVaultLocked, setPrevIsVaultLocked] = useState(isVaultLocked) + const [prevSyncStatus, setPrevSyncStatus] = useState(syncStatus) + if (isVaultLocked !== prevIsVaultLocked) { + setPrevIsVaultLocked(isVaultLocked) + if (isVaultLocked) { + setDraft(null) + } + } + if (syncStatus !== prevSyncStatus) { + setPrevSyncStatus(syncStatus) + if (syncStatus === 'saved') { + setDraft(null) + } + } + + // Reset stale "saved" status on mount + useEffect(() => { + if (useSyncStatusStore.getState().status[fieldName] === 'saved') { + setSyncStatus(fieldName, 'idle') + } + }, [fieldName, setSyncStatus]) + + const { debounceSave, retrySave } = useSaveScheduler(fieldName, setSyncStatus, saveMutation.mutate, isVaultLocked) + + // Auto-retry when the browser regains connectivity - listener is always + // registered; the handler checks status imperatively to avoid add/remove churn. + useEffect(() => { + const handleOnline = () => { + if (useSyncStatusStore.getState().status[fieldName] === 'error') { + retrySave() + } + } + window.addEventListener('online', handleOnline) + return () => window.removeEventListener('online', handleOnline) + }, [fieldName, retrySave]) + + const saveFieldValue = useCallback( + (value: string) => { + setDraft(value) + debounceSave(value) + }, + [debounceSave], + ) + + // FieldValue resolution: draft takes priority while editing, otherwise query data. + const fieldValue = isVaultLocked ? '' : (draft ?? fieldQuery.data ?? '') + + return { fieldValue, saveFieldValue, fieldSyncStatus: syncStatus, retrySave } +} + +export { useFieldEditor } diff --git a/src/features/fields/model/use-save-field.test.ts b/src/features/fields/model/use-field-mutation.test.ts similarity index 84% rename from src/features/fields/model/use-save-field.test.ts rename to src/features/fields/model/use-field-mutation.test.ts index 0ac7696..18d235f 100644 --- a/src/features/fields/model/use-save-field.test.ts +++ b/src/features/fields/model/use-field-mutation.test.ts @@ -23,7 +23,7 @@ vi.mock('@/shared/auth/auth-context', () => ({ // --- Import after mocks --- -import { useSaveField } from '@/features/fields/model/use-save-field' +import { useFieldMutation } from '@/features/fields/model/use-field-mutation' function createQueryClient() { return new QueryClient({ @@ -40,7 +40,7 @@ function wrapper({ children }: { children: ReactNode }) { return createElement(QueryClientProvider, { client: queryClient }, children) } -describe('useSaveField', () => { +describe('useFieldMutation', () => { beforeEach(() => { vi.clearAllMocks() mockSaveField.mockResolvedValue(undefined) @@ -48,7 +48,7 @@ describe('useSaveField', () => { }) it('calls fieldService.saveField with userId, field name and plaintext on mutate', async () => { - const { result } = renderHook(() => useSaveField('note'), { wrapper }) + const { result } = renderHook(() => useFieldMutation('note'), { wrapper }) result.current.mutate('My note content') @@ -66,7 +66,7 @@ describe('useSaveField', () => { const localWrapper = ({ children }: { children: ReactNode }) => createElement(QueryClientProvider, { client: queryClient }, children) - const { result } = renderHook(() => useSaveField('note'), { wrapper: localWrapper }) + const { result } = renderHook(() => useFieldMutation('note'), { wrapper: localWrapper }) result.current.mutate('new content') @@ -85,7 +85,7 @@ describe('useSaveField', () => { const localWrapper = ({ children }: { children: ReactNode }) => createElement(QueryClientProvider, { client: queryClient }, children) - const { result } = renderHook(() => useSaveField('note'), { wrapper: localWrapper }) + const { result } = renderHook(() => useFieldMutation('note'), { wrapper: localWrapper }) result.current.mutate('new content') @@ -103,7 +103,7 @@ describe('useSaveField', () => { const localWrapper = ({ children }: { children: ReactNode }) => createElement(QueryClientProvider, { client: queryClient }, children) - const { result } = renderHook(() => useSaveField('note'), { wrapper: localWrapper }) + const { result } = renderHook(() => useFieldMutation('note'), { wrapper: localWrapper }) result.current.mutate('My note content') @@ -116,7 +116,7 @@ describe('useSaveField', () => { it('sets error state when saveField throws', async () => { mockSaveField.mockRejectedValue(new Error('Save failed')) - const { result } = renderHook(() => useSaveField('note'), { wrapper }) + const { result } = renderHook(() => useFieldMutation('note'), { wrapper }) result.current.mutate('test') @@ -130,13 +130,13 @@ describe('useSaveField', () => { it('throws when userId is empty (no authenticated user)', async () => { mockUseAuth.mockReturnValue({ user: null }) - const { result } = renderHook(() => useSaveField('note'), { wrapper }) + const { result } = renderHook(() => useFieldMutation('note'), { wrapper }) result.current.mutate('test') await waitFor(() => { expect(result.current.isError).toBe(true) }) - expect(result.current.error?.message).toBe('useSaveField requires an authenticated user') + expect(result.current.error?.message).toBe('useFieldMutation requires an authenticated user') }) }) diff --git a/src/features/fields/model/use-save-field.ts b/src/features/fields/model/use-field-mutation.ts similarity index 83% rename from src/features/fields/model/use-save-field.ts rename to src/features/fields/model/use-field-mutation.ts index 2024baa..0b68d99 100644 --- a/src/features/fields/model/use-save-field.ts +++ b/src/features/fields/model/use-field-mutation.ts @@ -9,14 +9,15 @@ import type { FieldName } from '@/shared/types/entities/field.types' * Optimistically updates the cache so the user sees their change immediately, * then confirms with the server. Rolls back on error. */ -export function useSaveField(fieldName: FieldName) { +export function useFieldMutation(fieldName: FieldName) { const queryClient = useQueryClient() const userId = useAuth().user?.id ?? '' 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') + if (!userId) throw new Error('useFieldMutation requires an authenticated user') return fieldService.saveField(userId, fieldName, plaintext) }, onMutate: async (plaintext) => { diff --git a/src/features/fields/model/use-field.test.ts b/src/features/fields/model/use-field-query.test.ts similarity index 84% rename from src/features/fields/model/use-field.test.ts rename to src/features/fields/model/use-field-query.test.ts index f3c39d5..352c1da 100644 --- a/src/features/fields/model/use-field.test.ts +++ b/src/features/fields/model/use-field-query.test.ts @@ -21,7 +21,7 @@ vi.mock('@/shared/auth/auth-context', () => ({ // --- Import after mocks --- -import { useField } from '@/features/fields/model/use-field' +import { useFieldQuery } from '@/features/fields/model/use-field-query' const testQueryClient = new QueryClient({ defaultOptions: { @@ -40,7 +40,7 @@ afterEach(() => { testQueryClient.clear() }) -describe('useField', () => { +describe('useFieldQuery', () => { beforeEach(() => { vi.clearAllMocks() // Default: authenticated user, vault unlocked with keys loaded @@ -57,7 +57,7 @@ describe('useField', () => { it('fetches and returns decrypted field content when vault is unlocked', async () => { mockLoadField.mockResolvedValue('My note content') - const { result } = renderHook(() => useField('note'), { wrapper }) + const { result } = renderHook(() => useFieldQuery('note'), { wrapper }) await waitFor(() => { expect(result.current.isSuccess).toBe(true) @@ -69,7 +69,7 @@ describe('useField', () => { it('returns null when field has never been saved', async () => { mockLoadField.mockResolvedValue(null) - const { result } = renderHook(() => useField('note'), { wrapper }) + const { result } = renderHook(() => useFieldQuery('note'), { wrapper }) await waitFor(() => { expect(result.current.isSuccess).toBe(true) @@ -80,7 +80,7 @@ describe('useField', () => { it('is disabled when userId is empty (no authenticated user)', () => { mockUseAuth.mockReturnValue({ user: null }) - const { result } = renderHook(() => useField('note'), { wrapper }) + const { result } = renderHook(() => useFieldQuery('note'), { wrapper }) expect(result.current.fetchStatus).toBe('idle') expect(mockLoadField).not.toHaveBeenCalled() }) @@ -88,7 +88,7 @@ describe('useField', () => { it('is disabled when vault is locked', () => { useCryptoStore.setState({ isVaultLocked: true, loadedFieldKeys: {} }) - const { result } = renderHook(() => useField('note'), { wrapper }) + const { result } = renderHook(() => useFieldQuery('note'), { wrapper }) expect(result.current.fetchStatus).toBe('idle') expect(mockLoadField).not.toHaveBeenCalled() }) @@ -96,7 +96,7 @@ describe('useField', () => { it('is disabled when field key is not loaded', () => { useCryptoStore.setState({ loadedFieldKeys: {} }) - const { result } = renderHook(() => useField('note'), { wrapper }) + const { result } = renderHook(() => useFieldQuery('note'), { wrapper }) expect(result.current.fetchStatus).toBe('idle') expect(mockLoadField).not.toHaveBeenCalled() }) @@ -104,7 +104,7 @@ describe('useField', () => { it('is enabled when vault is unlocked and field key is loaded', async () => { mockLoadField.mockResolvedValue('hello') - const { result } = renderHook(() => useField('note'), { wrapper }) + const { result } = renderHook(() => useFieldQuery('note'), { wrapper }) await waitFor(() => { expect(result.current.isSuccess).toBe(true) @@ -116,7 +116,7 @@ describe('useField', () => { const { DecryptionError } = await import('@/shared/crypto/errors') mockLoadField.mockRejectedValue(new DecryptionError('Decryption failed')) - const { result } = renderHook(() => useField('note'), { wrapper }) + const { result } = renderHook(() => useFieldQuery('note'), { wrapper }) await waitFor(() => { expect(result.current.isError).toBe(true) @@ -128,7 +128,7 @@ describe('useField', () => { it('retries on non-crypto errors before failing', async () => { mockLoadField.mockRejectedValue(new Error('Network error')) - const { result } = renderHook(() => useField('note'), { wrapper }) + const { result } = renderHook(() => useFieldQuery('note'), { wrapper }) // Allow time for retries with backoff (1s + 2s = ~3s total) await waitFor( diff --git a/src/features/fields/model/use-field.ts b/src/features/fields/model/use-field-query.ts similarity index 94% rename from src/features/fields/model/use-field.ts rename to src/features/fields/model/use-field-query.ts index 4911b74..e78e99f 100644 --- a/src/features/fields/model/use-field.ts +++ b/src/features/fields/model/use-field-query.ts @@ -11,7 +11,7 @@ import type { FieldName } from '@/shared/types/entities/field.types' * The query is disabled while vault is locked or field key missing. On lock, * crypto-store purges field queries so unlock triggers a fresh fetch. */ -export function useField(fieldName: FieldName) { +export function useFieldQuery(fieldName: FieldName) { const userId = useAuth().user?.id ?? '' const enabled = useCryptoStore((s) => !s.isVaultLocked && s.loadedFieldKeys[fieldName] === true) && !!userId diff --git a/src/features/fields/model/use-save-scheduler.test.ts b/src/features/fields/model/use-save-scheduler.test.ts new file mode 100644 index 0000000..e6d1cf1 --- /dev/null +++ b/src/features/fields/model/use-save-scheduler.test.ts @@ -0,0 +1,217 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { renderHook, act } from '@testing-library/react' +import { useSyncStatusStore } from '@/features/fields/model/sync-status-store' +import { useSaveScheduler } from '@/features/fields/model/use-save-scheduler' + +const DEBOUNCE_MS = 1000 +const SAVED_DISPLAY_MS = 3000 + +/** Create a mock saveMutate function that calls onSuccess by default. */ +function createMockSaveMutate() { + const mock = vi.fn<(value: string, options?: { onSuccess?: () => void; onError?: () => void }) => void>() + mock.mockImplementation((_value, options) => { + options?.onSuccess?.() + }) + return mock +} + +describe('useSaveScheduler', () => { + let mockSaveMutate: ReturnType + + beforeEach(() => { + vi.useFakeTimers() + vi.clearAllMocks() + useSyncStatusStore.getState().resetAll() + mockSaveMutate = createMockSaveMutate() + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it('debounces saves — rapid scheduleSave calls trigger only one save', () => { + const { result } = renderHook(() => + useSaveScheduler('note', useSyncStatusStore.getState().setStatus, mockSaveMutate, false), + ) + + // Rapid keystrokes + act(() => { + result.current.debounceSave('a') + }) + act(() => { + vi.advanceTimersByTime(300) + }) + act(() => { + result.current.debounceSave('ab') + }) + act(() => { + vi.advanceTimersByTime(300) + }) + act(() => { + result.current.debounceSave('abc') + }) + + // Not yet saved + expect(mockSaveMutate).not.toHaveBeenCalled() + + // After debounce period, save fires with the latest value + act(() => { + vi.advanceTimersByTime(DEBOUNCE_MS) + }) + + expect(mockSaveMutate).toHaveBeenCalledTimes(1) + expect(mockSaveMutate).toHaveBeenCalledWith( + 'abc', + expect.objectContaining({ onSuccess: expect.any(Function), onError: expect.any(Function) }), + ) + }) + + it('sets sync status to saving then saved on success', () => { + const setStatus = useSyncStatusStore.getState().setStatus + const { result } = renderHook(() => useSaveScheduler('note', setStatus, mockSaveMutate, false)) + + const status = () => useSyncStatusStore.getState().status['note'] + + expect(status()).toBe('idle') + + // Schedule a save + act(() => { + result.current.debounceSave('new content') + }) + // Status is still 'idle' immediately after scheduleSave + expect(status()).toBe('idle') + + // After debounce fires, mockSaveMutate.onSuccess runs → status becomes 'saved' + act(() => { + vi.advanceTimersByTime(DEBOUNCE_MS) + }) + expect(status()).toBe('saved') + + // After SAVED_DISPLAY_MS, auto-transition to idle + act(() => { + vi.advanceTimersByTime(SAVED_DISPLAY_MS) + }) + expect(status()).toBe('idle') + }) + + it('sets sync status to error when save fails', () => { + const errorMock = vi.fn<(value: string, options?: { onSuccess?: () => void; onError?: () => void }) => void>() + errorMock.mockImplementation((_value, options) => { + options?.onError?.() + }) + + const setStatus = useSyncStatusStore.getState().setStatus + const { result } = renderHook(() => useSaveScheduler('note', setStatus, errorMock, false)) + + // Schedule a save that will fail + act(() => { + result.current.debounceSave('bad content') + }) + act(() => { + vi.advanceTimersByTime(DEBOUNCE_MS) + }) + + expect(useSyncStatusStore.getState().status['note']).toBe('error') + }) + + it('retry calls save immediately without debounce', () => { + // First call fails + const errorMock = createMockSaveMutate() + errorMock.mockImplementationOnce((_value, options) => { + options?.onError?.() + }) + + const setStatus = useSyncStatusStore.getState().setStatus + const { result } = renderHook(() => useSaveScheduler('note', setStatus, errorMock, false)) + + // Schedule initial save + act(() => { + result.current.debounceSave('new content') + }) + act(() => { + vi.advanceTimersByTime(DEBOUNCE_MS) + }) + + expect(errorMock).toHaveBeenCalledTimes(1) + + // Reset mock so next call succeeds + errorMock.mockImplementation((_value, options) => { + options?.onSuccess?.() + }) + + // Retry should call save immediately (no debounce) + act(() => { + result.current.retrySave() + }) + + expect(errorMock).toHaveBeenCalledTimes(2) + expect(errorMock).toHaveBeenLastCalledWith( + 'new content', + expect.objectContaining({ onSuccess: expect.any(Function) }), + ) + }) + + it('clears timers when vault locks', () => { + const setStatus = useSyncStatusStore.getState().setStatus + const { rerender } = renderHook( + ({ isVaultLocked }) => useSaveScheduler('note', setStatus, mockSaveMutate, isVaultLocked), + { initialProps: { isVaultLocked: false } }, + ) + + // Vault locks — timers should be cleared + rerender({ isVaultLocked: true }) + + // Advance timers to verify no pending saves fire + act(() => { + vi.advanceTimersByTime(DEBOUNCE_MS * 2) + }) + + expect(mockSaveMutate).not.toHaveBeenCalled() + }) + + it('clears timers on unmount', () => { + const setStatus = useSyncStatusStore.getState().setStatus + const { result, unmount } = renderHook(() => useSaveScheduler('note', setStatus, mockSaveMutate, false)) + + // Schedule a debounced save + act(() => { + result.current.debounceSave('content') + }) + + // Unmount before debounce fires + unmount() + + // Advance timers — save should not fire after unmount + act(() => { + vi.advanceTimersByTime(DEBOUNCE_MS * 2) + }) + + expect(mockSaveMutate).not.toHaveBeenCalled() + }) + + it('uses latest saveMutate after rerender', () => { + const setStatus = useSyncStatusStore.getState().setStatus + const firstMutate = createMockSaveMutate() + const secondMutate = createMockSaveMutate() + + const { rerender, result } = renderHook( + ({ saveMutate }) => useSaveScheduler('note', setStatus, saveMutate, false), + { initialProps: { saveMutate: firstMutate } }, + ) + + // Rerender with a new mutate function + rerender({ saveMutate: secondMutate }) + + // Schedule a save and advance past debounce + act(() => { + result.current.debounceSave('content') + }) + act(() => { + vi.advanceTimersByTime(DEBOUNCE_MS) + }) + + // The second (latest) mutate should be used + expect(firstMutate).not.toHaveBeenCalled() + expect(secondMutate).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/features/fields/model/use-save-scheduler.ts b/src/features/fields/model/use-save-scheduler.ts new file mode 100644 index 0000000..8dce686 --- /dev/null +++ b/src/features/fields/model/use-save-scheduler.ts @@ -0,0 +1,116 @@ +import { useRef, useEffect, useCallback } from 'react' +import { useSyncStatusStore } from '@/features/fields/model/sync-status-store' +import type { FieldName } from '@/shared/types/entities/field.types' +import type { SyncStatus } from '@/features/fields/model/sync-status-store' + +const DEBOUNCE_MS = 1000 +const SAVED_DISPLAY_MS = 3000 + +type TimerRef = React.RefObject | null> +type SetSyncStatus = (name: FieldName, status: SyncStatus) => void + +/** + * Schedule a 'saved' status to expire back to 'idle' after a delay. + * Only transitions if the status is still 'saved' when the timer fires. + */ +function debounceResetStatus(fieldName: FieldName, setSyncStatus: SetSyncStatus, timerRef: TimerRef) { + if (timerRef.current !== null) { + clearTimeout(timerRef.current) + } + timerRef.current = setTimeout(() => { + const current = useSyncStatusStore.getState().status[fieldName] + if (current === 'saved') { + setSyncStatus(fieldName, 'idle') + } + timerRef.current = null + }, SAVED_DISPLAY_MS) +} + +/** Clear a timer ref and null it out. */ +function clearTimerRef(timerRef: TimerRef) { + if (timerRef.current !== null) { + clearTimeout(timerRef.current) + timerRef.current = null + } +} + +export interface UseSaveSchedulerResult { + /** Enqueue a value for debounced save. Overwrites any pending save. */ + debounceSave: (value: string) => void + /** Immediately retry saving the last enqueued value. */ + retrySave: () => void +} + +/** + * Manages save scheduling, timer cleanup, and retry logic. + */ +function useSaveScheduler( + fieldName: FieldName, + setSyncStatus: SetSyncStatus, + saveMutate: (value: string, options?: { onSuccess?: () => void; onError?: () => void }) => void, + isVaultLocked: boolean, +): UseSaveSchedulerResult { + const debounceTimerRef = useRef | null>(null) + const savedTimerRef = useRef | null>(null) + const latestValueRef = useRef('') + // Ref to avoid stale closures in setTimeout callbacks + const saveMutationRef = useRef(saveMutate) + + // Keep mutateRef in sync with the latest mutation function + useEffect(() => { + saveMutationRef.current = saveMutate + }, [saveMutate]) + + // Clear timers when vault locks + useEffect(() => { + if (isVaultLocked) { + clearTimerRef(debounceTimerRef) + clearTimerRef(savedTimerRef) + } + }, [isVaultLocked]) + + // Cleanup timers on unmount + useEffect(() => { + return () => { + clearTimerRef(debounceTimerRef) + clearTimerRef(savedTimerRef) + } + }, []) + + const triggerSave = useCallback( + (value: string) => { + setSyncStatus(fieldName, 'saving') + saveMutationRef.current(value, { + onSuccess: () => { + setSyncStatus(fieldName, 'saved') + debounceResetStatus(fieldName, setSyncStatus, savedTimerRef) + }, + onError: () => { + setSyncStatus(fieldName, 'error') + }, + }) + }, + [fieldName, setSyncStatus], + ) + + const debounceSave = useCallback( + (value: string) => { + latestValueRef.current = value + clearTimerRef(debounceTimerRef) + + debounceTimerRef.current = setTimeout(() => { + triggerSave(value) + debounceTimerRef.current = null + }, DEBOUNCE_MS) + }, + [triggerSave], + ) + + const retrySave = useCallback(() => { + triggerSave(latestValueRef.current) + }, [triggerSave]) + + return { debounceSave, retrySave } +} + +export { useSaveScheduler } diff --git a/src/features/fields/ui/DashboardPage.test.tsx b/src/features/fields/ui/DashboardPage.test.tsx index 5d584ad..54b7de2 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-store' import { DashboardPage } from './DashboardPage' +// Mock useFieldEditor to avoid needing full TanStack Query + auth setup +vi.mock('@/features/fields/model/use-field-editor', () => { + return { + useFieldEditor: (fieldName: string) => ({ + fieldValue: `mock-${fieldName}-value`, + saveFieldValue: vi.fn(), + fieldSyncStatus: 'idle' as const, + retrySave: 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..b9c6d1d 100644 --- a/src/features/fields/ui/DashboardPage.tsx +++ b/src/features/fields/ui/DashboardPage.tsx @@ -1,45 +1,65 @@ +import { useEffect } 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-store' +import { useFieldEditor } from '@/features/fields/model/use-field-editor' 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 { - switch (fieldName) { - case 'note': - return - case 'website': - return - case 'email': - return - } +function FieldEditorWrapper({ fieldName, entranceIndex }: { fieldName: FieldName; entranceIndex: number }) { + const isVaultLocked = useCryptoStore((s) => s.isVaultLocked) + const openUnlockDialog = useVaultDialogStore((s) => s.openUnlockDialog) + const { fieldValue, saveFieldValue, fieldSyncStatus, retrySave } = useFieldEditor(fieldName) + + return ( + + } + > + {() => { + switch (fieldName) { + case 'note': + return + case 'website': + return + case 'email': + return + } + }} + + ) } 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)} - + ))}
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..c7d619c 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,16 +35,12 @@ function FieldCard({ fieldName, isLocked, children, onUnlock, entranceIndex }: F > {t(keys.label)} - {isLocked && ( - - - - )} + {isLocked ? : statusIndicator} {isLocked ? (
-
+

{t(keys.locked)}

{onUnlock && ( 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..9c42921 100644 --- a/src/features/fields/ui/NoteField.tsx +++ b/src/features/fields/ui/NoteField.tsx @@ -1,36 +1,37 @@ -import { useCallback, useEffect, useRef } from 'react' +import { useLayoutEffect, 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(() => { - if (textareaRef.current) { - autoResize(textareaRef.current) - } - }, []) - - // Resize on mount so pre-filled content (edit mode) gets correct height - useEffect(() => { + // Resize on value changes (initial load, user input, programmatic changes) + // useLayoutEffect runs before paint, avoiding visual flash + useLayoutEffect(() => { if (textareaRef.current) { autoResize(textareaRef.current) } - }, []) + }, [value]) return (