From 2b36f801f13b85a71f274b74b45ccc39d513136a Mon Sep 17 00:00:00 2001 From: Sergei Date: Sun, 1 Feb 2026 10:11:14 -0800 Subject: [PATCH] Add comprehensive loading state management system MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add useLoadingState hook with data/loading/error state management - Add useSimpleLoading hook for basic boolean loading state - Add useMultipleLoadingStates hook for tracking multiple items - Create Skeleton component with shimmer animation for placeholders - Create specialized skeletons: SkeletonText, SkeletonAvatar, SkeletonCard, SkeletonListItem, SkeletonBeneficiaryCard, SkeletonSensorCard - Create LoadingOverlay components: modal, inline, and card variants - Create ScreenLoading wrapper with loading/error/content states - Create RefreshableScreen with built-in pull-to-refresh - Create EmptyState and LoadingButtonState utility components - Add comprehensive tests for all components and hooks (61 tests passing) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- __tests__/components/LoadingOverlay.test.tsx | 57 ++++ __tests__/components/ScreenLoading.test.tsx | 168 +++++++++++ __tests__/components/Skeleton.test.tsx | 127 +++++++++ components/ui/LoadingOverlay.tsx | 130 +++++++++ components/ui/ScreenLoading.tsx | 272 ++++++++++++++++++ components/ui/Skeleton.tsx | 251 ++++++++++++++++ hooks/__tests__/useLoadingState.test.ts | 284 +++++++++++++++++++ hooks/useLoadingState.ts | 197 +++++++++++++ 8 files changed, 1486 insertions(+) create mode 100644 __tests__/components/LoadingOverlay.test.tsx create mode 100644 __tests__/components/ScreenLoading.test.tsx create mode 100644 __tests__/components/Skeleton.test.tsx create mode 100644 components/ui/LoadingOverlay.tsx create mode 100644 components/ui/ScreenLoading.tsx create mode 100644 components/ui/Skeleton.tsx create mode 100644 hooks/__tests__/useLoadingState.test.ts create mode 100644 hooks/useLoadingState.ts diff --git a/__tests__/components/LoadingOverlay.test.tsx b/__tests__/components/LoadingOverlay.test.tsx new file mode 100644 index 0000000..8e54502 --- /dev/null +++ b/__tests__/components/LoadingOverlay.test.tsx @@ -0,0 +1,57 @@ +import React from 'react'; +import { render } from '@testing-library/react-native'; +import { + LoadingOverlay, + InlineLoadingOverlay, + LoadingCardOverlay, +} from '@/components/ui/LoadingOverlay'; + +describe('LoadingOverlay', () => { + it('renders nothing when visible is false', () => { + const { toJSON } = render(); + expect(toJSON()).toBeNull(); + }); + + it('renders modal when visible is true', () => { + const { getByText } = render(); + expect(getByText('Loading...')).toBeTruthy(); + }); + + it('renders custom message', () => { + const { getByText } = render( + + ); + expect(getByText('Saving...')).toBeTruthy(); + }); +}); + +describe('InlineLoadingOverlay', () => { + it('renders nothing when visible is false', () => { + const { toJSON } = render(); + expect(toJSON()).toBeNull(); + }); + + it('renders overlay when visible is true', () => { + const { toJSON } = render(); + expect(toJSON()).toBeTruthy(); + }); + + it('renders with message', () => { + const { getByText } = render( + + ); + expect(getByText('Loading data...')).toBeTruthy(); + }); +}); + +describe('LoadingCardOverlay', () => { + it('renders nothing when visible is false', () => { + const { toJSON } = render(); + expect(toJSON()).toBeNull(); + }); + + it('renders overlay when visible is true', () => { + const { toJSON } = render(); + expect(toJSON()).toBeTruthy(); + }); +}); diff --git a/__tests__/components/ScreenLoading.test.tsx b/__tests__/components/ScreenLoading.test.tsx new file mode 100644 index 0000000..fc05994 --- /dev/null +++ b/__tests__/components/ScreenLoading.test.tsx @@ -0,0 +1,168 @@ +import React from 'react'; +import { Text, View } from 'react-native'; +import { render, fireEvent } from '@testing-library/react-native'; +import { + ScreenLoading, + RefreshableScreen, + EmptyState, + LoadingButtonState, +} from '@/components/ui/ScreenLoading'; + +describe('ScreenLoading', () => { + it('renders children when not loading and no error', () => { + const { getByText } = render( + + Content + + ); + expect(getByText('Content')).toBeTruthy(); + }); + + it('renders loading state when isLoading is true', () => { + const { getByText } = render( + + Content + + ); + expect(getByText('Loading...')).toBeTruthy(); + }); + + it('renders custom loading message', () => { + const { getByText } = render( + + Content + + ); + expect(getByText('Fetching data...')).toBeTruthy(); + }); + + it('renders error state when error is provided', () => { + const { getByText } = render( + + Content + + ); + expect(getByText('Something went wrong')).toBeTruthy(); + expect(getByText('Network error')).toBeTruthy(); + }); + + it('renders retry button when onRetry is provided', () => { + const onRetry = jest.fn(); + const { getByText } = render( + + Content + + ); + expect(getByText('Try Again')).toBeTruthy(); + }); + + it('calls onRetry when retry button is pressed', () => { + const onRetry = jest.fn(); + const { getByText } = render( + + Content + + ); + fireEvent.press(getByText('Try Again')); + expect(onRetry).toHaveBeenCalledTimes(1); + }); + + it('does not render retry button when onRetry is not provided', () => { + const { queryByText } = render( + + Content + + ); + expect(queryByText('Try Again')).toBeNull(); + }); +}); + +describe('RefreshableScreen', () => { + it('renders children', () => { + const onRefresh = jest.fn(); + const { getByText } = render( + + Content + + ); + expect(getByText('Content')).toBeTruthy(); + }); +}); + +describe('EmptyState', () => { + it('renders title and description', () => { + const { getByText } = render( + + ); + expect(getByText('No items')).toBeTruthy(); + expect(getByText('Add some items to get started')).toBeTruthy(); + }); + + it('renders action button when provided', () => { + const onAction = jest.fn(); + const { getByText } = render( + + ); + expect(getByText('Add Item')).toBeTruthy(); + }); + + it('calls onAction when action button is pressed', () => { + const onAction = jest.fn(); + const { getByText } = render( + + ); + fireEvent.press(getByText('Add Item')); + expect(onAction).toHaveBeenCalledTimes(1); + }); + + it('does not render action button when onAction is not provided', () => { + const { queryByText } = render( + + ); + expect(queryByText('Add Item')).toBeNull(); + }); +}); + +describe('LoadingButtonState', () => { + it('renders children when not loading', () => { + const { getByText } = render( + + Submit + + ); + expect(getByText('Submit')).toBeTruthy(); + }); + + it('renders loading state when isLoading is true', () => { + const { getByText } = render( + + Submit + + ); + expect(getByText('Loading...')).toBeTruthy(); + }); + + it('renders custom loading text', () => { + const { getByText } = render( + + Submit + + ); + expect(getByText('Saving...')).toBeTruthy(); + }); +}); diff --git a/__tests__/components/Skeleton.test.tsx b/__tests__/components/Skeleton.test.tsx new file mode 100644 index 0000000..bf136d5 --- /dev/null +++ b/__tests__/components/Skeleton.test.tsx @@ -0,0 +1,127 @@ +import React from 'react'; +import { render } from '@testing-library/react-native'; +import { + Skeleton, + SkeletonText, + SkeletonAvatar, + SkeletonCard, + SkeletonListItem, + SkeletonBeneficiaryCard, + SkeletonSensorCard, +} from '@/components/ui/Skeleton'; + +describe('Skeleton', () => { + it('renders with default props', () => { + const { toJSON } = render(); + const tree = toJSON(); + expect(tree).toBeTruthy(); + }); + + it('renders with custom width and height', () => { + const { toJSON } = render(); + const tree = toJSON(); + expect(tree).toBeTruthy(); + }); + + it('renders with custom border radius', () => { + const { toJSON } = render(); + const tree = toJSON(); + expect(tree).toBeTruthy(); + }); + + it('renders with percentage width', () => { + const { toJSON } = render(); + const tree = toJSON(); + expect(tree).toBeTruthy(); + }); +}); + +describe('SkeletonText', () => { + it('renders single line by default', () => { + const { toJSON } = render(); + const tree = toJSON(); + expect(tree).toBeTruthy(); + }); + + it('renders multiple lines', () => { + const { toJSON } = render(); + const tree = toJSON(); + expect(tree).toBeTruthy(); + }); + + it('renders with custom line height', () => { + const { toJSON } = render(); + const tree = toJSON(); + expect(tree).toBeTruthy(); + }); + + it('renders with custom last line width', () => { + const { toJSON } = render(); + const tree = toJSON(); + expect(tree).toBeTruthy(); + }); +}); + +describe('SkeletonAvatar', () => { + it('renders with default size', () => { + const { toJSON } = render(); + const tree = toJSON(); + expect(tree).toBeTruthy(); + }); + + it('renders with custom size', () => { + const { toJSON } = render(); + const tree = toJSON(); + expect(tree).toBeTruthy(); + }); +}); + +describe('SkeletonCard', () => { + it('renders with default height', () => { + const { toJSON } = render(); + const tree = toJSON(); + expect(tree).toBeTruthy(); + }); + + it('renders with custom height', () => { + const { toJSON } = render(); + const tree = toJSON(); + expect(tree).toBeTruthy(); + }); +}); + +describe('SkeletonListItem', () => { + it('renders with default props', () => { + const { toJSON } = render(); + const tree = toJSON(); + expect(tree).toBeTruthy(); + }); + + it('renders with custom avatar size', () => { + const { toJSON } = render(); + const tree = toJSON(); + expect(tree).toBeTruthy(); + }); + + it('renders with custom number of lines', () => { + const { toJSON } = render(); + const tree = toJSON(); + expect(tree).toBeTruthy(); + }); +}); + +describe('SkeletonBeneficiaryCard', () => { + it('renders beneficiary card skeleton', () => { + const { toJSON } = render(); + const tree = toJSON(); + expect(tree).toBeTruthy(); + }); +}); + +describe('SkeletonSensorCard', () => { + it('renders sensor card skeleton', () => { + const { toJSON } = render(); + const tree = toJSON(); + expect(tree).toBeTruthy(); + }); +}); diff --git a/components/ui/LoadingOverlay.tsx b/components/ui/LoadingOverlay.tsx new file mode 100644 index 0000000..08a57f7 --- /dev/null +++ b/components/ui/LoadingOverlay.tsx @@ -0,0 +1,130 @@ +import React from 'react'; +import { + View, + Text, + StyleSheet, + ActivityIndicator, + Modal, + ViewStyle, +} from 'react-native'; +import { AppColors, BorderRadius, FontSizes, Spacing, Shadows } from '@/constants/theme'; + +interface LoadingOverlayProps { + visible: boolean; + message?: string; + transparent?: boolean; +} + +/** + * Full-screen modal loading overlay + * Use for operations that block the entire UI + */ +export function LoadingOverlay({ + visible, + message = 'Loading...', + transparent = true, +}: LoadingOverlayProps) { + if (!visible) return null; + + return ( + + + + + {message && {message}} + + + + ); +} + +interface InlineLoadingOverlayProps { + visible: boolean; + message?: string; + style?: ViewStyle; +} + +/** + * Inline loading overlay that covers its parent container + * Use for loading states within a specific section + */ +export function InlineLoadingOverlay({ + visible, + message, + style, +}: InlineLoadingOverlayProps) { + if (!visible) return null; + + return ( + + + {message && {message}} + + ); +} + +interface LoadingCardOverlayProps { + visible: boolean; +} + +/** + * Card-style loading overlay + * Use for overlay on cards or smaller components + */ +export function LoadingCardOverlay({ visible }: LoadingCardOverlayProps) { + if (!visible) return null; + + return ( + + + + ); +} + +const styles = StyleSheet.create({ + overlay: { + flex: 1, + backgroundColor: AppColors.overlay, + justifyContent: 'center', + alignItems: 'center', + }, + container: { + backgroundColor: AppColors.surface, + borderRadius: BorderRadius.xl, + padding: Spacing.xl, + alignItems: 'center', + minWidth: 150, + ...Shadows.lg, + }, + message: { + marginTop: Spacing.md, + fontSize: FontSizes.base, + color: AppColors.textSecondary, + textAlign: 'center', + }, + inlineOverlay: { + ...StyleSheet.absoluteFillObject, + backgroundColor: 'rgba(255, 255, 255, 0.9)', + justifyContent: 'center', + alignItems: 'center', + borderRadius: BorderRadius.lg, + }, + inlineMessage: { + marginTop: Spacing.sm, + fontSize: FontSizes.sm, + color: AppColors.textSecondary, + textAlign: 'center', + }, + cardOverlay: { + ...StyleSheet.absoluteFillObject, + backgroundColor: 'rgba(255, 255, 255, 0.8)', + justifyContent: 'center', + alignItems: 'center', + borderRadius: BorderRadius.lg, + }, +}); diff --git a/components/ui/ScreenLoading.tsx b/components/ui/ScreenLoading.tsx new file mode 100644 index 0000000..72b0efa --- /dev/null +++ b/components/ui/ScreenLoading.tsx @@ -0,0 +1,272 @@ +import React from 'react'; +import { + View, + Text, + StyleSheet, + ActivityIndicator, + TouchableOpacity, + ScrollView, + RefreshControl, +} from 'react-native'; +import { Ionicons } from '@expo/vector-icons'; +import { SafeAreaView } from 'react-native-safe-area-context'; +import { + AppColors, + BorderRadius, + FontSizes, + Spacing, + FontWeights, + Shadows, +} from '@/constants/theme'; + +interface ScreenLoadingProps { + isLoading: boolean; + error?: string | null; + children: React.ReactNode; + onRetry?: () => void; + loadingMessage?: string; + fullScreen?: boolean; +} + +/** + * Wrapper component for screens with loading/error/content states + * Provides consistent loading and error UI across the app + */ +export function ScreenLoading({ + isLoading, + error, + children, + onRetry, + loadingMessage = 'Loading...', + fullScreen = true, +}: ScreenLoadingProps) { + if (isLoading) { + return ( + + + {loadingMessage} + + ); + } + + if (error) { + return ( + + + + + Something went wrong + {error} + {onRetry && ( + + + Try Again + + )} + + ); + } + + return <>{children}; +} + +interface RefreshableScreenProps { + isRefreshing: boolean; + onRefresh: () => void; + children: React.ReactNode; + contentContainerStyle?: object; + showsVerticalScrollIndicator?: boolean; +} + +/** + * ScrollView with built-in pull-to-refresh + * Provides consistent refresh behavior across the app + */ +export function RefreshableScreen({ + isRefreshing, + onRefresh, + children, + contentContainerStyle, + showsVerticalScrollIndicator = false, +}: RefreshableScreenProps) { + return ( + + } + > + {children} + + ); +} + +interface EmptyStateProps { + icon?: keyof typeof Ionicons.glyphMap; + title: string; + description?: string; + actionLabel?: string; + onAction?: () => void; +} + +/** + * Empty state component for when there's no content to display + */ +export function EmptyState({ + icon = 'folder-open-outline', + title, + description, + actionLabel, + onAction, +}: EmptyStateProps) { + return ( + + + + + {title} + {description && {description}} + {actionLabel && onAction && ( + + {actionLabel} + + )} + + ); +} + +interface LoadingButtonStateProps { + isLoading: boolean; + loadingText?: string; + children: React.ReactNode; +} + +/** + * Wrapper for button content that shows loading state + */ +export function LoadingButtonState({ + isLoading, + loadingText = 'Loading...', + children, +}: LoadingButtonStateProps) { + if (isLoading) { + return ( + + + {loadingText && {loadingText}} + + ); + } + + return <>{children}; +} + +const styles = StyleSheet.create({ + container: { + justifyContent: 'center', + alignItems: 'center', + padding: Spacing.xl, + }, + fullScreen: { + flex: 1, + backgroundColor: AppColors.background, + }, + loadingMessage: { + marginTop: Spacing.md, + fontSize: FontSizes.base, + color: AppColors.textSecondary, + textAlign: 'center', + }, + errorIcon: { + marginBottom: Spacing.md, + }, + errorTitle: { + fontSize: FontSizes.lg, + fontWeight: FontWeights.semibold, + color: AppColors.textPrimary, + marginBottom: Spacing.sm, + textAlign: 'center', + }, + errorMessage: { + fontSize: FontSizes.base, + color: AppColors.textSecondary, + textAlign: 'center', + marginBottom: Spacing.lg, + paddingHorizontal: Spacing.lg, + }, + retryButton: { + flexDirection: 'row', + alignItems: 'center', + backgroundColor: AppColors.primary, + paddingHorizontal: Spacing.lg, + paddingVertical: Spacing.md, + borderRadius: BorderRadius.lg, + gap: Spacing.sm, + ...Shadows.primary, + }, + retryText: { + fontSize: FontSizes.base, + fontWeight: FontWeights.semibold, + color: AppColors.white, + }, + scrollView: { + flex: 1, + }, + emptyContainer: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + padding: Spacing.xl, + }, + emptyIcon: { + width: 80, + height: 80, + borderRadius: 40, + backgroundColor: AppColors.surfaceSecondary, + justifyContent: 'center', + alignItems: 'center', + marginBottom: Spacing.lg, + }, + emptyTitle: { + fontSize: FontSizes.lg, + fontWeight: FontWeights.semibold, + color: AppColors.textPrimary, + textAlign: 'center', + marginBottom: Spacing.sm, + }, + emptyDescription: { + fontSize: FontSizes.base, + color: AppColors.textSecondary, + textAlign: 'center', + marginBottom: Spacing.lg, + paddingHorizontal: Spacing.lg, + }, + emptyAction: { + backgroundColor: AppColors.primary, + paddingHorizontal: Spacing.lg, + paddingVertical: Spacing.md, + borderRadius: BorderRadius.lg, + }, + emptyActionText: { + fontSize: FontSizes.base, + fontWeight: FontWeights.semibold, + color: AppColors.white, + }, + buttonLoading: { + flexDirection: 'row', + alignItems: 'center', + gap: Spacing.sm, + }, + buttonLoadingText: { + fontSize: FontSizes.base, + color: AppColors.white, + fontWeight: FontWeights.medium, + }, +}); diff --git a/components/ui/Skeleton.tsx b/components/ui/Skeleton.tsx new file mode 100644 index 0000000..3269621 --- /dev/null +++ b/components/ui/Skeleton.tsx @@ -0,0 +1,251 @@ +import React, { useEffect, useRef } from 'react'; +import { + View, + StyleSheet, + Animated, + ViewStyle, + Easing, + DimensionValue, +} from 'react-native'; +import { AppColors, BorderRadius, Spacing } from '@/constants/theme'; + +interface SkeletonProps { + width?: DimensionValue; + height?: DimensionValue; + borderRadius?: number; + style?: ViewStyle; +} + +/** + * Animated skeleton loader for placeholder content + * Uses a shimmer effect to indicate loading state + */ +export function Skeleton({ + width = '100%', + height = 20, + borderRadius = BorderRadius.sm, + style, +}: SkeletonProps) { + const shimmerAnim = useRef(new Animated.Value(0)).current; + + useEffect(() => { + const animation = Animated.loop( + Animated.sequence([ + Animated.timing(shimmerAnim, { + toValue: 1, + duration: 1000, + easing: Easing.ease, + useNativeDriver: true, + }), + Animated.timing(shimmerAnim, { + toValue: 0, + duration: 1000, + easing: Easing.ease, + useNativeDriver: true, + }), + ]) + ); + + animation.start(); + + return () => animation.stop(); + }, [shimmerAnim]); + + const animatedOpacity = shimmerAnim.interpolate({ + inputRange: [0, 1], + outputRange: [0.3, 0.7], + }); + + return ( + + ); +} + +/** + * Skeleton for text lines - common use case + */ +export function SkeletonText({ + lines = 1, + lineHeight = 16, + spacing = Spacing.sm, + lastLineWidth = '60%', + style, +}: { + lines?: number; + lineHeight?: number; + spacing?: number; + lastLineWidth?: DimensionValue; + style?: ViewStyle; +}) { + return ( + + {Array.from({ length: lines }).map((_, index) => ( + 1 ? lastLineWidth : '100%'} + style={index < lines - 1 ? { marginBottom: spacing } : undefined} + /> + ))} + + ); +} + +/** + * Skeleton for avatar/circular elements + */ +export function SkeletonAvatar({ + size = 48, + style, +}: { + size?: number; + style?: ViewStyle; +}) { + return ( + + ); +} + +/** + * Skeleton for a card-like element + */ +export function SkeletonCard({ + height = 100, + style, +}: { + height?: DimensionValue; + style?: ViewStyle; +}) { + return ( + + + + ); +} + +/** + * Skeleton for list items with avatar and text + */ +export function SkeletonListItem({ + avatarSize = 48, + lines = 2, + style, +}: { + avatarSize?: number; + lines?: number; + style?: ViewStyle; +}) { + return ( + + + + + + + ); +} + +/** + * Skeleton for beneficiary cards + */ +export function SkeletonBeneficiaryCard({ style }: { style?: ViewStyle }) { + return ( + + + + + + + + + + + + + + ); +} + +/** + * Skeleton for sensor health cards + */ +export function SkeletonSensorCard({ style }: { style?: ViewStyle }) { + return ( + + + + + + + + + ); +} + +const styles = StyleSheet.create({ + skeleton: { + backgroundColor: AppColors.surfaceSecondary, + }, + card: { + marginBottom: Spacing.md, + }, + listItem: { + flexDirection: 'row', + alignItems: 'center', + paddingVertical: Spacing.md, + paddingHorizontal: Spacing.md, + backgroundColor: AppColors.surface, + borderRadius: BorderRadius.lg, + marginBottom: Spacing.sm, + }, + listItemContent: { + flex: 1, + marginLeft: Spacing.md, + }, + beneficiaryCard: { + backgroundColor: AppColors.surface, + borderRadius: BorderRadius.xl, + padding: Spacing.lg, + marginBottom: Spacing.md, + }, + beneficiaryHeader: { + flexDirection: 'row', + alignItems: 'center', + marginBottom: Spacing.md, + }, + beneficiaryInfo: { + flex: 1, + marginLeft: Spacing.md, + }, + beneficiaryActions: { + flexDirection: 'row', + gap: Spacing.sm, + }, + sensorCard: { + backgroundColor: AppColors.surface, + borderRadius: BorderRadius.lg, + padding: Spacing.md, + marginBottom: Spacing.sm, + }, + sensorHeader: { + flexDirection: 'row', + alignItems: 'center', + gap: Spacing.sm, + }, +}); diff --git a/hooks/__tests__/useLoadingState.test.ts b/hooks/__tests__/useLoadingState.test.ts new file mode 100644 index 0000000..27cb26b --- /dev/null +++ b/hooks/__tests__/useLoadingState.test.ts @@ -0,0 +1,284 @@ +import { renderHook, act, cleanup } from '@testing-library/react-native'; +import { + useLoadingState, + useSimpleLoading, + useMultipleLoadingStates, +} from '@/hooks/useLoadingState'; + +// Cleanup after each test to prevent unmounted renderer issues +afterEach(() => { + cleanup(); +}); + +describe('useLoadingState', () => { + it('initializes with default state', () => { + const { result } = renderHook(() => useLoadingState()); + + expect(result.current.data).toBeNull(); + expect(result.current.isLoading).toBe(false); + expect(result.current.error).toBeNull(); + expect(result.current.isRefreshing).toBe(false); + }); + + it('initializes with provided initial data', () => { + const { result } = renderHook(() => + useLoadingState({ initialData: { id: 1, name: 'Test' } }) + ); + + expect(result.current.data).toEqual({ id: 1, name: 'Test' }); + }); + + it('setData updates data and clears error', () => { + const { result } = renderHook(() => useLoadingState()); + + act(() => { + result.current.setError('Previous error'); + }); + + act(() => { + result.current.setData('new data'); + }); + + expect(result.current.data).toBe('new data'); + expect(result.current.error).toBeNull(); + }); + + it('setLoading updates loading state', () => { + const { result } = renderHook(() => useLoadingState()); + + act(() => { + result.current.setLoading(true); + }); + + expect(result.current.isLoading).toBe(true); + + act(() => { + result.current.setLoading(false); + }); + + expect(result.current.isLoading).toBe(false); + }); + + it('setError updates error and clears loading states', () => { + const { result } = renderHook(() => useLoadingState()); + + act(() => { + result.current.setLoading(true); + }); + + act(() => { + result.current.setError('Something went wrong'); + }); + + expect(result.current.error).toBe('Something went wrong'); + expect(result.current.isLoading).toBe(false); + expect(result.current.isRefreshing).toBe(false); + }); + + it('setRefreshing updates refreshing state', () => { + const { result } = renderHook(() => useLoadingState()); + + act(() => { + result.current.setRefreshing(true); + }); + + expect(result.current.isRefreshing).toBe(true); + }); + + it('reset restores initial state', () => { + const { result } = renderHook(() => + useLoadingState({ initialData: 'initial' }) + ); + + act(() => { + result.current.setData('modified'); + result.current.setLoading(true); + result.current.setError('error'); + }); + + act(() => { + result.current.reset(); + }); + + expect(result.current.data).toBe('initial'); + expect(result.current.isLoading).toBe(false); + expect(result.current.error).toBeNull(); + expect(result.current.isRefreshing).toBe(false); + }); + + it('execute handles successful async function', async () => { + const { result } = renderHook(() => useLoadingState()); + + await act(async () => { + await result.current.execute(async () => { + return 'success'; + }); + }); + + expect(result.current.data).toBe('success'); + expect(result.current.isLoading).toBe(false); + expect(result.current.error).toBeNull(); + }); + + it('execute handles async function error', async () => { + const { result } = renderHook(() => useLoadingState()); + + await act(async () => { + await result.current.execute(async () => { + throw new Error('Network error'); + }); + }); + + expect(result.current.error).toBe('Network error'); + expect(result.current.isLoading).toBe(false); + }); + + it('execute with refresh option returns data', async () => { + const { result } = renderHook(() => useLoadingState()); + + await act(async () => { + await result.current.execute( + async () => { + return 'refreshed data'; + }, + { refresh: true } + ); + }); + + expect(result.current.data).toBe('refreshed data'); + expect(result.current.isRefreshing).toBe(false); + }); + + it('execute uses transform function', async () => { + const { result } = renderHook(() => useLoadingState()); + + await act(async () => { + await result.current.execute( + async () => { + return { value: 'raw' }; + }, + { transform: (res) => res.value.toUpperCase() } + ); + }); + + expect(result.current.data).toBe('RAW'); + }); +}); + +describe('useSimpleLoading', () => { + it('initializes with default state', () => { + const { result } = renderHook(() => useSimpleLoading()); + expect(result.current.isLoading).toBe(false); + }); + + it('initializes with provided initial state', () => { + const { result } = renderHook(() => useSimpleLoading(true)); + expect(result.current.isLoading).toBe(true); + }); + + it('startLoading sets isLoading to true', () => { + const { result } = renderHook(() => useSimpleLoading()); + + act(() => { + result.current.startLoading(); + }); + + expect(result.current.isLoading).toBe(true); + }); + + it('stopLoading sets isLoading to false', () => { + const { result } = renderHook(() => useSimpleLoading(true)); + + act(() => { + result.current.stopLoading(); + }); + + expect(result.current.isLoading).toBe(false); + }); + + it('toggleLoading toggles isLoading', () => { + const { result } = renderHook(() => useSimpleLoading(false)); + + act(() => { + result.current.toggleLoading(); + }); + expect(result.current.isLoading).toBe(true); + + act(() => { + result.current.toggleLoading(); + }); + expect(result.current.isLoading).toBe(false); + }); + + it('withLoading wraps async function', async () => { + const { result } = renderHook(() => useSimpleLoading()); + + await act(async () => { + const returnValue = await result.current.withLoading(async () => { + return 'done'; + }); + + expect(returnValue).toBe('done'); + }); + + expect(result.current.isLoading).toBe(false); + }); +}); + +describe('useMultipleLoadingStates', () => { + it('initializes with empty loading states', () => { + const { result } = renderHook(() => useMultipleLoadingStates()); + + expect(result.current.loadingStates).toEqual({}); + expect(result.current.anyLoading).toBe(false); + }); + + it('setLoading updates individual loading state', () => { + const { result } = renderHook(() => useMultipleLoadingStates()); + + act(() => { + result.current.setLoading('item-1', true); + }); + + expect(result.current.isLoading('item-1')).toBe(true); + expect(result.current.isLoading('item-2')).toBe(false); + expect(result.current.anyLoading).toBe(true); + }); + + it('tracks multiple loading states', () => { + const { result } = renderHook(() => useMultipleLoadingStates()); + + act(() => { + result.current.setLoading('item-1', true); + result.current.setLoading('item-2', true); + }); + + expect(result.current.isLoading('item-1')).toBe(true); + expect(result.current.isLoading('item-2')).toBe(true); + expect(result.current.anyLoading).toBe(true); + + act(() => { + result.current.setLoading('item-1', false); + }); + + expect(result.current.isLoading('item-1')).toBe(false); + expect(result.current.isLoading('item-2')).toBe(true); + expect(result.current.anyLoading).toBe(true); + }); + + it('clearAll clears all loading states', () => { + const { result } = renderHook(() => useMultipleLoadingStates()); + + act(() => { + result.current.setLoading('item-1', true); + result.current.setLoading('item-2', true); + }); + + act(() => { + result.current.clearAll(); + }); + + expect(result.current.loadingStates).toEqual({}); + expect(result.current.anyLoading).toBe(false); + }); +}); diff --git a/hooks/useLoadingState.ts b/hooks/useLoadingState.ts new file mode 100644 index 0000000..1951930 --- /dev/null +++ b/hooks/useLoadingState.ts @@ -0,0 +1,197 @@ +import { useState, useCallback } from 'react'; + +interface LoadingState { + data: T | null; + isLoading: boolean; + error: string | null; + isRefreshing: boolean; +} + +interface UseLoadingStateOptions { + initialData?: T | null; +} + +interface UseLoadingStateReturn extends LoadingState { + setData: (data: T | null) => void; + setLoading: (loading: boolean) => void; + setError: (error: string | null) => void; + setRefreshing: (refreshing: boolean) => void; + reset: () => void; + execute: ( + asyncFn: () => Promise, + options?: { refresh?: boolean; transform?: (result: R) => T } + ) => Promise; +} + +/** + * Custom hook for managing loading states + * Provides consistent loading, error, and data state management + */ +export function useLoadingState( + options: UseLoadingStateOptions = {} +): UseLoadingStateReturn { + const { initialData = null } = options; + + const [state, setState] = useState>({ + data: initialData, + isLoading: false, + error: null, + isRefreshing: false, + }); + + const setData = useCallback((data: T | null) => { + setState((prev) => ({ ...prev, data, error: null })); + }, []); + + const setLoading = useCallback((isLoading: boolean) => { + setState((prev) => ({ ...prev, isLoading })); + }, []); + + const setError = useCallback((error: string | null) => { + setState((prev) => ({ ...prev, error, isLoading: false, isRefreshing: false })); + }, []); + + const setRefreshing = useCallback((isRefreshing: boolean) => { + setState((prev) => ({ ...prev, isRefreshing })); + }, []); + + const reset = useCallback(() => { + setState({ + data: initialData, + isLoading: false, + error: null, + isRefreshing: false, + }); + }, [initialData]); + + /** + * Execute an async function with automatic loading state management + * @param asyncFn - The async function to execute + * @param options.refresh - If true, sets isRefreshing instead of isLoading + * @param options.transform - Optional function to transform the result before setting data + */ + const execute = useCallback( + async ( + asyncFn: () => Promise, + execOptions?: { refresh?: boolean; transform?: (result: R) => T } + ): Promise => { + const { refresh = false, transform } = execOptions || {}; + + try { + if (refresh) { + setState((prev) => ({ ...prev, isRefreshing: true, error: null })); + } else { + setState((prev) => ({ ...prev, isLoading: true, error: null })); + } + + const result = await asyncFn(); + + // Transform and set data if transform function provided + if (transform) { + const transformedData = transform(result); + setState((prev) => ({ + ...prev, + data: transformedData, + isLoading: false, + isRefreshing: false, + })); + } else { + // If no transform, try to set result as data (if types match) + setState((prev) => ({ + ...prev, + data: result as unknown as T, + isLoading: false, + isRefreshing: false, + })); + } + + return result; + } catch (err) { + const errorMessage = + err instanceof Error ? err.message : 'An error occurred'; + setState((prev) => ({ + ...prev, + error: errorMessage, + isLoading: false, + isRefreshing: false, + })); + return undefined; + } + }, + [] + ); + + return { + ...state, + setData, + setLoading, + setError, + setRefreshing, + reset, + execute, + }; +} + +/** + * Simple boolean loading state hook + * For cases where you just need a loading flag + */ +export function useSimpleLoading(initialState = false) { + const [isLoading, setIsLoading] = useState(initialState); + + const startLoading = useCallback(() => setIsLoading(true), []); + const stopLoading = useCallback(() => setIsLoading(false), []); + const toggleLoading = useCallback(() => setIsLoading((prev) => !prev), []); + + const withLoading = useCallback( + async (asyncFn: () => Promise): Promise => { + setIsLoading(true); + try { + return await asyncFn(); + } finally { + setIsLoading(false); + } + }, + [] + ); + + return { + isLoading, + setIsLoading, + startLoading, + stopLoading, + toggleLoading, + withLoading, + }; +} + +/** + * Hook for managing multiple loading states by key + * Useful for list items that can have individual loading states + */ +export function useMultipleLoadingStates() { + const [loadingStates, setLoadingStates] = useState>({}); + + const setLoading = useCallback((key: string, loading: boolean) => { + setLoadingStates((prev) => ({ ...prev, [key]: loading })); + }, []); + + const isLoading = useCallback( + (key: string) => loadingStates[key] || false, + [loadingStates] + ); + + const anyLoading = Object.values(loadingStates).some(Boolean); + + const clearAll = useCallback(() => { + setLoadingStates({}); + }, []); + + return { + loadingStates, + setLoading, + isLoading, + anyLoading, + clearAll, + }; +}