diff --git a/.gitignore b/.gitignore index a933881..728ee61 100644 --- a/.gitignore +++ b/.gitignore @@ -81,3 +81,4 @@ GoogleService-Info.plist # Documentation files (except README.md) *.md !README.md +!docs/**/*.md diff --git a/docs/form-cache.md b/docs/form-cache.md new file mode 100644 index 0000000..8511e0e --- /dev/null +++ b/docs/form-cache.md @@ -0,0 +1,58 @@ +# Form Value Caching + +Client-side caching for reusable form fields (name, email, address, preferences) so users spend less time re-entering the same information across screens. + +## Features + +- **Persistent cache** — Values stored in AsyncStorage under `@teachlink/form-cache/v1` +- **Shared field keys** — One schema reused by profile edit, registration, and `MobileFormInput` +- **Suggestions** — Non-intrusive chip below focused inputs when a different cached value exists +- **Autofill on empty fields** — `useFormCache` fills only fields that are currently empty +- **Privacy control** — **Settings → Privacy → Clear Cached Form Data** removes all cached values +- **TTL** — Entries expire after **90 days**; expired keys are pruned on read/write + +## Cached field keys + +| Key | Typical use | +|-----|-------------| +| `fullName` | Profile, registration | +| `email` | Profile, registration | +| `bio` | Profile | +| `location` | Profile, address forms | +| `website` | Profile | +| `phone` | Contact forms | +| `addressLine1` / `addressLine2` | Shipping, billing | +| `city` / `state` / `postalCode` / `country` | Address blocks | +| `company` | Organization fields | + +## Lifecycle + +1. **Write** — On input blur (via `cacheKey` on `MobileFormInput`) or batch `cacheFormValues` after successful form save +2. **Read** — On form open (`applyPrefillToFields`) or input focus (suggestion chip) +3. **Expire** — `updatedAt` + `FORM_CACHE_TTL_MS`; pruned automatically +4. **Clear** — User action in settings or `clearFormCache()` in code/tests + +## Implementation map + +| Module | Role | +|--------|------| +| `src/services/formCache.ts` | Storage, TTL, suggestions | +| `src/hooks/useFormCache.ts` | React hook for multi-field forms | +| `src/components/mobile/MobileFormInput.tsx` | Per-field cache + suggestion UI | +| `src/components/mobile/MobileProfile.tsx` | Profile edit prefill + persist | +| `src/pages/mobile/MobileRegister.tsx` | Registration prefill + persist | +| `src/components/mobile/MobileSettings.tsx` | Clear cache control | + +## Testing + +```bash +npm run test -- tests/services/formCache.test.ts +npm run test -- tests/hooks/useFormCache.test.ts +``` + +Manual: edit profile → save → open registration or profile again → confirm suggestions/prefill; clear cache in settings → confirm fields no longer suggest. + +## Related issues + +- #401 — Form prefilling with cached values +- #5, #91, #132 — Broader UX / forms / persistence workstreams diff --git a/src/components/mobile/MobileFormInput.tsx b/src/components/mobile/MobileFormInput.tsx index f47a531..e6ad130 100644 --- a/src/components/mobile/MobileFormInput.tsx +++ b/src/components/mobile/MobileFormInput.tsx @@ -1,8 +1,19 @@ -import React, { useState } from 'react'; -import { View, TextInput, TextInputProps, TouchableOpacity, StyleSheet } from 'react-native'; +import React, { useEffect, useState } from 'react'; import { Eye, EyeOff, AlertCircle } from 'lucide-react-native'; -import { AppText as Text } from '../common/AppText'; +import { + View, + TextInput, + TextInputProps, + TouchableOpacity, + StyleSheet, +} from 'react-native'; import { useDynamicFontSize } from '../../hooks'; +import { + formCacheService, + setCachedFieldValue, + type FormCacheFieldKey, +} from '../../services/formCache'; +import { AppText as Text } from '../common/AppText'; /** * Props for the MobileFormInput component @@ -24,6 +35,10 @@ interface MobileFormInputProps extends TextInputProps { required?: boolean; /** Whether to use dark mode styling */ isDark?: boolean; + /** Shared cache key for autofill and suggestions */ + cacheKey?: FormCacheFieldKey; + /** Persist value to cache on blur (default: true when cacheKey is set) */ + cacheOnBlur?: boolean; } export const MobileFormInput: React.FC = ({ @@ -36,16 +51,53 @@ export const MobileFormInput: React.FC = ({ leftIcon, required = false, isDark = false, + cacheKey, + cacheOnBlur = true, secureTextEntry, multiline = false, keyboardType = 'default', + onBlur, ...rest }) => { const [isFocused, setIsFocused] = useState(false); const [showPassword, setShowPassword] = useState(false); + const [suggestion, setSuggestion] = useState(null); const { scale } = useDynamicFontSize(); const isPassword = secureTextEntry === true; + useEffect(() => { + if (!cacheKey || !isFocused) { + setSuggestion(null); + return; + } + + let cancelled = false; + void (async () => { + const store = await formCacheService.loadFormCache(); + if (cancelled) return; + setSuggestion(formCacheService.getSuggestionForField(store, cacheKey, value)); + })(); + + return () => { + cancelled = true; + }; + }, [cacheKey, isFocused, value]); + + const handleBlur = (e: Parameters>[0]) => { + setIsFocused(false); + if (cacheKey && cacheOnBlur && value.trim()) { + void setCachedFieldValue(cacheKey, value); + } + onBlur?.(e); + }; + + const handleApplySuggestion = () => { + if (suggestion) { + onChangeText(suggestion); + setSuggestion(null); + } + }; + const borderColor = error ? '#ef4444' : isFocused ? '#19c3e6' : isDark ? '#334155' : '#e2e8f0'; const labelColor = error ? '#ef4444' : isDark ? '#94a3b8' : '#64748b'; @@ -94,7 +146,7 @@ export const MobileFormInput: React.FC = ({ value={value} onChangeText={onChangeText} onFocus={() => setIsFocused(true)} - onBlur={() => setIsFocused(false)} + onBlur={handleBlur} secureTextEntry={isPassword && !showPassword} multiline={multiline} keyboardType={keyboardType} @@ -112,6 +164,28 @@ export const MobileFormInput: React.FC = ({ )} + {suggestion && !error && ( + + + Use saved: + + + {suggestion} + + + )} + {error && ( @@ -176,4 +250,23 @@ const styles = StyleSheet.create({ color: '#ef4444', flex: 1, }, + suggestionRow: { + flexDirection: 'row', + alignItems: 'center', + gap: 6, + marginTop: 6, + paddingHorizontal: 12, + paddingVertical: 8, + borderRadius: 10, + borderWidth: 1, + }, + suggestionLabel: { + fontSize: 12, + fontWeight: '500', + }, + suggestionValue: { + flex: 1, + fontSize: 12, + fontWeight: '600', + }, }); diff --git a/src/components/mobile/MobileProfile.tsx b/src/components/mobile/MobileProfile.tsx index 305b58b..95ec96a 100644 --- a/src/components/mobile/MobileProfile.tsx +++ b/src/components/mobile/MobileProfile.tsx @@ -15,7 +15,7 @@ import { Users, X, } from 'lucide-react-native'; -import React, { useState } from 'react'; +import React, { useEffect, useState } from 'react'; import { ActivityIndicator, SafeAreaView, @@ -24,13 +24,15 @@ import { TouchableOpacity, View, } from 'react-native'; -import { AppText as Text } from '../common/AppText'; -import { CachedImage } from '../ui/CachedImage'; -import { Skeleton } from '../ui/Skeleton'; +import { useFormCache } from '../../hooks/useFormCache'; +import { PROFILE_FORM_CACHE_KEYS } from '../../services/formCache'; import { Achievement, AchievementBadges } from './AchievementBadges'; import { AvatarCamera } from './AvatarCamera'; import { MobileFormInput } from './MobileFormInput'; import { StatisticsDisplay } from './StatisticsDisplay'; +import { AppText as Text } from '../common/AppText'; +import { CachedImage } from '../ui/CachedImage'; +import { Skeleton } from '../ui/Skeleton'; // ─── Types ─────────────────────────────────────────────────────────────────── @@ -236,16 +238,61 @@ interface MobileProfileProps { isLoading?: boolean; } -import { useDynamicFontSize } from '../../hooks'; - export const MobileProfile: React.FC = ({ userId: _userId, isDark = false, isLoading = false, }) => { const [profile, setProfile] = useState(MOCK_PROFILE); - const { scale } = useDynamicFontSize(); - const { achievements, unlockedCount } = useAchievementStore(); + const { + applyPrefillToFields, + persistFields, + prefillValues, + isLoading: formCacheLoading, + } = useFormCache(PROFILE_FORM_CACHE_KEYS); + const unlockedCount = profile.achievements.filter(a => !a.isLocked).length; + const [activeTab, setActiveTab] = useState('overview'); + const [isEditing, setIsEditing] = useState(false); + const [isCameraVisible, setIsCameraVisible] = useState(false); + const [isSaving, setIsSaving] = useState(false); + + // Edit form state + const [editName, setEditName] = useState(''); + const [editBio, setEditBio] = useState(''); + const [editEmail, setEditEmail] = useState(''); + const [editLocation, setEditLocation] = useState(''); + const [editWebsite, setEditWebsite] = useState(''); + const [formErrors, setFormErrors] = useState>({}); + + useEffect(() => { + if (!isEditing || formCacheLoading) return; + applyPrefillToFields( + { + fullName: editName, + email: editEmail, + bio: editBio, + location: editLocation, + website: editWebsite, + }, + { + fullName: setEditName, + email: setEditEmail, + bio: setEditBio, + location: setEditLocation, + website: setEditWebsite, + } + ); + }, [ + applyPrefillToFields, + editBio, + editEmail, + editLocation, + editName, + editWebsite, + formCacheLoading, + isEditing, + prefillValues, + ]); if (isLoading) { const bg = isDark ? '#0f172a' : '#f8fafc'; @@ -286,18 +333,6 @@ export const MobileProfile: React.FC = ({ ); } - const [activeTab, setActiveTab] = useState('overview'); - const [isEditing, setIsEditing] = useState(false); - const [isCameraVisible, setIsCameraVisible] = useState(false); - const [isSaving, setIsSaving] = useState(false); - - // Edit form state - const [editName, setEditName] = useState(''); - const [editBio, setEditBio] = useState(''); - const [editEmail, setEditEmail] = useState(''); - const [editLocation, setEditLocation] = useState(''); - const [editWebsite, setEditWebsite] = useState(''); - const [formErrors, setFormErrors] = useState>({}); // Theme tokens const bg = isDark ? '#0f172a' : '#f8fafc'; @@ -320,6 +355,22 @@ export const MobileProfile: React.FC = ({ setEditEmail(profile.email); setEditLocation(profile.location); setEditWebsite(profile.website); + applyPrefillToFields( + { + fullName: profile.name, + email: profile.email, + bio: profile.bio, + location: profile.location, + website: profile.website, + }, + { + fullName: setEditName, + email: setEditEmail, + bio: setEditBio, + location: setEditLocation, + website: setEditWebsite, + } + ); setFormErrors({}); setIsEditing(true); }; @@ -349,6 +400,13 @@ export const MobileProfile: React.FC = ({ location: editLocation.trim(), website: editWebsite.trim(), })); + await persistFields({ + fullName: editName.trim(), + email: editEmail.trim(), + bio: editBio.trim(), + location: editLocation.trim(), + website: editWebsite.trim(), + }); setIsSaving(false); setIsEditing(false); }; @@ -603,6 +661,7 @@ export const MobileProfile: React.FC = ({ required error={formErrors.name} isDark={isDark} + cacheKey="fullName" leftIcon={} /> = ({ required error={formErrors.email} isDark={isDark} + cacheKey="email" leftIcon={} /> = ({ placeholder="Tell us about yourself..." multiline isDark={isDark} + cacheKey="bio" /> = ({ onChangeText={setEditLocation} placeholder="City, Country" isDark={isDark} + cacheKey="location" leftIcon={} /> = ({ keyboardType="url" autoCapitalize="none" isDark={isDark} + cacheKey="website" leftIcon={} /> @@ -699,7 +762,7 @@ export const MobileProfile: React.FC = ({ 🔥 {profile.stats.streak} Day Streak - Keep it up! You're on fire. + Keep it up! You're on fire. diff --git a/src/components/mobile/MobileSettings.tsx b/src/components/mobile/MobileSettings.tsx index b526681..85436b2 100644 --- a/src/components/mobile/MobileSettings.tsx +++ b/src/components/mobile/MobileSettings.tsx @@ -34,8 +34,9 @@ import { import { useAppStore } from '../../store'; import { useNotificationStore } from '../../store/notificationStore'; import { useSettingsStore } from '../../store/settingsStore'; -import { useBiometricAuth } from '../../hooks/useBiometricAuth'; import { useDynamicFontSize } from '../../hooks'; +import { useBiometricAuth } from '../../hooks/useBiometricAuth'; +import { useFormCache } from '../../hooks/useFormCache'; import { NativeToggle } from './NativeToggle'; import { PickerOption, SettingsPicker } from './SettingsPicker'; @@ -186,6 +187,25 @@ export function MobileSettings({ } = useBiometricAuth(); const { scale } = useDynamicFontSize(); + const { clearCache: clearStoredFormFields } = useFormCache([]); + + const handleClearFormCache = () => { + Alert.alert( + 'Clear Cached Form Data', + 'Remove saved names, emails, and addresses from this device?', + [ + { text: 'Cancel', style: 'cancel' }, + { + text: 'Clear', + style: 'destructive', + onPress: async () => { + await clearStoredFormFields(); + Alert.alert('Cleared', 'Cached form data has been removed.'); + }, + }, + ] + ); + }; const handleBiometricToggle = async (value: boolean) => { if (value) { @@ -284,6 +304,14 @@ export function MobileSettings({ label="Analytics" right={} /> + + } + label="Clear Cached Form Data" + description="Remove saved autofill values from this device" + onPress={handleClearFormCache} + destructive + /> {/* DOWNLOADS */} diff --git a/src/hooks/index.ts b/src/hooks/index.ts index 9a344e3..0a720d1 100644 --- a/src/hooks/index.ts +++ b/src/hooks/index.ts @@ -1,3 +1,4 @@ +export * from './useFormCache'; export * from './useAnalytics'; export { AuthProvider, useAuth } from './useAuth'; export * from './useBiometricAuth'; diff --git a/src/hooks/useFormCache.ts b/src/hooks/useFormCache.ts new file mode 100644 index 0000000..1d316d5 --- /dev/null +++ b/src/hooks/useFormCache.ts @@ -0,0 +1,89 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; + +import { + cacheFormValues, + clearFormCache, + formCacheService, + getCachedFieldValues, + type FormCacheFieldKey, + type FormCacheStore, + loadFormCache, +} from '../services/formCache'; + +export function useFormCache(fieldKeys: FormCacheFieldKey[]) { + const [prefillValues, setPrefillValues] = useState>>( + {} + ); + const [cacheStore, setCacheStore] = useState({}); + const [isLoading, setIsLoading] = useState(true); + + const keysSignature = fieldKeys.join(','); + const stableFieldKeysRef = useRef(fieldKeys); + + useEffect(() => { + stableFieldKeysRef.current = fieldKeys; + }, [keysSignature]); + + const refresh = useCallback(async () => { + setIsLoading(true); + const [values, store] = await Promise.all([ + getCachedFieldValues(stableFieldKeysRef.current), + loadFormCache(), + ]); + setPrefillValues(values); + setCacheStore(store); + setIsLoading(false); + }, [keysSignature]); + + useEffect(() => { + void refresh(); + }, [refresh]); + + const persistFields = useCallback( + async (values: Partial>) => { + await cacheFormValues(values); + await refresh(); + }, + [refresh] + ); + + const applyPrefillToFields = useCallback( + ( + currentValues: Partial>, + setters: Partial void>> + ) => { + for (const key of stableFieldKeysRef.current) { + const cached = prefillValues[key]; + const current = currentValues[key]?.trim() ?? ''; + const setter = setters[key]; + if (cached && !current && setter) { + setter(cached); + } + } + }, + [prefillValues] + ); + + const getSuggestion = useCallback( + (key: FormCacheFieldKey, currentValue: string) => + formCacheService.getSuggestionForField(cacheStore, key, currentValue), + [cacheStore] + ); + + const clearCache = useCallback(async () => { + await clearFormCache(); + setPrefillValues({}); + setCacheStore({}); + }, []); + + return { + prefillValues, + cacheStore, + isLoading, + persistFields, + applyPrefillToFields, + getSuggestion, + clearCache, + refresh, + }; +} diff --git a/src/pages/mobile/MobileRegister.tsx b/src/pages/mobile/MobileRegister.tsx index 21018ec..5d1c600 100644 --- a/src/pages/mobile/MobileRegister.tsx +++ b/src/pages/mobile/MobileRegister.tsx @@ -1,4 +1,6 @@ -import React, { useRef, useState } from 'react'; +import React, { useEffect, useRef, useState } from 'react'; +import { LinearGradient } from 'expo-linear-gradient'; +import { AlertCircle, BookOpen, Lock, Mail, User } from 'lucide-react-native'; import { ActivityIndicator, KeyboardAvoidingView, @@ -11,9 +13,9 @@ import { TouchableOpacity, View, } from 'react-native'; -import { LinearGradient } from 'expo-linear-gradient'; -import { AlertCircle, BookOpen, Lock, Mail, User } from 'lucide-react-native'; import { useDynamicFontSize } from '../../hooks/useDynamicFontSize'; +import { useFormCache } from '../../hooks/useFormCache'; +import { cacheFormValues } from '../../services/formCache'; import { getPasswordStrength, validateConfirmPassword, @@ -52,8 +54,11 @@ export const MobileRegister: React.FC = ({ const confirmRef = useRef(null); const { scale } = useDynamicFontSize(); + const { applyPrefillToFields, isLoading: formCacheLoading, prefillValues } = useFormCache([ + 'fullName', + 'email', + ]); const styles = createStyles(scale); - const bg = isDark ? '#0f172a' : '#f8fafc'; const cardBg = isDark ? '#1e293b' : '#fff'; const textPrimary = isDark ? '#f1f5f9' : '#1e293b'; @@ -64,6 +69,11 @@ export const MobileRegister: React.FC = ({ const passwordStrength = getPasswordStrength(password); + useEffect(() => { + if (formCacheLoading) return; + applyPrefillToFields({ fullName: name, email }, { fullName: setName, email: setEmail }); + }, [applyPrefillToFields, email, formCacheLoading, name, prefillValues]); + function clearFieldError(field: keyof FieldErrors) { setErrors((prev) => ({ ...prev, [field]: undefined })); } @@ -90,6 +100,7 @@ export const MobileRegister: React.FC = ({ try { // Registration API call would go here await new Promise((resolve) => setTimeout(resolve, 1000)); + await cacheFormValues({ fullName: name.trim(), email: email.trim().toLowerCase() }); onRegisterSuccess?.(); } finally { setIsLoading(false); diff --git a/src/services/formCache.ts b/src/services/formCache.ts new file mode 100644 index 0000000..2d3b117 --- /dev/null +++ b/src/services/formCache.ts @@ -0,0 +1,149 @@ +import AsyncStorage from '@react-native-async-storage/async-storage'; + +/** AsyncStorage key for the form value cache (versioned for future migrations). */ +export const FORM_CACHE_STORAGE_KEY = '@teachlink/form-cache/v1'; + +/** Cached entries older than this are pruned on read/write (90 days). */ +export const FORM_CACHE_TTL_MS = 90 * 24 * 60 * 60 * 1000; + +/** Reusable field identifiers shared across forms. */ +export type FormCacheFieldKey = + | 'fullName' + | 'email' + | 'bio' + | 'location' + | 'website' + | 'phone' + | 'addressLine1' + | 'addressLine2' + | 'city' + | 'state' + | 'postalCode' + | 'country' + | 'company'; + +export interface CachedFormField { + value: string; + updatedAt: number; +} + +export type FormCacheStore = Partial>; + +const SENSITIVE_FIELD_KEYS: FormCacheFieldKey[] = []; + +export function isExpired(entry: CachedFormField, now = Date.now()): boolean { + return now - entry.updatedAt > FORM_CACHE_TTL_MS; +} + +export function pruneExpiredCache(store: FormCacheStore, now = Date.now()): FormCacheStore { + const pruned: FormCacheStore = {}; + for (const key of Object.keys(store) as FormCacheFieldKey[]) { + const entry = store[key]; + if (entry && !isExpired(entry, now)) { + pruned[key] = entry; + } + } + return pruned; +} + +export async function loadFormCache(): Promise { + const raw = await AsyncStorage.getItem(FORM_CACHE_STORAGE_KEY); + if (!raw) return {}; + try { + const parsed = JSON.parse(raw) as FormCacheStore; + const pruned = pruneExpiredCache(parsed); + if (Object.keys(pruned).length !== Object.keys(parsed).length) { + await saveFormCache(pruned); + } + return pruned; + } catch { + return {}; + } +} + +export async function saveFormCache(store: FormCacheStore): Promise { + await AsyncStorage.setItem(FORM_CACHE_STORAGE_KEY, JSON.stringify(store)); +} + +export async function getCachedFieldValue(key: FormCacheFieldKey): Promise { + const store = await loadFormCache(); + const entry = store[key]; + if (!entry || isExpired(entry)) return null; + return entry.value; +} + +export async function getCachedFieldValues( + keys: FormCacheFieldKey[] +): Promise>> { + const store = await loadFormCache(); + const result: Partial> = {}; + for (const key of keys) { + const entry = store[key]; + if (entry && !isExpired(entry) && entry.value.trim()) { + result[key] = entry.value; + } + } + return result; +} + +export async function setCachedFieldValue(key: FormCacheFieldKey, value: string): Promise { + const trimmed = value.trim(); + if (!trimmed || SENSITIVE_FIELD_KEYS.includes(key)) return; + + const store = await loadFormCache(); + store[key] = { value: trimmed, updatedAt: Date.now() }; + await saveFormCache(pruneExpiredCache(store)); +} + +export async function cacheFormValues( + values: Partial> +): Promise { + const store = await loadFormCache(); + const now = Date.now(); + + for (const [key, value] of Object.entries(values) as [FormCacheFieldKey, string][]) { + const trimmed = value?.trim(); + if (!trimmed || SENSITIVE_FIELD_KEYS.includes(key)) continue; + store[key] = { value: trimmed, updatedAt: now }; + } + + await saveFormCache(pruneExpiredCache(store)); +} + +export function getSuggestionForField( + store: FormCacheStore, + key: FormCacheFieldKey, + currentValue: string +): string | null { + const entry = store[key]; + if (!entry || isExpired(entry)) return null; + const suggestion = entry.value.trim(); + if (!suggestion) return null; + if (suggestion === currentValue.trim()) return null; + return suggestion; +} + +export async function clearFormCache(): Promise { + await AsyncStorage.removeItem(FORM_CACHE_STORAGE_KEY); +} + +/** Maps profile/edit labels to shared cache keys. */ +export const PROFILE_FORM_CACHE_KEYS: FormCacheFieldKey[] = [ + 'fullName', + 'email', + 'bio', + 'location', + 'website', +]; + +export const formCacheService = { + loadFormCache, + getCachedFieldValue, + getCachedFieldValues, + setCachedFieldValue, + cacheFormValues, + clearFormCache, + getSuggestionForField, + pruneExpiredCache, + isExpired, +}; diff --git a/tests/hooks/useFormCache.test.ts b/tests/hooks/useFormCache.test.ts new file mode 100644 index 0000000..8480b16 --- /dev/null +++ b/tests/hooks/useFormCache.test.ts @@ -0,0 +1,105 @@ +import { beforeEach, describe, expect, it } from '@jest/globals'; +import AsyncStorage from '@react-native-async-storage/async-storage'; +import { act, renderHook, waitFor } from '@testing-library/react-native'; + +import { useFormCache } from '../../src/hooks/useFormCache'; +import { FORM_CACHE_STORAGE_KEY } from '../../src/services/formCache'; + +jest.mock('@react-native-async-storage/async-storage', () => ({ + getItem: jest.fn(), + setItem: jest.fn(), + removeItem: jest.fn(), +})); + +const mockGetItem = AsyncStorage.getItem as jest.Mock; +const mockSetItem = AsyncStorage.setItem as jest.Mock; +const mockRemoveItem = AsyncStorage.removeItem as jest.Mock; + +beforeEach(() => { + jest.clearAllMocks(); + mockGetItem.mockResolvedValue(null); + mockSetItem.mockResolvedValue(undefined); + mockRemoveItem.mockResolvedValue(undefined); +}); + +describe('useFormCache', () => { + it('loads cached values for requested keys', async () => { + const now = Date.now(); + mockGetItem.mockResolvedValue( + JSON.stringify({ + fullName: { value: 'Cached User', updatedAt: now }, + email: { value: 'c@d.com', updatedAt: now }, + }) + ); + + const { result } = renderHook(() => useFormCache(['fullName', 'email'])); + + await waitFor(() => expect(result.current.isLoading).toBe(false)); + + expect(result.current.prefillValues.fullName).toBe('Cached User'); + expect(result.current.prefillValues.email).toBe('c@d.com'); + }); + + it('applyPrefillToFields only fills empty fields', async () => { + const now = Date.now(); + mockGetItem.mockResolvedValue( + JSON.stringify({ + fullName: { value: 'From Cache', updatedAt: now }, + email: { value: 'cached@e.com', updatedAt: now }, + }) + ); + + const setFullName = jest.fn(); + const setEmail = jest.fn(); + + const { result } = renderHook(() => useFormCache(['fullName', 'email'])); + + await waitFor(() => expect(result.current.isLoading).toBe(false)); + + act(() => { + result.current.applyPrefillToFields( + { fullName: 'Already', email: '' }, + { fullName: setFullName, email: setEmail } + ); + }); + + expect(setFullName).not.toHaveBeenCalled(); + expect(setEmail).toHaveBeenCalledWith('cached@e.com'); + }); + + it('clearCache removes storage and resets state', async () => { + const { result } = renderHook(() => useFormCache(['fullName'])); + + await waitFor(() => expect(result.current.isLoading).toBe(false)); + + await act(async () => { + await result.current.clearCache(); + }); + + expect(mockRemoveItem).toHaveBeenCalledWith(FORM_CACHE_STORAGE_KEY); + expect(result.current.prefillValues).toEqual({}); + }); + + it('shares persisted values across form sessions', async () => { + let storedValue: string | null = null; + mockGetItem.mockImplementation(async () => storedValue); + mockSetItem.mockImplementation(async (_key, value) => { + storedValue = value as string; + }); + + const firstForm = renderHook(() => useFormCache(['fullName', 'email'])); + await waitFor(() => expect(firstForm.result.current.isLoading).toBe(false)); + + await act(async () => { + await firstForm.result.current.persistFields({ + fullName: 'Persisted User', + email: 'persisted@teachlink.dev', + }); + }); + + const secondForm = renderHook(() => useFormCache(['fullName'])); + await waitFor(() => expect(secondForm.result.current.isLoading).toBe(false)); + + expect(secondForm.result.current.prefillValues.fullName).toBe('Persisted User'); + }); +}); diff --git a/tests/services/formCache.test.ts b/tests/services/formCache.test.ts new file mode 100644 index 0000000..eb553c9 --- /dev/null +++ b/tests/services/formCache.test.ts @@ -0,0 +1,136 @@ +import { beforeEach, describe, expect, it } from '@jest/globals'; +import AsyncStorage from '@react-native-async-storage/async-storage'; + +import { + cacheFormValues, + clearFormCache, + FORM_CACHE_STORAGE_KEY, + FORM_CACHE_TTL_MS, + getCachedFieldValue, + getSuggestionForField, + isExpired, + loadFormCache, + pruneExpiredCache, + setCachedFieldValue, +} from '../../src/services/formCache'; + +jest.mock('@react-native-async-storage/async-storage', () => ({ + getItem: jest.fn(), + setItem: jest.fn(), + removeItem: jest.fn(), +})); + +const mockGetItem = AsyncStorage.getItem as jest.Mock; +const mockSetItem = AsyncStorage.setItem as jest.Mock; +const mockRemoveItem = AsyncStorage.removeItem as jest.Mock; + +beforeEach(() => { + jest.clearAllMocks(); + mockGetItem.mockResolvedValue(null); + mockSetItem.mockResolvedValue(undefined); + mockRemoveItem.mockResolvedValue(undefined); +}); + +describe('formCache', () => { + describe('isExpired', () => { + it('returns true when older than TTL', () => { + const old = Date.now() - FORM_CACHE_TTL_MS - 1000; + expect(isExpired({ value: 'x', updatedAt: old })).toBe(true); + }); + + it('returns false when within TTL', () => { + expect(isExpired({ value: 'x', updatedAt: Date.now() })).toBe(false); + }); + }); + + describe('pruneExpiredCache', () => { + it('drops expired entries', () => { + const now = Date.now(); + const store = pruneExpiredCache( + { + email: { value: 'a@b.com', updatedAt: now }, + location: { value: 'Old', updatedAt: now - FORM_CACHE_TTL_MS - 1 }, + }, + now + ); + expect(store.email).toBeDefined(); + expect(store.location).toBeUndefined(); + }); + }); + + describe('getSuggestionForField', () => { + it('returns null when current matches cache', () => { + const s = getSuggestionForField({ email: { value: 'a@b.com', updatedAt: Date.now() } }, 'email', 'a@b.com'); + expect(s).toBeNull(); + }); + + it('returns cached when different from current', () => { + const s = getSuggestionForField( + { email: { value: 'cached@test.com', updatedAt: Date.now() } }, + 'email', + 'other@test.com' + ); + expect(s).toBe('cached@test.com'); + }); + }); + + describe('loadFormCache', () => { + it('returns empty object when storage is empty', async () => { + mockGetItem.mockResolvedValueOnce(null); + expect(await loadFormCache()).toEqual({}); + }); + + it('parses valid JSON and persists prune when expired keys exist', async () => { + const now = Date.now(); + const raw = JSON.stringify({ + email: { value: 'keep@x.com', updatedAt: now }, + city: { value: 'gone', updatedAt: now - FORM_CACHE_TTL_MS - 10 }, + }); + mockGetItem.mockResolvedValueOnce(raw); + const result = await loadFormCache(); + expect(result.city).toBeUndefined(); + expect(result.email?.value).toBe('keep@x.com'); + expect(mockSetItem).toHaveBeenCalled(); + }); + }); + + describe('setCachedFieldValue', () => { + it('does not persist empty strings', async () => { + mockGetItem.mockResolvedValueOnce('{}'); + await setCachedFieldValue('fullName', ' '); + expect(mockSetItem).not.toHaveBeenCalled(); + }); + + it('merges with existing store', async () => { + mockGetItem.mockResolvedValueOnce(JSON.stringify({})); + await setCachedFieldValue('fullName', 'Jane'); + const written = JSON.parse(mockSetItem.mock.calls[0][1] as string); + expect(written.fullName.value).toBe('Jane'); + expect(mockSetItem.mock.calls[0][0]).toBe(FORM_CACHE_STORAGE_KEY); + }); + }); + + describe('cacheFormValues', () => { + it('writes multiple keys', async () => { + mockGetItem.mockResolvedValueOnce('{}'); + await cacheFormValues({ fullName: 'A', email: 'a@b.com' }); + const written = JSON.parse(mockSetItem.mock.calls[0][1] as string); + expect(written.fullName.value).toBe('A'); + expect(written.email.value).toBe('a@b.com'); + }); + }); + + describe('getCachedFieldValue', () => { + it('returns null for missing key', async () => { + mockGetItem.mockResolvedValueOnce('{}'); + expect(await getCachedFieldValue('phone')).toBeNull(); + }); + }); + + describe('clearFormCache', () => { + it('removes storage key', async () => { + await clearFormCache(); + expect(mockRemoveItem).toHaveBeenCalledWith(FORM_CACHE_STORAGE_KEY); + }); + }); +}); diff --git a/tsconfig.json b/tsconfig.json index d35b930..b12ae52 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -7,7 +7,7 @@ "esModuleInterop": true, "allowSyntheticDefaultImports": true, "baseUrl": ".", - "ignoreDeprecations": "6.0", + "ignoreDeprecations": "5.0", "lib": ["ES2015", "ES2017", "DOM"], "paths": { "@/*": ["./src/*"]