Skip to content

docs(cookbook): network requests recipes #1651

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
cbb1c24
example code
Jul 23, 2024
71a6afb
Merge branch 'main' into feat/cookbook/network-reqs
vanGalilea Aug 20, 2024
89df27e
add users list with fetch example and corres. minimal test
vanGalilea Aug 20, 2024
6c57ad7
Merge branch 'main' into feat/cookbook/network-reqs
vanGalilea Aug 26, 2024
49e755d
rename to PhoneBook and add FavoritesList
vanGalilea Aug 26, 2024
fd2cba8
Test harness and proper mocking
vanGalilea Aug 27, 2024
cfdbe42
Compose Axios recipe
vanGalilea Aug 27, 2024
d105659
Compose Fetch recipe
vanGalilea Aug 27, 2024
652d6de
Fix tune TS errors
Aug 27, 2024
fb1f45b
Fix info label
Aug 27, 2024
d52e36e
Sanity check failing test in CI
Aug 27, 2024
a37017d
Sanity check failing test in CI 2 - add waitForElementToBeRemoved
Aug 27, 2024
70791e5
Sanity check failing test in CI 3 - remove waitForElementToBeRemoved …
Aug 27, 2024
11e9407
Sanity check failing test in CI 4 - add console.logs
Aug 27, 2024
bf6b1f4
Debug tests failing in CI
vanGalilea Aug 27, 2024
20f0aa6
Debug tests failing in CI 2
vanGalilea Aug 27, 2024
1eef224
Debug tests failing in CI 3
vanGalilea Aug 27, 2024
4608c88
jest.setTimeout to 7 s
vanGalilea Aug 27, 2024
e2aab55
isKnownError
vanGalilea Aug 27, 2024
852da82
Increase to 10s
vanGalilea Aug 27, 2024
f7c35cc
isErrorWithMessage addition
vanGalilea Aug 27, 2024
d348f72
isErrorWithMessage addition
vanGalilea Aug 27, 2024
29eff89
jest setTimeout 8s
vanGalilea Aug 27, 2024
08a1e5c
useFakeTimers
vanGalilea Aug 27, 2024
dfaff9e
run with --detectOpenHandles
vanGalilea Aug 27, 2024
1ca49e0
reset GH action
vanGalilea Aug 27, 2024
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions examples/cookbook/__mocks__/axios.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
const chuckNorrisError = () => {
throw Error(
"Please ensure you mock 'Axios' - Only Chuck Norris is allowed to make API requests when testing ;)",
);
};

export default {
get: jest.fn(chuckNorrisError),
post: jest.fn(chuckNorrisError),
put: jest.fn(chuckNorrisError),
delete: jest.fn(chuckNorrisError),
request: jest.fn(chuckNorrisError),
};
5 changes: 3 additions & 2 deletions examples/cookbook/app/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@ type Recipe = {
};

const recipes: Recipe[] = [
{ id: 2, title: 'Welcome Screen with Custom Render', path: 'custom-render/' },
{ id: 1, title: 'Task List with Jotai', path: 'jotai/' },
{ id: 1, title: 'Welcome Screen with Custom Render', path: 'custom-render/' },
{ id: 2, title: 'Task List with Jotai', path: 'state-management/jotai/' },
{ id: 3, title: 'Phone book with\na Variety of Net. Req. Methods', path: 'network-requests/' },
];
52 changes: 52 additions & 0 deletions examples/cookbook/app/network-requests/PhoneBook.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import React, { useEffect, useState } from 'react';
import { Text } from 'react-native';
import { User } from './types';
import ContactsList from './components/ContactsList';
import FavoritesList from './components/FavoritesList';
import getAllContacts from './api/getAllContacts';
import getAllFavorites from './api/getAllFavorites';

export default () => {
const [usersData, setUsersData] = useState<User[]>([]);
const [favoritesData, setFavoritesData] = useState<User[]>([]);
const [error, setError] = useState<string | null>(null);

useEffect(() => {
const _getAllContacts = async () => {
const _data = await getAllContacts();
setUsersData(_data);
};
const _getAllFavorites = async () => {
const _data = await getAllFavorites();
setFavoritesData(_data);
};

const run = async () => {
try {
await Promise.all([_getAllContacts(), _getAllFavorites()]);
} catch (e) {
const message = isErrorWithMessage(e) ? e.message : 'Something went wrong';
setError(message);
}
};

void run();
}, []);

if (error) {
return <Text>An error occurred: {error}</Text>;
}

return (
<>
<FavoritesList users={favoritesData} />
<ContactsList users={usersData} />
</>
);
};

