Skip to content

Commit 5dac2c3

Browse files
committed
feat: add necessary component to handle image, icon, errors
1 parent 7c39361 commit 5dac2c3

File tree

20 files changed

+345
-104
lines changed

20 files changed

+345
-104
lines changed

template/__mocks__/TestAppWrapper.tsx

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import { QueryClientProvider } from '@tanstack/react-query';
2+
import { type PropsWithChildren } from 'react';
3+
import { GestureHandlerRootView } from 'react-native-gesture-handler';
4+
5+
import { ThemeProvider } from '@/theme';
6+
7+
import '@/translations';
8+
9+
import { queryClient, storage } from '@/App';
10+
11+
function TestAppWrapper({ children }: PropsWithChildren) {
12+
return (
13+
<GestureHandlerRootView>
14+
<QueryClientProvider client={queryClient}>
15+
<ThemeProvider storage={storage}>{children}</ThemeProvider>
16+
</QueryClientProvider>
17+
</GestureHandlerRootView>
18+
);
19+
}
20+
21+
export default TestAppWrapper;

template/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
"intl-pluralrules": "^2.0.1",
2626
"ky": "^1.2.4",
2727
"react": "18.3.1",
28+
"react-error-boundary": "^4.0.13",
2829
"react-i18next": "^14.1.0",
2930
"react-native": "0.75.4",
3031
"react-native-gesture-handler": "^2.16.0",

template/src/App.tsx

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,27 @@
11
import 'react-native-gesture-handler';
22

33
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
4+
import { GestureHandlerRootView } from 'react-native-gesture-handler';
45
import { MMKV } from 'react-native-mmkv';
56

67
import { ThemeProvider } from '@/theme';
78
import ApplicationNavigator from '@/navigations/Application';
89

9-
import './translations';
10+
import '@/translations';
1011

1112
export const queryClient = new QueryClient();
1213

1314
export const storage = new MMKV();
1415

1516
function App() {
1617
return (
17-
<QueryClientProvider client={queryClient}>
18-
<ThemeProvider storage={storage}>
19-
<ApplicationNavigator />
20-
</ThemeProvider>
21-
</QueryClientProvider>
18+
<GestureHandlerRootView>
19+
<QueryClientProvider client={queryClient}>
20+
<ThemeProvider storage={storage}>
21+
<ApplicationNavigator />
22+
</ThemeProvider>
23+
</QueryClientProvider>
24+
</GestureHandlerRootView>
2225
);
2326
}
2427

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import type { ErrorInfo } from 'react';
2+
import type { ErrorBoundaryPropsWithFallback } from 'react-error-boundary';
3+
4+
import { ErrorBoundary as DefaultErrorBoundary } from 'react-error-boundary';
5+
import { View } from 'react-native';
6+
7+
type Optional<T, K extends keyof T> = Pick<Partial<T>, K> & Omit<T, K>;
8+
9+
type Props = Optional<ErrorBoundaryPropsWithFallback, 'fallback'>;
10+
11+
function ErrorBoundary({ fallback = <View />, onError, ...props }: Props) {
12+
const onErrorReportCrashlytics = (error: Error, info: ErrorInfo) => {
13+
// use any crash reporting tool here
14+
return onError?.(error, info);
15+
};
16+
17+
return (
18+
<DefaultErrorBoundary
19+
{...props}
20+
fallback={fallback}
21+
onError={onErrorReportCrashlytics}
22+
/>
23+
);
24+
}
25+
26+
export default ErrorBoundary;

