Skip to content

Commit 88ab09a

Browse files
Mozez155claude
andcommitted
feat: standardise feedback states across wallet screens
Create reusable ErrorState component and export EmptyState/ErrorState from the component index. Apply all three state components (LoadingState, EmptyState, ErrorState) to the Recent Activity section on the balance screen, replacing ad-hoc inline markup with consistent, accessible feedback surfaces. Add tests for ErrorState (5) and EmptyState (6). Closes #16 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 2bde1e4 commit 88ab09a

5 files changed

Lines changed: 233 additions & 13 deletions

File tree

__tests__/EmptyState.test.tsx

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
import React from 'react';
2+
import { Text } from 'react-native';
3+
import { render, fireEvent } from '@testing-library/react-native';
4+
5+
jest.mock('@react-native-async-storage/async-storage', () => ({
6+
getItem: jest.fn(async () => null),
7+
setItem: jest.fn(async () => {}),
8+
}));
9+
10+
import { EmptyState } from '../src/components/EmptyState';
11+
12+
describe('EmptyState', () => {
13+
it('renders the required title', () => {
14+
const { getByText } = render(<EmptyState title="No contacts yet" />);
15+
16+
expect(getByText('No contacts yet')).toBeTruthy();
17+
});
18+
19+
it('renders an optional message when provided', () => {
20+
const { getByText } = render(
21+
<EmptyState title="No activity yet" message="Your payments will appear here." />
22+
);
23+
24+
expect(getByText('Your payments will appear here.')).toBeTruthy();
25+
});
26+
27+
it('does not render a message node when none is provided', () => {
28+
const { queryByText } = render(<EmptyState title="No items" />);
29+
30+
expect(queryByText('Your payments will appear here.')).toBeNull();
31+
});
32+
33+
it('renders a custom icon', () => {
34+
const { getByTestId } = render(
35+
<EmptyState
36+
title="Empty"
37+
icon={<Text testID="custom-icon">icon</Text>}
38+
/>
39+
);
40+
41+
expect(getByTestId('custom-icon')).toBeTruthy();
42+
});
43+
44+
it('renders the action button and fires onPress', () => {
45+
const onPress = jest.fn();
46+
const { getByText } = render(
47+
<EmptyState
48+
title="No contacts yet"
49+
action={{ label: 'Add Contact', onPress }}
50+
/>
51+
);
52+
53+
fireEvent.press(getByText('Add Contact'));
54+
expect(onPress).toHaveBeenCalledTimes(1);
55+
});
56+
57+
it('does not render an action button when no action is provided', () => {
58+
const { queryByText } = render(<EmptyState title="Empty" />);
59+
60+
expect(queryByText('Add Contact')).toBeNull();
61+
});
62+
});

__tests__/ErrorState.test.tsx

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
import React from 'react';
2+
import { render, fireEvent } from '@testing-library/react-native';
3+
4+
jest.mock('@react-native-async-storage/async-storage', () => ({
5+
getItem: jest.fn(async () => null),
6+
setItem: jest.fn(async () => {}),
7+
}));
8+
9+
jest.mock('lucide-react-native', () => ({
10+
AlertTriangle: () => null,
11+
}));
12+
13+
import { ErrorState } from '../src/components/ErrorState';
14+
15+
describe('ErrorState', () => {
16+
it('renders with the default title and alert role', () => {
17+
const { getByTestId } = render(<ErrorState />);
18+
19+
const node = getByTestId('error-state');
20+
expect(node.props.accessibilityRole).toBe('alert');
21+
expect(node.props.accessibilityLabel).toBe('Something went wrong');
22+
});
23+
24+
it('renders a custom title and message', () => {
25+
const { getByText } = render(
26+
<ErrorState title="Could not load data" message="Pull down to try again." />
27+
);
28+
29+
expect(getByText('Could not load data')).toBeTruthy();
30+
expect(getByText('Pull down to try again.')).toBeTruthy();
31+
});
32+
33+
it('does not render a message when none is provided', () => {
34+
const { queryByText } = render(<ErrorState title="Failed" />);
35+
36+
expect(queryByText('Pull down to try again.')).toBeNull();
37+
});
38+
39+
it('renders the action button and fires onPress', () => {
40+
const onPress = jest.fn();
41+
const { getByText } = render(
42+
<ErrorState
43+
title="Error"
44+
action={{ label: 'Retry', onPress }}
45+
/>
46+
);
47+
48+
fireEvent.press(getByText('Retry'));
49+
expect(onPress).toHaveBeenCalledTimes(1);
50+
});
51+
52+
it('accepts a caller-supplied testID', () => {
53+
const { getByTestId } = render(
54+
<ErrorState testID="recent-activity-error" />
55+
);
56+
57+
expect(getByTestId('recent-activity-error')).toBeTruthy();
58+
});
59+
});

app/(tabs)/index.tsx

