Add retry button to error states in equipment and subscription screens
- Added error state with retry functionality to equipment.tsx - Display error message when sensor loading fails - Provide "Try Again" button to retry loading - Clear error on successful retry - Added error state with retry functionality to subscription.tsx - Display error message when beneficiary loading fails - Provide "Try Again" button with icon to retry loading - Show offline icon and proper error layout - Added comprehensive tests for error handling - ErrorMessage component tests for inline errors - FullScreenError component tests - Equipment screen error state tests - Subscription screen error state tests 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
74a4c9e8f4
commit
88fc9042a7
79
__tests__/components/ErrorMessage.test.tsx
Normal file
79
__tests__/components/ErrorMessage.test.tsx
Normal file
@ -0,0 +1,79 @@
|
||||
import React from 'react';
|
||||
import { render, fireEvent } from '@testing-library/react-native';
|
||||
import { ErrorMessage, FullScreenError } from '@/components/ui/ErrorMessage';
|
||||
|
||||
describe('ErrorMessage', () => {
|
||||
it('renders error message with retry button', () => {
|
||||
const onRetry = jest.fn();
|
||||
const { getByText } = render(
|
||||
<ErrorMessage message="Test error" onRetry={onRetry} />
|
||||
);
|
||||
|
||||
expect(getByText('Test error')).toBeTruthy();
|
||||
expect(getByText('Retry')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('calls onRetry when retry button is pressed', () => {
|
||||
const onRetry = jest.fn();
|
||||
const { getByText } = render(
|
||||
<ErrorMessage message="Test error" onRetry={onRetry} />
|
||||
);
|
||||
|
||||
fireEvent.press(getByText('Retry'));
|
||||
expect(onRetry).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('renders without retry button when onRetry is not provided', () => {
|
||||
const { queryByText } = render(<ErrorMessage message="Test error" />);
|
||||
|
||||
expect(queryByText('Retry')).toBeNull();
|
||||
});
|
||||
|
||||
it('renders with skip button when onSkip is provided', () => {
|
||||
const onSkip = jest.fn();
|
||||
const { getByText } = render(
|
||||
<ErrorMessage message="Test error" onSkip={onSkip} />
|
||||
);
|
||||
|
||||
expect(getByText('Skip')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('FullScreenError', () => {
|
||||
it('renders full screen error with retry button', () => {
|
||||
const onRetry = jest.fn();
|
||||
const { getByText } = render(
|
||||
<FullScreenError message="Connection failed" onRetry={onRetry} />
|
||||
);
|
||||
|
||||
expect(getByText('Something went wrong')).toBeTruthy();
|
||||
expect(getByText('Connection failed')).toBeTruthy();
|
||||
expect(getByText('Try Again')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('calls onRetry when Try Again button is pressed', () => {
|
||||
const onRetry = jest.fn();
|
||||
const { getByText } = render(
|
||||
<FullScreenError message="Connection failed" onRetry={onRetry} />
|
||||
);
|
||||
|
||||
fireEvent.press(getByText('Try Again'));
|
||||
expect(onRetry).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('renders custom title', () => {
|
||||
const { getByText } = render(
|
||||
<FullScreenError title="Custom Error" message="Test message" />
|
||||
);
|
||||
|
||||
expect(getByText('Custom Error')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('renders without retry button when onRetry is not provided', () => {
|
||||
const { queryByText } = render(
|
||||
<FullScreenError message="Connection failed" />
|
||||
);
|
||||
|
||||
expect(queryByText('Try Again')).toBeNull();
|
||||
});
|
||||
});
|
||||
91
__tests__/screens/equipment.test.tsx
Normal file
91
__tests__/screens/equipment.test.tsx
Normal file
@ -0,0 +1,91 @@
|
||||
import React from 'react';
|
||||
import { render, fireEvent, waitFor } from '@testing-library/react-native';
|
||||
import { api } from '@/services/api';
|
||||
|
||||
// Mock dependencies
|
||||
jest.mock('@/services/api');
|
||||
jest.mock('expo-router', () => ({
|
||||
useLocalSearchParams: () => ({ id: '1' }),
|
||||
router: { back: jest.fn(), push: jest.fn() },
|
||||
}));
|
||||
jest.mock('@/contexts/BeneficiaryContext', () => ({
|
||||
useBeneficiary: () => ({
|
||||
currentBeneficiary: { id: 1, name: 'Test User' },
|
||||
}),
|
||||
}));
|
||||
jest.mock('@/contexts/BLEContext', () => ({
|
||||
useBLE: () => ({
|
||||
isBLEAvailable: true,
|
||||
}),
|
||||
}));
|
||||
|
||||
// Import the screen after mocks are set up
|
||||
import EquipmentScreen from '@/app/(tabs)/beneficiaries/[id]/equipment';
|
||||
|
||||
describe('EquipmentScreen - Error Handling', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('displays error message when API call fails', async () => {
|
||||
const mockError = { ok: false, error: { message: 'Network error' } };
|
||||
(api.getDevicesForBeneficiary as jest.Mock).mockResolvedValue(mockError);
|
||||
|
||||
const { getByText, findByText } = render(<EquipmentScreen />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(findByText('Network error')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it('displays retry button when error occurs', async () => {
|
||||
const mockError = { ok: false, error: { message: 'Failed to load sensors' } };
|
||||
(api.getDevicesForBeneficiary as jest.Mock).mockResolvedValue(mockError);
|
||||
|
||||
const { findByText } = render(<EquipmentScreen />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(findByText('Try Again')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it('retries loading sensors when retry button is pressed', async () => {
|
||||
const mockError = { ok: false, error: { message: 'Failed to load sensors' } };
|
||||
const mockSuccess = { ok: true, data: [] };
|
||||
|
||||
(api.getDevicesForBeneficiary as jest.Mock)
|
||||
.mockResolvedValueOnce(mockError)
|
||||
.mockResolvedValueOnce(mockSuccess);
|
||||
|
||||
const { findByText, queryByText } = render(<EquipmentScreen />);
|
||||
|
||||
// Wait for error to appear
|
||||
await waitFor(() => {
|
||||
expect(findByText('Try Again')).toBeTruthy();
|
||||
});
|
||||
|
||||
// Press retry button
|
||||
const retryButton = await findByText('Try Again');
|
||||
fireEvent.press(retryButton);
|
||||
|
||||
// Wait for error to disappear after successful retry
|
||||
await waitFor(() => {
|
||||
expect(queryByText('Failed to load sensors')).toBeNull();
|
||||
});
|
||||
|
||||
// Verify API was called twice
|
||||
expect(api.getDevicesForBeneficiary).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('displays generic error message for unknown errors', async () => {
|
||||
(api.getDevicesForBeneficiary as jest.Mock).mockRejectedValue(
|
||||
new Error('Unknown error')
|
||||
);
|
||||
|
||||
const { findByText } = render(<EquipmentScreen />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(findByText(/Unknown error|Failed to load sensors/)).toBeTruthy();
|
||||
});
|
||||
});
|
||||
});
|
||||
103
__tests__/screens/subscription.test.tsx
Normal file
103
__tests__/screens/subscription.test.tsx
Normal file
@ -0,0 +1,103 @@
|
||||
import React from 'react';
|
||||
import { render, fireEvent, waitFor } from '@testing-library/react-native';
|
||||
import { api } from '@/services/api';
|
||||
|
||||
// Mock dependencies
|
||||
jest.mock('@/services/api');
|
||||
jest.mock('expo-router', () => ({
|
||||
useLocalSearchParams: () => ({ id: '1' }),
|
||||
router: { back: jest.fn(), replace: jest.fn() },
|
||||
}));
|
||||
jest.mock('@/contexts/AuthContext', () => ({
|
||||
useAuth: () => ({
|
||||
user: { id: '1', email: 'test@example.com' },
|
||||
}),
|
||||
}));
|
||||
jest.mock('@stripe/stripe-react-native', () => ({
|
||||
usePaymentSheet: () => ({
|
||||
initPaymentSheet: jest.fn(),
|
||||
presentPaymentSheet: jest.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
// Import the screen after mocks are set up
|
||||
import SubscriptionScreen from '@/app/(tabs)/beneficiaries/[id]/subscription';
|
||||
|
||||
describe('SubscriptionScreen - Error Handling', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('displays error message when beneficiary loading fails', async () => {
|
||||
const mockError = { ok: false, error: { message: 'Failed to load beneficiary data' } };
|
||||
(api.getWellNuoBeneficiary as jest.Mock).mockResolvedValue(mockError);
|
||||
(api.getTransactionHistory as jest.Mock).mockResolvedValue({ ok: true, data: { transactions: [] } });
|
||||
|
||||
const { findByText } = render(<SubscriptionScreen />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(findByText('Failed to load beneficiary data')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it('displays retry button when error occurs', async () => {
|
||||
const mockError = { ok: false, error: { message: 'Network error' } };
|
||||
(api.getWellNuoBeneficiary as jest.Mock).mockResolvedValue(mockError);
|
||||
(api.getTransactionHistory as jest.Mock).mockResolvedValue({ ok: true, data: { transactions: [] } });
|
||||
|
||||
const { findByText } = render(<SubscriptionScreen />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(findByText('Try Again')).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it('retries loading beneficiary when retry button is pressed', async () => {
|
||||
const mockError = { ok: false, error: { message: 'Network error' } };
|
||||
const mockSuccess = {
|
||||
ok: true,
|
||||
data: {
|
||||
id: 1,
|
||||
name: 'Test User',
|
||||
subscription: { status: 'active' },
|
||||
},
|
||||
};
|
||||
|
||||
(api.getWellNuoBeneficiary as jest.Mock)
|
||||
.mockResolvedValueOnce(mockError)
|
||||
.mockResolvedValueOnce(mockSuccess);
|
||||
(api.getTransactionHistory as jest.Mock).mockResolvedValue({ ok: true, data: { transactions: [] } });
|
||||
|
||||
const { findByText, queryByText } = render(<SubscriptionScreen />);
|
||||
|
||||
// Wait for error to appear
|
||||
await waitFor(() => {
|
||||
expect(findByText('Try Again')).toBeTruthy();
|
||||
});
|
||||
|
||||
// Press retry button
|
||||
const retryButton = await findByText('Try Again');
|
||||
fireEvent.press(retryButton);
|
||||
|
||||
// Wait for error to disappear after successful retry
|
||||
await waitFor(() => {
|
||||
expect(queryByText('Network error')).toBeNull();
|
||||
});
|
||||
|
||||
// Verify API was called twice (initial + retry)
|
||||
expect(api.getWellNuoBeneficiary).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('displays generic error message for exceptions', async () => {
|
||||
(api.getWellNuoBeneficiary as jest.Mock).mockRejectedValue(
|
||||
new Error('Connection timeout')
|
||||
);
|
||||
(api.getTransactionHistory as jest.Mock).mockResolvedValue({ ok: true, data: { transactions: [] } });
|
||||
|
||||
const { findByText } = render(<SubscriptionScreen />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(findByText(/Connection timeout|Failed to load beneficiary data/)).toBeTruthy();
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -46,6 +46,7 @@ export default function EquipmentScreen() {
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||
const [isDetaching, setIsDetaching] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const beneficiaryName = currentBeneficiary?.name || 'this person';
|
||||
|
||||
@ -60,20 +61,20 @@ export default function EquipmentScreen() {
|
||||
|
||||
try {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
// Get WP sensors from API (attached to beneficiary)
|
||||
const response = await api.getDevicesForBeneficiary(id);
|
||||
|
||||
if (!response.ok) {
|
||||
// If error is "Not authenticated with Legacy API" or network error,
|
||||
// just show empty state without Alert
|
||||
setError(response.error?.message || 'Failed to load sensors');
|
||||
setApiSensors([]);
|
||||
return;
|
||||
}
|
||||
|
||||
setApiSensors(response.data || []);
|
||||
} catch (error) {
|
||||
// Show empty state instead of Alert
|
||||
setError(error instanceof Error ? error.message : 'Failed to load sensors');
|
||||
setApiSensors([]);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
@ -313,6 +314,20 @@ export default function EquipmentScreen() {
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Error Message */}
|
||||
{error && (
|
||||
<View style={styles.errorContainer}>
|
||||
<View style={styles.errorContent}>
|
||||
<Ionicons name="alert-circle" size={24} color={AppColors.error} />
|
||||
<Text style={styles.errorText}>{error}</Text>
|
||||
</View>
|
||||
<TouchableOpacity style={styles.retryButton} onPress={handleRefresh}>
|
||||
<Ionicons name="refresh" size={18} color={AppColors.white} />
|
||||
<Text style={styles.retryButtonText}>Try Again</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
)}
|
||||
|
||||
<ScrollView
|
||||
style={styles.content}
|
||||
contentContainerStyle={styles.scrollContent}
|
||||
@ -782,4 +797,41 @@ const styles = StyleSheet.create({
|
||||
fontStyle: 'italic',
|
||||
opacity: 0.6,
|
||||
},
|
||||
// Error Container
|
||||
errorContainer: {
|
||||
marginHorizontal: Spacing.lg,
|
||||
marginVertical: Spacing.md,
|
||||
padding: Spacing.lg,
|
||||
backgroundColor: AppColors.errorLight,
|
||||
borderRadius: BorderRadius.lg,
|
||||
borderWidth: 1,
|
||||
borderColor: AppColors.error,
|
||||
},
|
||||
errorContent: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: Spacing.sm,
|
||||
marginBottom: Spacing.md,
|
||||
},
|
||||
errorText: {
|
||||
flex: 1,
|
||||
fontSize: FontSizes.base,
|
||||
color: AppColors.error,
|
||||
fontWeight: FontWeights.medium,
|
||||
},
|
||||
retryButton: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backgroundColor: AppColors.error,
|
||||
paddingVertical: Spacing.sm,
|
||||
paddingHorizontal: Spacing.lg,
|
||||
borderRadius: BorderRadius.md,
|
||||
gap: Spacing.xs,
|
||||
},
|
||||
retryButtonText: {
|
||||
color: AppColors.white,
|
||||
fontSize: FontSizes.sm,
|
||||
fontWeight: FontWeights.semibold,
|
||||
},
|
||||
});
|
||||
|
||||
@ -31,6 +31,7 @@ export default function SubscriptionScreen() {
|
||||
const [isCanceling, setIsCanceling] = useState(false);
|
||||
const [beneficiary, setBeneficiary] = useState<Beneficiary | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [showSuccessModal, setShowSuccessModal] = useState(false);
|
||||
const [justSubscribed, setJustSubscribed] = useState(false);
|
||||
const [transactions, setTransactions] = useState<Array<{
|
||||
@ -73,12 +74,15 @@ export default function SubscriptionScreen() {
|
||||
const loadBeneficiary = async () => {
|
||||
if (!id) return;
|
||||
try {
|
||||
setError(null);
|
||||
const response = await api.getWellNuoBeneficiary(parseInt(id, 10));
|
||||
if (response.ok && response.data) {
|
||||
setBeneficiary(response.data);
|
||||
} else {
|
||||
setError(response.error?.message || 'Failed to load beneficiary data');
|
||||
}
|
||||
} catch (error) {
|
||||
// Failed to load beneficiary
|
||||
setError(error instanceof Error ? error.message : 'Failed to load beneficiary data');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
@ -317,7 +321,7 @@ export default function SubscriptionScreen() {
|
||||
);
|
||||
}
|
||||
|
||||
if (!beneficiary) {
|
||||
if (!beneficiary || error) {
|
||||
return (
|
||||
<SafeAreaView style={styles.container} edges={['top', 'bottom']}>
|
||||
<View style={styles.header}>
|
||||
@ -328,7 +332,18 @@ export default function SubscriptionScreen() {
|
||||
<View style={styles.placeholder} />
|
||||
</View>
|
||||
<View style={styles.centerContainer}>
|
||||
<Text style={styles.errorText}>Unable to load beneficiary</Text>
|
||||
<Ionicons name="cloud-offline-outline" size={64} color={AppColors.textMuted} />
|
||||
<Text style={styles.errorTitle}>{error || 'Unable to load beneficiary'}</Text>
|
||||
<TouchableOpacity
|
||||
style={styles.retryButton}
|
||||
onPress={() => {
|
||||
setIsLoading(true);
|
||||
loadBeneficiary();
|
||||
}}
|
||||
>
|
||||
<Ionicons name="refresh" size={20} color={AppColors.white} />
|
||||
<Text style={styles.retryButtonText}>Try Again</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</SafeAreaView>
|
||||
);
|
||||
@ -568,6 +583,29 @@ const styles = StyleSheet.create({
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
padding: Spacing.xl,
|
||||
},
|
||||
errorTitle: {
|
||||
fontSize: FontSizes.lg,
|
||||
fontWeight: FontWeights.semibold,
|
||||
color: AppColors.textPrimary,
|
||||
marginTop: Spacing.lg,
|
||||
textAlign: 'center',
|
||||
},
|
||||
retryButton: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
backgroundColor: AppColors.primary,
|
||||
paddingVertical: Spacing.sm + 4,
|
||||
paddingHorizontal: Spacing.lg,
|
||||
borderRadius: BorderRadius.lg,
|
||||
marginTop: Spacing.xl,
|
||||
gap: Spacing.sm,
|
||||
},
|
||||
retryButtonText: {
|
||||
color: AppColors.white,
|
||||
fontSize: FontSizes.base,
|
||||
fontWeight: FontWeights.semibold,
|
||||
},
|
||||
errorText: {
|
||||
fontSize: FontSizes.base,
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user