Add comprehensive loading state management system
- 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 <noreply@anthropic.com>
This commit is contained in:
parent
610104090a
commit
2b36f801f1
57
__tests__/components/LoadingOverlay.test.tsx
Normal file
57
__tests__/components/LoadingOverlay.test.tsx
Normal file
@ -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(<LoadingOverlay visible={false} />);
|
||||||
|
expect(toJSON()).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders modal when visible is true', () => {
|
||||||
|
const { getByText } = render(<LoadingOverlay visible={true} />);
|
||||||
|
expect(getByText('Loading...')).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders custom message', () => {
|
||||||
|
const { getByText } = render(
|
||||||
|
<LoadingOverlay visible={true} message="Saving..." />
|
||||||
|
);
|
||||||
|
expect(getByText('Saving...')).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('InlineLoadingOverlay', () => {
|
||||||
|
it('renders nothing when visible is false', () => {
|
||||||
|
const { toJSON } = render(<InlineLoadingOverlay visible={false} />);
|
||||||
|
expect(toJSON()).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders overlay when visible is true', () => {
|
||||||
|
const { toJSON } = render(<InlineLoadingOverlay visible={true} />);
|
||||||
|
expect(toJSON()).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders with message', () => {
|
||||||
|
const { getByText } = render(
|
||||||
|
<InlineLoadingOverlay visible={true} message="Loading data..." />
|
||||||
|
);
|
||||||
|
expect(getByText('Loading data...')).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('LoadingCardOverlay', () => {
|
||||||
|
it('renders nothing when visible is false', () => {
|
||||||
|
const { toJSON } = render(<LoadingCardOverlay visible={false} />);
|
||||||
|
expect(toJSON()).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders overlay when visible is true', () => {
|
||||||
|
const { toJSON } = render(<LoadingCardOverlay visible={true} />);
|
||||||
|
expect(toJSON()).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
168
__tests__/components/ScreenLoading.test.tsx
Normal file
168
__tests__/components/ScreenLoading.test.tsx
Normal file
@ -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(
|
||||||
|
<ScreenLoading isLoading={false} error={null}>
|
||||||
|
<Text>Content</Text>
|
||||||
|
</ScreenLoading>
|
||||||
|
);
|
||||||
|
expect(getByText('Content')).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders loading state when isLoading is true', () => {
|
||||||
|
const { getByText } = render(
|
||||||
|
<ScreenLoading isLoading={true} error={null}>
|
||||||
|
<Text>Content</Text>
|
||||||
|
</ScreenLoading>
|
||||||
|
);
|
||||||
|
expect(getByText('Loading...')).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders custom loading message', () => {
|
||||||
|
const { getByText } = render(
|
||||||
|
<ScreenLoading
|
||||||
|
isLoading={true}
|
||||||
|
error={null}
|
||||||
|
loadingMessage="Fetching data..."
|
||||||
|
>
|
||||||
|
<Text>Content</Text>
|
||||||
|
</ScreenLoading>
|
||||||
|
);
|
||||||
|
expect(getByText('Fetching data...')).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders error state when error is provided', () => {
|
||||||
|
const { getByText } = render(
|
||||||
|
<ScreenLoading isLoading={false} error="Network error">
|
||||||
|
<Text>Content</Text>
|
||||||
|
</ScreenLoading>
|
||||||
|
);
|
||||||
|
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(
|
||||||
|
<ScreenLoading isLoading={false} error="Error" onRetry={onRetry}>
|
||||||
|
<Text>Content</Text>
|
||||||
|
</ScreenLoading>
|
||||||
|
);
|
||||||
|
expect(getByText('Try Again')).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls onRetry when retry button is pressed', () => {
|
||||||
|
const onRetry = jest.fn();
|
||||||
|
const { getByText } = render(
|
||||||
|
<ScreenLoading isLoading={false} error="Error" onRetry={onRetry}>
|
||||||
|
<Text>Content</Text>
|
||||||
|
</ScreenLoading>
|
||||||
|
);
|
||||||
|
fireEvent.press(getByText('Try Again'));
|
||||||
|
expect(onRetry).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not render retry button when onRetry is not provided', () => {
|
||||||
|
const { queryByText } = render(
|
||||||
|
<ScreenLoading isLoading={false} error="Error">
|
||||||
|
<Text>Content</Text>
|
||||||
|
</ScreenLoading>
|
||||||
|
);
|
||||||
|
expect(queryByText('Try Again')).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('RefreshableScreen', () => {
|
||||||
|
it('renders children', () => {
|
||||||
|
const onRefresh = jest.fn();
|
||||||
|
const { getByText } = render(
|
||||||
|
<RefreshableScreen isRefreshing={false} onRefresh={onRefresh}>
|
||||||
|
<Text>Content</Text>
|
||||||
|
</RefreshableScreen>
|
||||||
|
);
|
||||||
|
expect(getByText('Content')).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('EmptyState', () => {
|
||||||
|
it('renders title and description', () => {
|
||||||
|
const { getByText } = render(
|
||||||
|
<EmptyState
|
||||||
|
title="No items"
|
||||||
|
description="Add some items to get started"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
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(
|
||||||
|
<EmptyState
|
||||||
|
title="No items"
|
||||||
|
actionLabel="Add Item"
|
||||||
|
onAction={onAction}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
expect(getByText('Add Item')).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls onAction when action button is pressed', () => {
|
||||||
|
const onAction = jest.fn();
|
||||||
|
const { getByText } = render(
|
||||||
|
<EmptyState
|
||||||
|
title="No items"
|
||||||
|
actionLabel="Add Item"
|
||||||
|
onAction={onAction}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
fireEvent.press(getByText('Add Item'));
|
||||||
|
expect(onAction).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not render action button when onAction is not provided', () => {
|
||||||
|
const { queryByText } = render(
|
||||||
|
<EmptyState title="No items" actionLabel="Add Item" />
|
||||||
|
);
|
||||||
|
expect(queryByText('Add Item')).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('LoadingButtonState', () => {
|
||||||
|
it('renders children when not loading', () => {
|
||||||
|
const { getByText } = render(
|
||||||
|
<LoadingButtonState isLoading={false}>
|
||||||
|
<Text>Submit</Text>
|
||||||
|
</LoadingButtonState>
|
||||||
|
);
|
||||||
|
expect(getByText('Submit')).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders loading state when isLoading is true', () => {
|
||||||
|
const { getByText } = render(
|
||||||
|
<LoadingButtonState isLoading={true}>
|
||||||
|
<Text>Submit</Text>
|
||||||
|
</LoadingButtonState>
|
||||||
|
);
|
||||||
|
expect(getByText('Loading...')).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders custom loading text', () => {
|
||||||
|
const { getByText } = render(
|
||||||
|
<LoadingButtonState isLoading={true} loadingText="Saving...">
|
||||||
|
<Text>Submit</Text>
|
||||||
|
</LoadingButtonState>
|
||||||
|
);
|
||||||
|
expect(getByText('Saving...')).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
127
__tests__/components/Skeleton.test.tsx
Normal file
127
__tests__/components/Skeleton.test.tsx
Normal file
@ -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(<Skeleton />);
|
||||||
|
const tree = toJSON();
|
||||||
|
expect(tree).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders with custom width and height', () => {
|
||||||
|
const { toJSON } = render(<Skeleton width={100} height={50} />);
|
||||||
|
const tree = toJSON();
|
||||||
|
expect(tree).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders with custom border radius', () => {
|
||||||
|
const { toJSON } = render(<Skeleton borderRadius={20} />);
|
||||||
|
const tree = toJSON();
|
||||||
|
expect(tree).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders with percentage width', () => {
|
||||||
|
const { toJSON } = render(<Skeleton width="80%" />);
|
||||||
|
const tree = toJSON();
|
||||||
|
expect(tree).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('SkeletonText', () => {
|
||||||
|
it('renders single line by default', () => {
|
||||||
|
const { toJSON } = render(<SkeletonText />);
|
||||||
|
const tree = toJSON();
|
||||||
|
expect(tree).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders multiple lines', () => {
|
||||||
|
const { toJSON } = render(<SkeletonText lines={3} />);
|
||||||
|
const tree = toJSON();
|
||||||
|
expect(tree).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders with custom line height', () => {
|
||||||
|
const { toJSON } = render(<SkeletonText lineHeight={20} />);
|
||||||
|
const tree = toJSON();
|
||||||
|
expect(tree).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders with custom last line width', () => {
|
||||||
|
const { toJSON } = render(<SkeletonText lines={2} lastLineWidth="50%" />);
|
||||||
|
const tree = toJSON();
|
||||||
|
expect(tree).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('SkeletonAvatar', () => {
|
||||||
|
it('renders with default size', () => {
|
||||||
|
const { toJSON } = render(<SkeletonAvatar />);
|
||||||
|
const tree = toJSON();
|
||||||
|
expect(tree).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders with custom size', () => {
|
||||||
|
const { toJSON } = render(<SkeletonAvatar size={64} />);
|
||||||
|
const tree = toJSON();
|
||||||
|
expect(tree).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('SkeletonCard', () => {
|
||||||
|
it('renders with default height', () => {
|
||||||
|
const { toJSON } = render(<SkeletonCard />);
|
||||||
|
const tree = toJSON();
|
||||||
|
expect(tree).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders with custom height', () => {
|
||||||
|
const { toJSON } = render(<SkeletonCard height={200} />);
|
||||||
|
const tree = toJSON();
|
||||||
|
expect(tree).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('SkeletonListItem', () => {
|
||||||
|
it('renders with default props', () => {
|
||||||
|
const { toJSON } = render(<SkeletonListItem />);
|
||||||
|
const tree = toJSON();
|
||||||
|
expect(tree).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders with custom avatar size', () => {
|
||||||
|
const { toJSON } = render(<SkeletonListItem avatarSize={64} />);
|
||||||
|
const tree = toJSON();
|
||||||
|
expect(tree).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders with custom number of lines', () => {
|
||||||
|
const { toJSON } = render(<SkeletonListItem lines={3} />);
|
||||||
|
const tree = toJSON();
|
||||||
|
expect(tree).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('SkeletonBeneficiaryCard', () => {
|
||||||
|
it('renders beneficiary card skeleton', () => {
|
||||||
|
const { toJSON } = render(<SkeletonBeneficiaryCard />);
|
||||||
|
const tree = toJSON();
|
||||||
|
expect(tree).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('SkeletonSensorCard', () => {
|
||||||
|
it('renders sensor card skeleton', () => {
|
||||||
|
const { toJSON } = render(<SkeletonSensorCard />);
|
||||||
|
const tree = toJSON();
|
||||||
|
expect(tree).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
130
components/ui/LoadingOverlay.tsx
Normal file
130
components/ui/LoadingOverlay.tsx
Normal file
@ -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 (
|
||||||
|
<Modal
|
||||||
|
visible={visible}
|
||||||
|
transparent={transparent}
|
||||||
|
animationType="fade"
|
||||||
|
statusBarTranslucent
|
||||||
|
>
|
||||||
|
<View style={styles.overlay}>
|
||||||
|
<View style={styles.container}>
|
||||||
|
<ActivityIndicator size="large" color={AppColors.primary} />
|
||||||
|
{message && <Text style={styles.message}>{message}</Text>}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<View style={[styles.inlineOverlay, style]}>
|
||||||
|
<ActivityIndicator size="large" color={AppColors.primary} />
|
||||||
|
{message && <Text style={styles.inlineMessage}>{message}</Text>}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<View style={styles.cardOverlay}>
|
||||||
|
<ActivityIndicator size="small" color={AppColors.primary} />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
});
|
||||||
272
components/ui/ScreenLoading.tsx
Normal file
272
components/ui/ScreenLoading.tsx
Normal file
@ -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 (
|
||||||
|
<View style={[styles.container, fullScreen && styles.fullScreen]}>
|
||||||
|
<ActivityIndicator size="large" color={AppColors.primary} />
|
||||||
|
<Text style={styles.loadingMessage}>{loadingMessage}</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<View style={[styles.container, fullScreen && styles.fullScreen]}>
|
||||||
|
<View style={styles.errorIcon}>
|
||||||
|
<Ionicons name="alert-circle" size={48} color={AppColors.error} />
|
||||||
|
</View>
|
||||||
|
<Text style={styles.errorTitle}>Something went wrong</Text>
|
||||||
|
<Text style={styles.errorMessage}>{error}</Text>
|
||||||
|
{onRetry && (
|
||||||
|
<TouchableOpacity style={styles.retryButton} onPress={onRetry}>
|
||||||
|
<Ionicons name="refresh" size={18} color={AppColors.white} />
|
||||||
|
<Text style={styles.retryText}>Try Again</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<ScrollView
|
||||||
|
style={styles.scrollView}
|
||||||
|
contentContainerStyle={contentContainerStyle}
|
||||||
|
showsVerticalScrollIndicator={showsVerticalScrollIndicator}
|
||||||
|
refreshControl={
|
||||||
|
<RefreshControl
|
||||||
|
refreshing={isRefreshing}
|
||||||
|
onRefresh={onRefresh}
|
||||||
|
tintColor={AppColors.primary}
|
||||||
|
colors={[AppColors.primary]}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</ScrollView>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<View style={styles.emptyContainer}>
|
||||||
|
<View style={styles.emptyIcon}>
|
||||||
|
<Ionicons name={icon} size={48} color={AppColors.textMuted} />
|
||||||
|
</View>
|
||||||
|
<Text style={styles.emptyTitle}>{title}</Text>
|
||||||
|
{description && <Text style={styles.emptyDescription}>{description}</Text>}
|
||||||
|
{actionLabel && onAction && (
|
||||||
|
<TouchableOpacity style={styles.emptyAction} onPress={onAction}>
|
||||||
|
<Text style={styles.emptyActionText}>{actionLabel}</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<View style={styles.buttonLoading}>
|
||||||
|
<ActivityIndicator size="small" color={AppColors.white} />
|
||||||
|
{loadingText && <Text style={styles.buttonLoadingText}>{loadingText}</Text>}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
});
|
||||||
251
components/ui/Skeleton.tsx
Normal file
251
components/ui/Skeleton.tsx
Normal file
@ -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 (
|
||||||
|
<Animated.View
|
||||||
|
style={[
|
||||||
|
styles.skeleton,
|
||||||
|
{
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
borderRadius,
|
||||||
|
opacity: animatedOpacity,
|
||||||
|
},
|
||||||
|
style,
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 (
|
||||||
|
<View style={style}>
|
||||||
|
{Array.from({ length: lines }).map((_, index) => (
|
||||||
|
<Skeleton
|
||||||
|
key={index}
|
||||||
|
height={lineHeight}
|
||||||
|
width={index === lines - 1 && lines > 1 ? lastLineWidth : '100%'}
|
||||||
|
style={index < lines - 1 ? { marginBottom: spacing } : undefined}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Skeleton for avatar/circular elements
|
||||||
|
*/
|
||||||
|
export function SkeletonAvatar({
|
||||||
|
size = 48,
|
||||||
|
style,
|
||||||
|
}: {
|
||||||
|
size?: number;
|
||||||
|
style?: ViewStyle;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Skeleton
|
||||||
|
width={size}
|
||||||
|
height={size}
|
||||||
|
borderRadius={size / 2}
|
||||||
|
style={style}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Skeleton for a card-like element
|
||||||
|
*/
|
||||||
|
export function SkeletonCard({
|
||||||
|
height = 100,
|
||||||
|
style,
|
||||||
|
}: {
|
||||||
|
height?: DimensionValue;
|
||||||
|
style?: ViewStyle;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<View style={[styles.card, style]}>
|
||||||
|
<Skeleton height={height} borderRadius={BorderRadius.lg} />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Skeleton for list items with avatar and text
|
||||||
|
*/
|
||||||
|
export function SkeletonListItem({
|
||||||
|
avatarSize = 48,
|
||||||
|
lines = 2,
|
||||||
|
style,
|
||||||
|
}: {
|
||||||
|
avatarSize?: number;
|
||||||
|
lines?: number;
|
||||||
|
style?: ViewStyle;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<View style={[styles.listItem, style]}>
|
||||||
|
<SkeletonAvatar size={avatarSize} />
|
||||||
|
<View style={styles.listItemContent}>
|
||||||
|
<SkeletonText lines={lines} lastLineWidth="70%" />
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Skeleton for beneficiary cards
|
||||||
|
*/
|
||||||
|
export function SkeletonBeneficiaryCard({ style }: { style?: ViewStyle }) {
|
||||||
|
return (
|
||||||
|
<View style={[styles.beneficiaryCard, style]}>
|
||||||
|
<View style={styles.beneficiaryHeader}>
|
||||||
|
<SkeletonAvatar size={56} />
|
||||||
|
<View style={styles.beneficiaryInfo}>
|
||||||
|
<Skeleton height={20} width="70%" style={{ marginBottom: Spacing.xs }} />
|
||||||
|
<Skeleton height={14} width="50%" />
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
<View style={styles.beneficiaryActions}>
|
||||||
|
<Skeleton height={36} width={80} borderRadius={BorderRadius.md} />
|
||||||
|
<Skeleton height={36} width={80} borderRadius={BorderRadius.md} />
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Skeleton for sensor health cards
|
||||||
|
*/
|
||||||
|
export function SkeletonSensorCard({ style }: { style?: ViewStyle }) {
|
||||||
|
return (
|
||||||
|
<View style={[styles.sensorCard, style]}>
|
||||||
|
<View style={styles.sensorHeader}>
|
||||||
|
<Skeleton height={24} width={24} borderRadius={BorderRadius.sm} />
|
||||||
|
<Skeleton height={18} width="60%" />
|
||||||
|
</View>
|
||||||
|
<Skeleton height={40} width="100%" style={{ marginTop: Spacing.md }} />
|
||||||
|
<Skeleton height={12} width="80%" style={{ marginTop: Spacing.sm }} />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
});
|
||||||
284
hooks/__tests__/useLoadingState.test.ts
Normal file
284
hooks/__tests__/useLoadingState.test.ts
Normal file
@ -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<string>());
|
||||||
|
|
||||||
|
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<string>());
|
||||||
|
|
||||||
|
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<string>());
|
||||||
|
|
||||||
|
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<string>());
|
||||||
|
|
||||||
|
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<string>());
|
||||||
|
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
197
hooks/useLoadingState.ts
Normal file
197
hooks/useLoadingState.ts
Normal file
@ -0,0 +1,197 @@
|
|||||||
|
import { useState, useCallback } from 'react';
|
||||||
|
|
||||||
|
interface LoadingState<T> {
|
||||||
|
data: T | null;
|
||||||
|
isLoading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
isRefreshing: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UseLoadingStateOptions<T> {
|
||||||
|
initialData?: T | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UseLoadingStateReturn<T> extends LoadingState<T> {
|
||||||
|
setData: (data: T | null) => void;
|
||||||
|
setLoading: (loading: boolean) => void;
|
||||||
|
setError: (error: string | null) => void;
|
||||||
|
setRefreshing: (refreshing: boolean) => void;
|
||||||
|
reset: () => void;
|
||||||
|
execute: <R = T>(
|
||||||
|
asyncFn: () => Promise<R>,
|
||||||
|
options?: { refresh?: boolean; transform?: (result: R) => T }
|
||||||
|
) => Promise<R | undefined>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Custom hook for managing loading states
|
||||||
|
* Provides consistent loading, error, and data state management
|
||||||
|
*/
|
||||||
|
export function useLoadingState<T>(
|
||||||
|
options: UseLoadingStateOptions<T> = {}
|
||||||
|
): UseLoadingStateReturn<T> {
|
||||||
|
const { initialData = null } = options;
|
||||||
|
|
||||||
|
const [state, setState] = useState<LoadingState<T>>({
|
||||||
|
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 <R = T>(
|
||||||
|
asyncFn: () => Promise<R>,
|
||||||
|
execOptions?: { refresh?: boolean; transform?: (result: R) => T }
|
||||||
|
): Promise<R | undefined> => {
|
||||||
|
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 <T>(asyncFn: () => Promise<T>): Promise<T> => {
|
||||||
|
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<Record<string, boolean>>({});
|
||||||
|
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user