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:
Sergei 2026-01-29 11:27:19 -08:00
parent 74a4c9e8f4
commit 88fc9042a7
5 changed files with 369 additions and 6 deletions

View 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();
});
});

View 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();
});
});
});

View 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();
});
});
});

View File

@ -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,
},
});

View File

@ -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,