Lines changed: 24 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,9 @@ import { NetworkStateBanner } from '../../src/components/NetworkStateBanner';
1111
import { WalletEmptyState } from '../../src/components/WalletEmptyState';
1212
import { BalanceDisplay } from '../../src/components/BalanceDisplay';
1313
import { FundingStatusBanner } from '../../src/components/FundingStatusBanner';
14+
import { LoadingState } from '../../src/components/LoadingState';
15+
import { EmptyState } from '../../src/components/EmptyState';
16+
import { ErrorState } from '../../src/components/ErrorState';
1417
import { useNetworkState } from '../../src/hooks/useNetworkState';
1518
import { Clock } from 'lucide-react-native';
1619
import { BackupReminderModal } from '../../src/components/BackupReminderModal';
@@ -127,11 +130,27 @@ export default function HomeScreen() {
127130
</View>
128131

129132
<View style={styles.transactionsList}>
130-
{recentTransactions.length === 0 && !isLoading && (
131-
<View style={styles.emptyState}>
132-
<Clock color={colors.textMuted} size={48} style={{ marginBottom: SIZES.md }} />
133-
<Text style={styles.emptyText}>No recent transactions</Text>
134-
</View>
133+
{recentTransactions.length === 0 && isLoading && (
134+
<LoadingState
135+
message="Loading transactions…"
136+
testID="recent-activity-loading"
137+
/>
138+
)}
139+
{recentTransactions.length === 0 && !isLoading && error && (
140+
<ErrorState
141+
icon={<Clock color={colors.error} size={48} />}
142+
title="Could not load transactions"
143+
message="Pull down to try again."
144+
testID="recent-activity-error"
145+
/>
146+
)}
147+
{recentTransactions.length === 0 && !isLoading && !error && (
148+
<EmptyState
149+
icon={<Clock color={colors.textMuted} size={48} />}
150+
title="No recent transactions"
151+
message="Your payments will appear here once you send or receive XLM."
152+
testID="recent-activity-empty"
153+
/>
135154
)}
136155
{recentTransactions.map((tx, index) => (
137156
<TransactionListItem
@@ -189,12 +208,4 @@ const createStyles = (colors: ThemeColors) => StyleSheet.create({
189208
padding: SIZES.md,
190209
marginBottom: SIZES.xxl,
191210
},
192-
emptyState: {
193-
padding: SIZES.xl,
194-
alignItems: 'center',
195-
},
196-
emptyText: {
197-
color: colors.textMuted,
198-
fontSize: 14,
199-
},
200211
});

src/components/ErrorState.tsx

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
import React, { useMemo } from 'react';
2+
import { View, Text, StyleSheet, StyleProp, ViewStyle } from 'react-native';
3+
import { AlertTriangle } from 'lucide-react-native';
4+
import { SIZES, ThemeColors } from '../constants/theme';
5+
import { useTheme } from '../hooks/useTheme';
6+
import { Button } from './Button';
7+
8+
interface ErrorStateAction {
9+
label: string;
10+
onPress: () => void;
11+
variant?: 'primary' | 'secondary' | 'outline';
12+
}
13+
14+
export interface ErrorStateProps {
15+
icon?: React.ReactNode;
16+
title?: string;
17+
message?: string;
18+
action?: ErrorStateAction;
19+
style?: StyleProp<ViewStyle>;
20+
testID?: string;
21+
}
22+
23+
export const ErrorState: React.FC<ErrorStateProps> = ({
24+
icon,
25+
title = 'Something went wrong',
26+
message,
27+
action,
28+
style,
29+
testID = 'error-state',
30+
}) => {
31+
const { colors } = useTheme();
32+
const styles = useMemo(() => createStyles(colors), [colors]);
33+
34+
return (
35+
<View
36+
style={[styles.container, style]}
37+
accessibilityRole="alert"
38+
accessibilityLabel={title}
39+
testID={testID}
40+
>
41+
<View style={styles.iconWrapper}>
42+
{icon ?? <AlertTriangle color={colors.error} size={48} />}
43+
</View>
44+
<Text style={styles.title}>{title}</Text>
45+
{message ? <Text style={styles.message}>{message}</Text> : null}
46+
{action ? (
47+
<Button
48+
title={action.label}
49+
onPress={action.onPress}
50+
variant={action.variant ?? 'outline'}
51+
style={styles.action}
52+
/>
53+
) : null}
54+
</View>
55+
);
56+
};
57+
58+
const createStyles = (colors: ThemeColors) =>
59+
StyleSheet.create({
60+
container: {
61+
alignItems: 'center',
62+
justifyContent: 'center',
63+
padding: SIZES.xl,
64+
},
65+
iconWrapper: {
66+
marginBottom: SIZES.md,
67+
},
68+
title: {
69+
color: colors.textPrimary,
70+
fontSize: 18,
71+
fontWeight: 'bold',
72+
textAlign: 'center',
73+
marginBottom: SIZES.xs,
74+
},
75+
message: {
76+
color: colors.textSecondary,
77+
fontSize: 14,
78+
textAlign: 'center',
79+
lineHeight: 20,
80+
},
81+
action: {
82+
marginTop: SIZES.lg,
83+
minWidth: 140,
84+
},
85+
});

src/components/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,9 @@ export { ReviewConfirm } from "./ReviewConfirm";
55
export type { ReviewConfirmProps, ReviewItem } from "./ReviewConfirm";
66
export { LoadingState } from "./LoadingState";
77
export type { LoadingStateProps } from "./LoadingState";
8+
export { EmptyState } from "./EmptyState";
9+
export { ErrorState } from "./ErrorState";
10+
export type { ErrorStateProps } from "./ErrorState";
811
export { ScreenHeader } from "./ScreenHeader";
912
export { OfflineBanner } from "./OfflineBanner";
1013
export { AsyncActionButton } from "./AsyncActionButton";

0 commit comments

Comments
 (0)