template/src/components/atoms/IconByVariant/IconByVariant.tsx

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ type Props = SvgProps & {
1414
const icons = getAssetsContext('icons');
1515
const EXTENSION = 'svg';
1616

17-
function IconByVariant({ path, ...props }: Props) {
17+
function IconByVariant({ path, width = 24, height = 24, ...props }: Props) {
1818
const [icon, setIcon] = useState<ReactElement<SvgProps>>();
1919
const { variant } = useTheme();
2020

@@ -59,11 +59,13 @@ function IconByVariant({ path, ...props }: Props) {
5959
...icon,
6060
props: {
6161
...icon.props,
62+
height,
63+
width,
6264
...currentProps,
6365
},
6466
};
6567
},
66-
[icon],
68+
[icon, width, height],
6769
);
6870

6971
return <Component {...props} />;
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import { render } from '@testing-library/react-native';
2+
import { Text } from 'react-native';
3+
4+
import TestAppWrapper from '@/../__mocks__/TestAppWrapper';
5+
6+
import SkeletonLoader from './Skeleton';
7+
8+
describe('SkeletonLoader', () => {
9+
beforeAll(() => {
10+
jest.useFakeTimers();
11+
});
12+
13+
it('renders children when not loading', () => {
14+
const { getByText } = render(
15+
<SkeletonLoader loading={false}>
16+
<Text>Loaded Content</Text>
17+
</SkeletonLoader>,
18+
{
19+
wrapper: TestAppWrapper,
20+
},
21+
);
22+
expect(getByText('Loaded Content')).toBeTruthy();
23+
});
24+
25+
it('renders skeleton when loading', () => {
26+
const { getByTestId } = render(<SkeletonLoader loading />, {
27+
wrapper: TestAppWrapper,
28+
});
29+
const skeleton = getByTestId('skeleton-loader');
30+
jest.advanceTimersByTime(800);
31+
expect(skeleton).toBeTruthy();
32+
});
33+
34+
it('applies correct height and width', () => {
35+
const { getByTestId } = render(
36+
<SkeletonLoader height={50} loading width={100} />,
37+
{ wrapper: TestAppWrapper },
38+
);
39+
const skeleton = getByTestId('skeleton-loader');
40+
expect(skeleton).toHaveAnimatedStyle({
41+
opacity: 0.2,
42+
});
43+
jest.advanceTimersByTime(800);
44+
expect(skeleton).toHaveAnimatedStyle({
45+
opacity: 1,
46+
});
47+
expect(skeleton).toHaveStyle({
48+
backgroundColor: '#A1A1A1',
49+
borderRadius: 4,
50+
height: 50,
51+
width: 100,
52+
});
53+
});
54+
});
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
import type { DimensionValue, ViewProps } from 'react-native';
2+
3+
import { useEffect } from 'react';
4+
import { View } from 'react-native';
5+
import Animated, {
6+
useAnimatedStyle,
7+
useSharedValue,
8+
withRepeat,
9+
withTiming,
10+
} from 'react-native-reanimated';
11+
12+
import { useTheme } from '@/theme';
13+
14+
type Props = ViewProps & {
15+
height?: DimensionValue;
16+
loading?: boolean;
17+
width?: DimensionValue;
18+
};
19+
20+
const FROM = 0.2;
21+
const TO = 1;
22+
23+
function SkeletonLoader({
24+
children,
25+
height = 24,
26+
loading = false,
27+
width = '100%',
28+
...props
29+
}: Props) {
30+
const { backgrounds, borders } = useTheme();
31+
32+
const opacity = useSharedValue(FROM);
33+
34+
const animatedStyles = useAnimatedStyle(() => ({
35+
opacity: opacity.value,
36+
}));
37+
38+
useEffect(() => {
39+
if (!loading) {
40+
return;
41+
}
42+
opacity.value = withRepeat(withTiming(TO, { duration: 800 }), -1, true);
43+
}, [loading, opacity]);
44+
45+
return (
46+
<View
47+
{...props}
48+
style={[{ minHeight: height, minWidth: width }, props.style]}
49+
>
50+
{loading ? (
51+
<Animated.View
52+
style={[
53+
animatedStyles,
54+
backgrounds.skeleton,
55+
borders.rounded_4,
56+
{
57+
height,
58+
width,
59+
},
60+
props.style,
61+
]}
62+
testID="skeleton-loader"
63+
/>
64+
) : (
65+
children
66+
)}
67+
</View>
68+
);
69+
}
70+
71+
export default SkeletonLoader;
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,4 @@
11
export { default as AssetByVariant } from './AssetByVariant/AssetByVariant';
2+
export { default as ErrorBoundary } from './ErrorBoundary/ErrorBoundary';
23
export { default as IconByVariant } from './IconByVariant/IconByVariant';
4+
export { default as Skeleton } from './Skeleton/Skeleton';

template/src/components/molecules/Brand/Brand.test.tsx

Lines changed: 10 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,30 +1,17 @@
11
import type { ViewStyle } from 'react-native';
22

3-
import { render, screen } from '@testing-library/react-native';
4-
import { MMKV } from 'react-native-mmkv';
3+
import { render } from '@testing-library/react-native';
54

6-
import { ThemeProvider } from '@/theme';
5+
import TestAppWrapper from '@/../__mocks__/TestAppWrapper';
76

87
import Brand from './Brand';
98

109
describe('Brand component should render correctly', () => {
11-
let storage: MMKV;
12-
13-
beforeAll(() => {
14-
storage = new MMKV();
15-
});
16-
1710
test('with default props if not precises (height: 200, width: 200, resizeMode: "contain")', () => {
18-
const component = (
19-
<ThemeProvider storage={storage}>
20-
<Brand />
21-
</ThemeProvider>
22-
);
23-
24-
render(component);
11+
const { getByTestId } = render(<Brand />, { wrapper: TestAppWrapper });
2512

26-
const wrapper = screen.getByTestId('brand-img-wrapper');
27-
const img = screen.getByTestId('brand-img');
13+
const wrapper = getByTestId('brand-img-wrapper');
14+
const img = getByTestId('brand-img');
2815

2916
// Props set correctly
3017
expect((wrapper.props.style as ViewStyle).height).toBe(200);
@@ -33,16 +20,13 @@ describe('Brand component should render correctly', () => {
3320
});
3421

3522
test('with passed props', () => {
36-
const component = (
37-
<ThemeProvider storage={storage}>
38-
<Brand height={100} resizeMode="cover" width={100} />
39-
</ThemeProvider>
23+
const { getByTestId } = render(
24+
<Brand height={100} resizeMode="cover" width={100} />,
25+
{ wrapper: TestAppWrapper },
4026
);
4127

42-
render(component);
43-
44-
const wrapper = screen.getByTestId('brand-img-wrapper');
45-
const img = screen.getByTestId('brand-img');
28+
const wrapper = getByTestId('brand-img-wrapper');
29+
const img = getByTestId('brand-img');
4630

4731
expect((wrapper.props.style as ViewStyle).height).toBe(100);
4832
expect((wrapper.props.style as ViewStyle).width).toBe(100);
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
import { useErrorBoundary } from 'react-error-boundary';
2+
import { useTranslation } from 'react-i18next';
3+
import { Text, TouchableOpacity, View } from 'react-native';
4+
5+
import { useTheme } from '@/theme';
6+
7+
import { IconByVariant } from '@/components/atoms';
8+
9+
type Props = {
10+
onReset?: () => void;
11+
};
12+
13+
function DefaultErrorScreen({ onReset }: Props) {
14+
const { gutters, layout, colors, fonts } = useTheme();
15+
const { t } = useTranslation();
16+
const { resetBoundary } = useErrorBoundary();
17+
18+
return (
19+
<View
20+
style={[
21+
layout.flex_1,
22+
layout.justifyCenter,
23+
layout.itemsCenter,
24+
gutters.gap_16,
25+
gutters.padding_16,
26+
]}
27+
>
28+
<IconByVariant
29+
height={42}
30+
path="fire"
31+
stroke={colors.red500}
32+
width={42}
33+
/>
34+
<Text style={[fonts.gray800, fonts.bold, fonts.size_16]}>
35+
{t('error_boundary.title')}
36+
</Text>
37+
<Text style={[fonts.gray800, fonts.size_12, fonts.alignCenter]}>
38+
{t('error_boundary.description')}
39+
</Text>
40+
41+
{onReset && (
42+
<TouchableOpacity
43+
onPress={() => {
44+
resetBoundary();
45+
onReset?.();
46+
}}
47+
>
48+
<Text style={[fonts.gray800, fonts.size_16]}>
49+
{t('error_boundary.cta')}
50+
</Text>
51+
</TouchableOpacity>
52+
)}
53+
</View>
54+
);
55+
}
56+
57+
export default DefaultErrorScreen;
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,2 @@
11
export { default as Brand } from './Brand/Brand';
2+
export { default as DefaultError } from './DefaultError/DefaultError';

0 commit comments

Comments
 (0)