Skip to content
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

docs(cookbook): network requests recipes #1655

Merged
merged 13 commits into from
Sep 18, 2024
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: 'advanced/' },
];
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(() => {
Copy link
Member

Choose a reason for hiding this comment

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

thinking out loud, perhaps we should showcase usage with TanStack Query instead of manual promise fetching. Implementing data fetching using useEffect to avoid race conditions is verbose. Wdyt @vanGalilea ?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Showcase is a very good idea, as it is become and industry standard the last years.
With my approach I intended to keep things more straight-forward.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Let me add it up, this week 👍🏻 In the meanwhile, you can review the rest ;)

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;
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { render, screen, waitForElementToBeRemoved } from '@testing-library/react-native';
import React from 'react';
import PhoneBook from '../PhoneBook';
import {
mockServerFailureForGetAllContacts,
mockServerFailureForGetAllFavorites,
} from './test-utils';

describe('PhoneBook', () => {
it('fetches all contacts and favorites successfully and renders lists in sections correctly', async () => {
Copy link
Member

Choose a reason for hiding this comment

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

should the be a mock call here? Seems like the timeout might be caused by different test order execution between the test runs locally and on the CI.

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: ida.kristensen@example.com')).toBeOnTheScreen();
expect(await screen.findAllByText(/name/i)).toHaveLength(3);
expect(await screen.findByText(/my favorites/i)).toBeOnTheScreen();
expect(await screen.findAllByLabelText('favorite-contact-avatar')).toHaveLength(3);
});

it('fails to fetch all contacts and renders error message', async () => {
mockServerFailureForGetAllContacts();
render(<PhoneBook />);

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

it('fails to fetch favorites and renders error message', async () => {
mockServerFailureForGetAllFavorites();
render(<PhoneBook />);

await waitForElementToBeRemoved(() => screen.getByText(/figuring out your favorites/i));
expect(await screen.findByText(/error fetching favorites/i)).toBeOnTheScreen();
});
});
109 changes: 109 additions & 0 deletions examples/cookbook/app/network-requests/__tests__/test-utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
import { User } from '../types';
import {http, HttpResponse} from "msw";
import {setupServer} from "msw/node";

// Define request handlers and response resolvers for random user API.
// By default, we always return the happy path response.
const handlers = [
http.get('https://randomuser.me/api/*', () => {
return HttpResponse.json(DATA);
}),
];

export const server = setupServer(...handlers);

export const mockServerFailureForGetAllContacts = () => {
server.use(
http.get('https://randomuser.me/api/', ({ request }) => {
// Construct a URL instance out of the intercepted request.
const url = new URL(request.url);
// Read the "results" URL query parameter using the "URLSearchParams" API.
const resultsLength = url.searchParams.get('results');
// Simulate a server error for the get all contacts request.
// We check if the "results" query parameter is set to "25"
// to know it's the correct request to mock, in our case get all contacts.
if (resultsLength === '25') {
vanGalilea marked this conversation as resolved.
Show resolved Hide resolved
return new HttpResponse(null, { status: 500 });
}

return HttpResponse.json(DATA);
}),
);
};

export const mockServerFailureForGetAllFavorites = () => {
server.use(
http.get('https://randomuser.me/api/', ({ request }) => {
// Construct a URL instance out of the intercepted request.
const url = new URL(request.url);
// Read the "results" URL query parameter using the "URLSearchParams" API.
const resultsLength = url.searchParams.get('results');
// Simulate a server error for the get all favorites request.
// We check if the "results" query parameter is set to "10"
// to know it's the correct request to mock, in our case get all favorites.
if (resultsLength === '10') {
return new HttpResponse(null, { status: 500 });
}

return HttpResponse.json(DATA);
}),
);
};
export const DATA: { results: User[] } = {
vanGalilea marked this conversation as resolved.
Show resolved Hide resolved
results: [
{
name: {
title: 'Mrs',
first: 'Ida',
last: 'Kristensen',
},
email: 'ida.kristensen@example.com',
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: 'elijah.ellis@example.com',
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: 'miro.halko@example.com',
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;
};
10 changes: 10 additions & 0 deletions examples/cookbook/app/network-requests/api/getAllFavorites.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=10');
if (!res.ok) {
throw new Error(`Error fetching favorites`);
}
const json = await res.json();
return json.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;
};
8 changes: 8 additions & 0 deletions examples/cookbook/jest-setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,14 @@

// Import built-in Jest matchers
import '@testing-library/react-native/extend-expect';
import { server } from './app/network-requests/__tests__/test-utils';

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

// Enable API mocking via Mock Service Worker (MSW)
beforeAll(() => server.listen());
// Reset any runtime request handlers we may add during the tests
afterEach(() => server.resetHandlers());
// Disable API mocking after the tests are done
afterAll(() => server.close());
Loading
Loading