const isErrorWithMessage = (
e: unknown,
): e is {
message: string;
} => typeof e === 'object' && e !== null && 'message' in e;
119 changes: 119 additions & 0 deletions examples/cookbook/app/network-requests/__tests__/PhoneBook.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
import { render, screen, waitForElementToBeRemoved } from '@testing-library/react-native';
import React from 'react';
import axios from 'axios';
import PhoneBook from '../PhoneBook';
import { User } from '../types';

jest.mock('axios');

jest.setTimeout(10000);
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When this test suit runs in CI, the default 5s is not sufficient.
Locally it's a matter of 0.91 s.

Any idea what might be causing this on CI? @mdjastrzebski?

describe('PhoneBook', () => {
it('fetches contacts successfully and renders in list', async () => {
(global.fetch as jest.Mock).mockResolvedValueOnce({
ok: true,
json: jest.fn().mockResolvedValueOnce(DATA),
});
(axios.get as jest.Mock).mockResolvedValue({ data: DATA });
render(<PhoneBook />);

await waitForElementToBeRemoved(() => screen.getByText(/users data not quite there yet/i));
expect(await screen.findByText('Name: Mrs Ida Kristensen')).toBeOnTheScreen();
expect(await screen.findByText('Email: [email protected]')).toBeOnTheScreen();
expect(await screen.findAllByText(/name/i)).toHaveLength(3);
});

it('fails to fetch contacts and renders error message', async () => {
(global.fetch as jest.Mock).mockResolvedValueOnce({
ok: false,
});
(axios.get as jest.Mock).mockResolvedValue({ data: DATA });
render(<PhoneBook />);

await waitForElementToBeRemoved(() => screen.getByText(/users data not quite there yet/i));
expect(await screen.findByText(/error fetching contacts/i)).toBeOnTheScreen();
});

it('fetches favorites successfully and renders all users avatars', async () => {
(global.fetch as jest.Mock).mockResolvedValueOnce({
ok: true,
json: jest.fn().mockResolvedValueOnce(DATA),
});
(axios.get as jest.Mock).mockResolvedValue({ data: DATA });
render(<PhoneBook />);

await waitForElementToBeRemoved(() => screen.getByText(/figuring out your favorites/i));
expect(await screen.findByText(/my favorites/i)).toBeOnTheScreen();
expect(await screen.findAllByLabelText('favorite-contact-avatar')).toHaveLength(3);
});

it('fails to fetch favorites and renders error message', async () => {
(global.fetch as jest.Mock).mockResolvedValueOnce({
ok: true,
json: jest.fn().mockResolvedValueOnce(DATA),
});
(axios.get as jest.Mock).mockRejectedValueOnce({ message: 'Error fetching favorites' });
render(<PhoneBook />);

await waitForElementToBeRemoved(() => screen.getByText(/figuring out your favorites/i));
expect(await screen.findByText(/error fetching favorites/i)).toBeOnTheScreen();
});
});

const DATA: { results: User[] } = {
results: [
{
name: {
title: 'Mrs',
first: 'Ida',
last: 'Kristensen',
},
email: '[email protected]',
id: {
name: 'CPR',
value: '250562-5730',
},
picture: {
large: 'https://randomuser.me/api/portraits/women/26.jpg',
medium: 'https://randomuser.me/api/portraits/med/women/26.jpg',
thumbnail: 'https://randomuser.me/api/portraits/thumb/women/26.jpg',
},
cell: '123-4567-890',
},
{
name: {
title: 'Mr',
first: 'Elijah',
last: 'Ellis',
},
email: '[email protected]',
id: {
name: 'TFN',
value: '138117486',
},
picture: {
large: 'https://randomuser.me/api/portraits/men/53.jpg',
medium: 'https://randomuser.me/api/portraits/med/men/53.jpg',
thumbnail: 'https://randomuser.me/api/portraits/thumb/men/53.jpg',
},
cell: '123-4567-890',
},
{
name: {
title: 'Mr',
first: 'Miro',
last: 'Halko',
},
email: '[email protected]',
id: {
name: 'HETU',
value: 'NaNNA945undefined',
},
picture: {
large: 'https://randomuser.me/api/portraits/men/17.jpg',
medium: 'https://randomuser.me/api/portraits/med/men/17.jpg',
thumbnail: 'https://randomuser.me/api/portraits/thumb/men/17.jpg',
},
cell: '123-4567-890',
},
],
};
10 changes: 10 additions & 0 deletions examples/cookbook/app/network-requests/api/getAllContacts.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { User } from '../types';

export default async (): Promise<User[]> => {
const res = await fetch('https://randomuser.me/api/?results=25');
if (!res.ok) {
throw new Error(`Error fetching contacts`);
}
const json = await res.json();
return json.results;
};
7 changes: 7 additions & 0 deletions examples/cookbook/app/network-requests/api/getAllFavorites.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import axios from 'axios';
import { User } from '../types';

export default async (): Promise<User[]> => {
const res = await axios.get('https://randomuser.me/api/?results=10');
return res.data.results;
};
60 changes: 60 additions & 0 deletions examples/cookbook/app/network-requests/components/ContactsList.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { FlatList, Image, StyleSheet, Text, View } from 'react-native';
import React, { useCallback } from 'react';
import type { ListRenderItem } from '@react-native/virtualized-lists';
import { User } from '../types';

export default ({ users }: { users: User[] }) => {
const renderItem: ListRenderItem<User> = useCallback(
({ item: { name, email, picture, cell }, index }) => {
const { title, first, last } = name;
const backgroundColor = index % 2 === 0 ? '#f9f9f9' : '#fff';
return (
<View style={[{ backgroundColor }, styles.userContainer]}>
<Image source={{ uri: picture.thumbnail }} style={styles.userImage} />
<View>
<Text>
Name: {title} {first} {last}
</Text>
<Text>Email: {email}</Text>
<Text>Mobile: {cell}</Text>
</View>
</View>
);
},
[],
);

if (users.length === 0) return <FullScreenLoader />;

return (
<View>
<FlatList<User>
data={users}
renderItem={renderItem}
keyExtractor={(item, index) => `${index}-${item.id.value}`}
/>
</View>
);
};
const FullScreenLoader = () => {
return (
<View style={styles.loaderContainer}>
<Text>Users data not quite there yet...</Text>
</View>
);
};

const styles = StyleSheet.create({
userContainer: {
padding: 16,
flexDirection: 'row',
alignItems: 'center',
},
userImage: {
width: 50,
height: 50,
borderRadius: 24,
marginRight: 16,
},
loaderContainer: { flex: 1, justifyContent: 'center', alignItems: 'center' },
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import { FlatList, Image, StyleSheet, Text, View } from 'react-native';
import React, { useCallback } from 'react';
import type { ListRenderItem } from '@react-native/virtualized-lists';
import { User } from '../types';

export default ({ users }: { users: User[] }) => {
const renderItem: ListRenderItem<User> = useCallback(({ item: { picture } }) => {
return (
<View style={styles.userContainer}>
<Image
source={{ uri: picture.thumbnail }}
style={styles.userImage}
accessibilityLabel={'favorite-contact-avatar'}
/>
</View>
);
}, []);

if (users.length === 0) return <FullScreenLoader />;

return (
<View style={styles.outerContainer}>
<Text>⭐My Favorites</Text>
<FlatList<User>
horizontal
showsHorizontalScrollIndicator={false}
data={users}
renderItem={renderItem}
keyExtractor={(item, index) => `${index}-${item.id.value}`}
/>
</View>
);
};
const FullScreenLoader = () => {
return (
<View style={styles.loaderContainer}>
<Text>Figuring out your favorites...</Text>
</View>
);
};

const styles = StyleSheet.create({
outerContainer: {
padding: 8,
},
userContainer: {
padding: 8,
flexDirection: 'row',
alignItems: 'center',
},
userImage: {
width: 52,
height: 52,
borderRadius: 36,
borderColor: '#9b6dff',
borderWidth: 2,
},
loaderContainer: { height: 52, justifyContent: 'center', alignItems: 'center' },
});
6 changes: 6 additions & 0 deletions examples/cookbook/app/network-requests/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import * as React from 'react';
import PhoneBook from './PhoneBook';

export default function Example() {
return <PhoneBook />;
}
18 changes: 18 additions & 0 deletions examples/cookbook/app/network-requests/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
export type User = {
name: {
title: string;
first: string;
last: string;
};
email: string;
id: {
name: string;
value: string;
};
picture: {
large: string;
medium: string;
thumbnail: string;
};
cell: string;
};
14 changes: 14 additions & 0 deletions examples/cookbook/jest-setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,17 @@ import '@testing-library/react-native/extend-expect';

// Silence the warning: Animated: `useNativeDriver` is not supported because the native animated module is missing
jest.mock('react-native/Libraries/Animated/NativeAnimatedHelper');

// Guard against API requests made during testing
beforeAll(() => {
// the global fetch function:
jest.spyOn(global, 'fetch').mockImplementation(()=> {
throw Error("Please ensure you mock 'fetch' Only Chuck Norris is allowed to make API requests when testing ;)");
});
// with Axios:
// see examples/cookbook/__mocks__/axios.ts
});
afterAll(() => {
// restore the original fetch function
(global.fetch as jest.Mock).mockRestore();
});
1 change: 1 addition & 0 deletions examples/cookbook/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
"typecheck": "tsc --noEmit"
},
"dependencies": {
"axios": "^1.7.5",
"expo": "^51.0.26",
"expo-constants": "~16.0.2",
"expo-linking": "~6.3.1",
Expand Down
Loading