├── .env
├── .github
└── workflows
│ └── CI.yml
├── .gitignore
├── .prettierrc
├── README.md
├── jsconfig.json
├── package.json
├── public
├── favicon.ico
├── index.html
├── logo192.png
├── logo512.png
├── manifest.json
└── robots.txt
├── src
├── __test__
│ └── app.test.js
├── app.js
├── components
│ ├── index.js
│ ├── profile.js
│ └── repo-list.js
├── index.js
├── reportWebVitals.js
├── screens
│ ├── __test__
│ │ └── app.test.js
│ └── app.js
├── serviceWorker.js
├── setupTests.js
├── test
│ ├── generate.js
│ ├── intersection-observer-test-utils.js
│ ├── server
│ │ ├── index.js
│ │ ├── server-handlers.js
│ │ └── test-server.js
│ └── test-utils.js
├── theme.js
└── utils
│ ├── __test__
│ ├── api-client.test.js
│ ├── hooks.test.js
│ └── validation.test.js
│ ├── api-client.js
│ ├── hooks.js
│ └── validation.js
└── yarn.lock
/.env:
--------------------------------------------------------------------------------
1 | REACT_APP_API_URL=https://api.github.com
2 |
--------------------------------------------------------------------------------
/.github/workflows/CI.yml:
--------------------------------------------------------------------------------
1 | name: CI dev
2 |
3 | on:
4 | pull_request:
5 | branches:
6 | - main
7 |
8 | jobs:
9 | test:
10 | runs-on: ubuntu-latest
11 |
12 | strategy:
13 | matrix:
14 | node-version: [16.x]
15 |
16 | steps:
17 | - uses: actions/checkout@v3
18 |
19 | - name: Use Node.js ${{ matrix.node-version }}
20 | uses: actions/setup-node@v3
21 | with:
22 | node-version: ${{ matrix.node-version }}
23 |
24 | - name: Get yarn cache directory
25 | id: yarn-cache-dir-path
26 | run: echo "::set-output name=dir::$(yarn cache dir)"
27 |
28 | - name: Use yarn cache
29 | uses: actions/cache@v3
30 | id: yarn-cache
31 | with:
32 | path: ${{ steps.yarn-cache-dir-path.outputs.dir }}
33 | key: ${{ runner.os }}-yarn-${{ matrix.node-version }}-${{ hashFiles('**/yarn.lock') }}
34 |
35 | - name: Install dependencies
36 | run: yarn install --prefer-offline --frozen-lockfile
37 |
38 | # - name: Run linter
39 | # run: yarn lint
40 |
41 | - name: Run test
42 | run: yarn test
43 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
2 |
3 | # dependencies
4 | /node_modules
5 | /.pnp
6 | .pnp.js
7 |
8 | # testing
9 | /coverage
10 |
11 | # production
12 | /build
13 |
14 | # misc
15 | .DS_Store
16 | .env.local
17 | .env.development.local
18 | .env.test.local
19 | .env.production.local
20 |
21 | npm-debug.log*
22 | yarn-debug.log*
23 | yarn-error.log*
24 |
--------------------------------------------------------------------------------
/.prettierrc:
--------------------------------------------------------------------------------
1 | {
2 | "arrowParens": "avoid",
3 | "trailingComma": "es5",
4 | "singleQuote": true,
5 | "semi": true
6 | }
7 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # GitHub Profile
2 |
3 | Simple application to display information from GitHub users.
4 |
5 | ## User Stories
6 |
7 | - [x] User can enter a username.
8 | - [x] User should get an alert if the username is not valid.
9 | - [x] User should get an alert if the username not found.
10 | - [x] User can see the profile data such as avatar, name, and username.
11 | - [x] User can see list repositories.
12 | - [x] User can see message if the repositories is empty.
13 | - [x] User scroll down to see all other repositories.
14 |
15 | ## Available Scripts
16 |
17 | In the project directory, you can run:
18 |
19 | - `yarn dev`: Runs the app in the development mode. Open [http://localhost:3000](http://localhost:3000) to view it in the browser.
20 | - `yarn test`: Launches the test runner in the interactive watch mode.
21 | - `yarn build`: Builds the app for production to the `build` folder. It correctly bundles React in production mode and optimizes the build for the best performance.
22 |
--------------------------------------------------------------------------------
/jsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "baseUrl": "src"
4 | },
5 | "include": [
6 | "src"
7 | ]
8 | }
9 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "react-github-profile",
3 | "version": "1.0.0",
4 | "author": "@iamyuu",
5 | "license": "MIT",
6 | "main": "./src/screens/app",
7 | "repository": {
8 | "type": "git",
9 | "url": "https://github.com/iamyuu/react-github-profile"
10 | },
11 | "dependencies": {
12 | "@chakra-ui/icons": "1.0.1",
13 | "@chakra-ui/react": "1.0.0",
14 | "@emotion/react": "11.0.0",
15 | "@emotion/styled": "11.0.0",
16 | "@testing-library/jest-dom": "5.11.6",
17 | "@testing-library/react": "11.2.2",
18 | "@testing-library/react-hooks": "3.4.2",
19 | "@testing-library/user-event": "12.2.2",
20 | "faker": "5.1.0",
21 | "framer-motion": "2.9.4",
22 | "msw": "0.22.3",
23 | "prettier": "2.2.1",
24 | "react": "17.0.1",
25 | "react-dom": "17.0.1",
26 | "react-error-boundary": "3.0.2",
27 | "react-icons": "3.0.0",
28 | "react-scripts": "4.0.1",
29 | "react-test-renderer": "17.0.1",
30 | "swr": "0.3.9",
31 | "web-vitals": "0.2.2"
32 | },
33 | "scripts": {
34 | "start": "react-scripts start",
35 | "build": "react-scripts build",
36 | "test": "react-scripts test",
37 | "eject": "react-scripts eject"
38 | },
39 | "eslintConfig": {
40 | "extends": "react-app"
41 | },
42 | "browserslist": {
43 | "production": [
44 | ">0.2%",
45 | "not dead",
46 | "not op_mini all"
47 | ],
48 | "development": [
49 | "last 1 chrome version",
50 | "last 1 firefox version",
51 | "last 1 safari version"
52 | ]
53 | },
54 | "jest": {
55 | "collectCoverageFrom": [
56 | "src/**/*.{js,jsx,ts,tsx}",
57 | "!src/**/*.d.ts",
58 | "!src/{index,reportWebVitals,serviceWorker}.js"
59 | ]
60 | }
61 | }
62 |
--------------------------------------------------------------------------------
/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/iamyuu/react-github-profile/19b2fa9829ac43c52758f58980e82d63ba91481c/public/favicon.ico
--------------------------------------------------------------------------------
/public/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
12 |
13 |
17 |
18 |
27 | Github Profile
28 |
29 |
30 |
31 |
32 |
42 |
43 |
44 |
--------------------------------------------------------------------------------
/public/logo192.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/iamyuu/react-github-profile/19b2fa9829ac43c52758f58980e82d63ba91481c/public/logo192.png
--------------------------------------------------------------------------------
/public/logo512.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/iamyuu/react-github-profile/19b2fa9829ac43c52758f58980e82d63ba91481c/public/logo512.png
--------------------------------------------------------------------------------
/public/manifest.json:
--------------------------------------------------------------------------------
1 | {
2 | "short_name": "React App",
3 | "name": "Create React App Sample",
4 | "icons": [
5 | {
6 | "src": "favicon.ico",
7 | "sizes": "64x64 32x32 24x24 16x16",
8 | "type": "image/x-icon"
9 | },
10 | {
11 | "src": "logo192.png",
12 | "type": "image/png",
13 | "sizes": "192x192"
14 | },
15 | {
16 | "src": "logo512.png",
17 | "type": "image/png",
18 | "sizes": "512x512"
19 | }
20 | ],
21 | "start_url": ".",
22 | "display": "standalone",
23 | "theme_color": "#000000",
24 | "background_color": "#ffffff"
25 | }
26 |
--------------------------------------------------------------------------------
/public/robots.txt:
--------------------------------------------------------------------------------
1 | # https://www.robotstxt.org/robotstxt.html
2 | User-agent: *
3 | Disallow:
4 |
--------------------------------------------------------------------------------
/src/__test__/app.test.js:
--------------------------------------------------------------------------------
1 | import { render, screen } from '@testing-library/react';
2 | import AppWrapper from 'app';
3 |
4 | test('renders AppWrapper correctly', () => {
5 | render();
6 |
7 | expect(screen.getByRole('searchbox')).toBeVisible();
8 | });
9 |
--------------------------------------------------------------------------------
/src/app.js:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import { SWRConfig } from 'swr';
3 | import { ChakraProvider } from '@chakra-ui/react';
4 | import theme from 'theme';
5 | import client from 'utils/api-client';
6 | import AppScreen from 'screens/app';
7 |
8 | const swrConfig = {
9 | fetcher: (...args) => client(...args),
10 | suspense: true,
11 | };
12 |
13 | export function Provider({ children }) {
14 | return (
15 |
16 | {children}
17 |
18 | );
19 | }
20 |
21 | export default function AppWrapper() {
22 | return (
23 |
24 |
25 |
26 | );
27 | }
28 |
--------------------------------------------------------------------------------
/src/components/index.js:
--------------------------------------------------------------------------------
1 | export * from './profile';
2 | export * from './repo-list';
3 |
--------------------------------------------------------------------------------
/src/components/profile.js:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import useSWR from 'swr';
3 | import {
4 | Flex,
5 | Heading,
6 | Text,
7 | Link,
8 | Avatar,
9 | Box,
10 | Skeleton,
11 | SkeletonCircle,
12 | } from '@chakra-ui/react';
13 | import { username as validateUsername } from 'utils/validation';
14 |
15 | export function ProfileFallback() {
16 | return (
17 |
18 |
19 |
20 |
21 |
22 | );
23 | }
24 |
25 | export default function ProfileDataView({ username }) {
26 | const { isValid, error } = validateUsername(username);
27 | const { data: profile, error: apiError } = useSWR(
28 | isValid ? `/users/${username}` : null
29 | );
30 |
31 | // TODO: Test this use case
32 | if (error || apiError) {
33 | throw error || apiError;
34 | }
35 |
36 | return (
37 |
38 |
45 |
46 |
47 |
48 |
49 | {profile.name}
50 |
51 |
52 | (@{profile.login})
53 |
54 |
55 |
56 |
57 |
58 | {profile.bio}
59 |
60 |
61 | );
62 | }
63 |
--------------------------------------------------------------------------------
/src/components/repo-list.js:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import {
3 | Box,
4 | Wrap,
5 | WrapItem,
6 | Spacer,
7 | Heading,
8 | Text,
9 | Link,
10 | Badge,
11 | Spinner,
12 | SimpleGrid,
13 | } from '@chakra-ui/react';
14 | import { username as validateUsername } from 'utils/validation';
15 | import { useInfiniteScroll } from 'utils/hooks';
16 |
17 | export function ReposFallback() {
18 | return (
19 |
20 |
21 |
22 | );
23 | }
24 |
25 | function ReposEmptyState() {
26 | return User doesn't have any repositories yet;
27 | }
28 |
29 | export default function ReposDataView({ username }) {
30 | const { isValid, error } = validateUsername(username);
31 | const {
32 | ref,
33 | items: repos,
34 | error: apiError,
35 | isEmpty,
36 | isLoadingMore,
37 | isReachingEnd,
38 | } = useInfiniteScroll(isValid ? `/users/${username}/repos` : null);
39 |
40 | if (error || apiError) {
41 | throw error || apiError;
42 | }
43 |
44 | if (isEmpty) {
45 | return ;
46 | }
47 |
48 | return (
49 | <>
50 |
51 | {repos.map(repo => (
52 |
61 |
62 |
69 |
70 | {repo.stargazers_count} Star
71 |
72 | •
73 |
74 | {repo.open_issues_count} issues
75 |
76 | •
77 |
78 | {repo.forks_count} fork
79 |
80 |
81 |
82 |
83 |
84 | {repo.language && (
85 |
86 |
87 | {repo.language}
88 |
89 |
90 | )}
91 |
92 |
93 |
100 |
101 | {repo.name}
102 |
103 |
104 |
105 | {repo.description}
106 |
107 | ))}
108 |
109 |
110 |
111 | {!isReachingEnd && isLoadingMore ? : null}
112 |
113 | >
114 | );
115 | }
116 |
--------------------------------------------------------------------------------
/src/index.js:
--------------------------------------------------------------------------------
1 | import React, { StrictMode } from 'react';
2 | import ReactDOM from 'react-dom';
3 | import App from './app';
4 | import reportWebVitals from './reportWebVitals';
5 | import * as serviceWorker from './serviceWorker';
6 |
7 | ReactDOM.render(
8 |
9 |
10 | ,
11 | document.getElementById('root')
12 | );
13 |
14 | serviceWorker.register();
15 |
16 | reportWebVitals();
17 |
--------------------------------------------------------------------------------
/src/reportWebVitals.js:
--------------------------------------------------------------------------------
1 | const reportWebVitals = onPerfEntry => {
2 | if (onPerfEntry && onPerfEntry instanceof Function) {
3 | import('web-vitals').then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => {
4 | getCLS(onPerfEntry);
5 | getFID(onPerfEntry);
6 | getFCP(onPerfEntry);
7 | getLCP(onPerfEntry);
8 | getTTFB(onPerfEntry);
9 | });
10 | }
11 | };
12 |
13 | export default reportWebVitals;
14 |
--------------------------------------------------------------------------------
/src/screens/__test__/app.test.js:
--------------------------------------------------------------------------------
1 | import { render, screen, waitForLoadingToFinish, act } from 'test/test-utils';
2 | import { server, rest } from 'test/server';
3 | import userEvent from '@testing-library/user-event';
4 | import App from 'screens/app';
5 | import { mockAllIsIntersecting } from 'test/intersection-observer-test-utils';
6 |
7 | const API_URL = process.env.REACT_APP_API_URL;
8 |
9 | const renderAppScreen = async ({ username } = {}) => {
10 | jest.useFakeTimers();
11 |
12 | const utils = render();
13 |
14 | mockAllIsIntersecting(true);
15 |
16 | if (!username) {
17 | username = 'USERNAME';
18 | }
19 |
20 | userEvent.type(screen.getByRole('searchbox'), username);
21 | act(() => jest.advanceTimersByTime(510));
22 |
23 | await waitForLoadingToFinish();
24 |
25 | return { ...utils, username };
26 | };
27 |
28 | test('renders the app screens', () => {
29 | render();
30 |
31 | expect(screen.getByRole('searchbox')).toBeInTheDocument();
32 | expect(screen.getByText(/find your github profile/i)).toBeInTheDocument();
33 | });
34 |
35 | describe('console error', () => {
36 | beforeAll(() => {
37 | jest.spyOn(console, 'error').mockImplementation(() => {});
38 | });
39 |
40 | afterAll(() => {
41 | console.error.mockRestore();
42 | });
43 |
44 | test('get an alert if the username is not valid', async () => {
45 | await renderAppScreen({ username: 'x$y%z' });
46 |
47 | expect(screen.getByRole('alert')).toHaveTextContent(/invalid username/i);
48 | });
49 |
50 | test('get an alert if the username not found', async () => {
51 | await renderAppScreen({ username: '404' });
52 |
53 | expect(
54 | screen.getByRole('alert').querySelector('.chakra-alert__title')
55 | .textContent
56 | ).toMatchInlineSnapshot(`"Not Found!"`);
57 | });
58 | });
59 |
60 | test('see profile', async () => {
61 | const { username } = await renderAppScreen();
62 |
63 | expect(screen.getByLabelText('avatar')).toBeInTheDocument();
64 | expect(screen.getByLabelText('name')).toBeInTheDocument();
65 | expect(screen.getByLabelText('username')).toBeInTheDocument();
66 | expect(screen.getByLabelText('username').textContent).toBe(`(@${username})`);
67 | expect(screen.getByLabelText('bio')).toBeInTheDocument();
68 | });
69 |
70 | test('see message if the repositories is empty', async () => {
71 | server.use(
72 | rest.get(`${API_URL}/users/:username/repos`, (req, res, ctx) => {
73 | return res(ctx.json([]));
74 | })
75 | );
76 |
77 | await renderAppScreen();
78 |
79 | expect(screen.getByRole('alert').textContent).toMatchInlineSnapshot(
80 | `"User doesn't have any repositories yet"`
81 | );
82 | });
83 |
84 | test('see list repositories', async () => {
85 | await renderAppScreen();
86 |
87 | expect(screen.getAllByLabelText('star count')[0]).toBeInTheDocument();
88 | expect(screen.getAllByLabelText('issues count')[0]).toBeInTheDocument();
89 | expect(screen.getAllByLabelText('fork count')[0]).toBeInTheDocument();
90 | expect(screen.getAllByLabelText('language')[0]).toBeInTheDocument();
91 | expect(screen.getAllByLabelText('repo name')[0]).toBeInTheDocument();
92 | expect(screen.getAllByLabelText('repo description')[0]).toBeInTheDocument();
93 | });
94 |
95 | test.todo('scroll down to see all other repositories');
96 |
--------------------------------------------------------------------------------
/src/screens/app.js:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import { ErrorBoundary } from 'react-error-boundary';
3 | import {
4 | Container,
5 | Box,
6 | Text,
7 | Alert,
8 | AlertIcon,
9 | AlertTitle,
10 | AlertDescription,
11 | InputGroup,
12 | Input,
13 | InputRightElement,
14 | } from '@chakra-ui/react';
15 | import { SearchIcon } from '@chakra-ui/icons';
16 | import { ProfileFallback, ReposFallback } from 'components';
17 | import { useDebounce } from 'utils/hooks';
18 |
19 | const ProfileDataView = React.lazy(() => import('components/profile'));
20 | const ReposDataView = React.lazy(() => import('components/repo-list'));
21 |
22 | function ErrorFallback({ error }) {
23 | return (
24 |
25 |
26 | {error.message}!
27 | Try another username
28 |
29 | );
30 | }
31 |
32 | function EmptyState() {
33 | return Find your github profile;
34 | }
35 |
36 | export default function App() {
37 | const [username, setUsername] = React.useState('');
38 | const debouncedUsername = useDebounce(username);
39 |
40 | function handleSearch(event) {
41 | setUsername(event.target.value);
42 | }
43 |
44 | return (
45 |
46 |
47 |
48 |
56 | } />
57 |
58 |
59 |
60 | {debouncedUsername ? (
61 |
65 |
66 | }>
67 |
68 |
69 |
70 |
71 | }>
72 |
73 |
74 |
75 | ) : (
76 |
77 | )}
78 |
79 | );
80 | }
81 |
--------------------------------------------------------------------------------
/src/serviceWorker.js:
--------------------------------------------------------------------------------
1 | // This optional code is used to register a service worker.
2 | // register() is not called by default.
3 |
4 | // This lets the app load faster on subsequent visits in production, and gives
5 | // it offline capabilities. However, it also means that developers (and users)
6 | // will only see deployed updates on subsequent visits to a page, after all the
7 | // existing tabs open on the page have been closed, since previously cached
8 | // resources are updated in the background.
9 |
10 | // To learn more about the benefits of this model and instructions on how to
11 | // opt-in, read https://cra.link/PWA
12 |
13 | const isLocalhost = Boolean(
14 | window.location.hostname === 'localhost' ||
15 | // [::1] is the IPv6 localhost address.
16 | window.location.hostname === '[::1]' ||
17 | // 127.0.0.0/8 are considered localhost for IPv4.
18 | window.location.hostname.match(
19 | /^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/
20 | )
21 | );
22 |
23 | export function register(config) {
24 | if (process.env.NODE_ENV === 'production' && 'serviceWorker' in navigator) {
25 | // The URL constructor is available in all browsers that support SW.
26 | const publicUrl = new URL(process.env.PUBLIC_URL, window.location.href);
27 | if (publicUrl.origin !== window.location.origin) {
28 | // Our service worker won't work if PUBLIC_URL is on a different origin
29 | // from what our page is served on. This might happen if a CDN is used to
30 | // serve assets; see https://github.com/facebook/create-react-app/issues/2374
31 | return;
32 | }
33 |
34 | window.addEventListener('load', () => {
35 | const swUrl = `${process.env.PUBLIC_URL}/service-worker.js`;
36 |
37 | if (isLocalhost) {
38 | // This is running on localhost. Let's check if a service worker still exists or not.
39 | checkValidServiceWorker(swUrl, config);
40 |
41 | // Add some additional logging to localhost, pointing developers to the
42 | // service worker/PWA documentation.
43 | navigator.serviceWorker.ready.then(() => {
44 | console.log(
45 | 'This web app is being served cache-first by a service ' +
46 | 'worker. To learn more, visit https://cra.link/PWA'
47 | );
48 | });
49 | } else {
50 | // Is not localhost. Just register service worker
51 | registerValidSW(swUrl, config);
52 | }
53 | });
54 | }
55 | }
56 |
57 | function registerValidSW(swUrl, config) {
58 | navigator.serviceWorker
59 | .register(swUrl)
60 | .then(registration => {
61 | registration.onupdatefound = () => {
62 | const installingWorker = registration.installing;
63 | if (installingWorker == null) {
64 | return;
65 | }
66 | installingWorker.onstatechange = () => {
67 | if (installingWorker.state === 'installed') {
68 | if (navigator.serviceWorker.controller) {
69 | // At this point, the updated precached content has been fetched,
70 | // but the previous service worker will still serve the older
71 | // content until all client tabs are closed.
72 | console.log(
73 | 'New content is available and will be used when all ' +
74 | 'tabs for this page are closed. See https://cra.link/PWA.'
75 | );
76 |
77 | // Execute callback
78 | if (config && config.onUpdate) {
79 | config.onUpdate(registration);
80 | }
81 | } else {
82 | // At this point, everything has been precached.
83 | // It is the perfect time to display a
84 | // "Content is cached for offline use." message.
85 | console.log('Content is cached for offline use.');
86 |
87 | // Execute callback
88 | if (config && config.onSuccess) {
89 | config.onSuccess(registration);
90 | }
91 | }
92 | }
93 | };
94 | };
95 | })
96 | .catch(error => {
97 | console.error('Error during service worker registration:', error);
98 | });
99 | }
100 |
101 | function checkValidServiceWorker(swUrl, config) {
102 | // Check if the service worker can be found. If it can't reload the page.
103 | fetch(swUrl, {
104 | headers: { 'Service-Worker': 'script' },
105 | })
106 | .then(response => {
107 | // Ensure service worker exists, and that we really are getting a JS file.
108 | const contentType = response.headers.get('content-type');
109 | if (
110 | response.status === 404 ||
111 | (contentType != null && contentType.indexOf('javascript') === -1)
112 | ) {
113 | // No service worker found. Probably a different app. Reload the page.
114 | navigator.serviceWorker.ready.then(registration => {
115 | registration.unregister().then(() => {
116 | window.location.reload();
117 | });
118 | });
119 | } else {
120 | // Service worker found. Proceed as normal.
121 | registerValidSW(swUrl, config);
122 | }
123 | })
124 | .catch(() => {
125 | console.log(
126 | 'No internet connection found. App is running in offline mode.'
127 | );
128 | });
129 | }
130 |
131 | export function unregister() {
132 | if ('serviceWorker' in navigator) {
133 | navigator.serviceWorker.ready
134 | .then(registration => {
135 | registration.unregister();
136 | })
137 | .catch(error => {
138 | console.error(error.message);
139 | });
140 | }
141 | }
142 |
--------------------------------------------------------------------------------
/src/setupTests.js:
--------------------------------------------------------------------------------
1 | import '@testing-library/jest-dom/extend-expect';
2 | import { cache } from 'swr';
3 | import { server } from 'test/server';
4 |
5 | // enable API mocking in test runs using the same request handlers
6 | // as for the client-side mocking.
7 | beforeAll(() => server.listen());
8 | afterAll(() => server.close());
9 | afterEach(() => server.resetHandlers());
10 |
11 | // general cleanup
12 | afterEach(() => {
13 | cache.clear();
14 | });
15 |
--------------------------------------------------------------------------------
/src/test/generate.js:
--------------------------------------------------------------------------------
1 | import faker from 'faker';
2 |
3 | export const buildProfile = overrides => ({
4 | name: faker.name.firstName(),
5 | login: faker.internet.userName(),
6 | avatar_url: faker.image.people(),
7 | html_url: faker.internet.url(),
8 | bio: faker.random.words(),
9 | ...overrides,
10 | });
11 |
12 | export const buildRepo = overrides => ({
13 | id: faker.random.uuid(),
14 | name: faker.name.title(),
15 | description: faker.random.words(),
16 | stargazers_count: faker.random.number(),
17 | open_issues_count: faker.random.number(),
18 | forks_count: faker.random.number(),
19 | language: faker.random.alpha(),
20 | html_url: faker.internet.url(),
21 | ...overrides,
22 | });
23 |
--------------------------------------------------------------------------------
/src/test/intersection-observer-test-utils.js:
--------------------------------------------------------------------------------
1 | import { act } from 'react-dom/test-utils';
2 |
3 | const observers = new Map();
4 |
5 | beforeEach(() => {
6 | /**
7 | * Create a custom IntersectionObserver mock, allowing us to intercept the observe and unobserve calls.
8 | * We keep track of the elements being observed, so when `mockAllIsIntersecting` is triggered it will
9 | * know which elements to trigger the event on.
10 | */
11 | global.IntersectionObserver = jest.fn((cb, options = {}) => {
12 | const item = {
13 | callback: cb,
14 | elements: new Set(),
15 | created: Date.now(),
16 | };
17 | const instance = {
18 | thresholds: Array.isArray(options.threshold)
19 | ? options.threshold
20 | : [options.threshold ?? 0],
21 | root: options.root ?? null,
22 | rootMargin: options.rootMargin ?? '',
23 | observe: jest.fn(element => {
24 | item.elements.add(element);
25 | }),
26 | unobserve: jest.fn(element => {
27 | item.elements.delete(element);
28 | }),
29 | disconnect: jest.fn(() => {
30 | observers.delete(instance);
31 | }),
32 | takeRecords: jest.fn(),
33 | };
34 |
35 | observers.set(instance, item);
36 |
37 | return instance;
38 | });
39 | });
40 |
41 | afterEach(() => {
42 | // @ts-ignore
43 | global.IntersectionObserver.mockClear();
44 | observers.clear();
45 | });
46 |
47 | function triggerIntersection(elements, trigger, observer, item) {
48 | const entries = [];
49 |
50 | const isIntersecting =
51 | typeof trigger === 'number'
52 | ? observer.thresholds.some(threshold => trigger >= threshold)
53 | : trigger;
54 |
55 | const ratio =
56 | typeof trigger === 'number'
57 | ? observer.thresholds.find(threshold => trigger >= threshold) ?? 0
58 | : trigger
59 | ? 1
60 | : 0;
61 |
62 | elements.forEach(element => {
63 | entries.push({
64 | boundingClientRect: element.getBoundingClientRect(),
65 | intersectionRatio: ratio,
66 | intersectionRect: isIntersecting
67 | ? element.getBoundingClientRect()
68 | : {
69 | bottom: 0,
70 | height: 0,
71 | left: 0,
72 | right: 0,
73 | top: 0,
74 | width: 0,
75 | x: 0,
76 | y: 0,
77 | toJSON() {},
78 | },
79 | isIntersecting,
80 | rootBounds: observer.root ? observer.root.getBoundingClientRect() : null,
81 | target: element,
82 | time: Date.now() - item.created,
83 | });
84 | });
85 |
86 | // Trigger the IntersectionObserver callback with all the entries
87 | if (act) act(() => item.callback(entries, observer));
88 | else item.callback(entries, observer);
89 | }
90 |
91 | /**
92 | * Set the `isIntersecting` on all current IntersectionObserver instances
93 | * @param isIntersecting {boolean | number}
94 | */
95 | export function mockAllIsIntersecting(isIntersecting) {
96 | for (let [observer, item] of observers) {
97 | triggerIntersection(
98 | Array.from(item.elements),
99 | isIntersecting,
100 | observer,
101 | item
102 | );
103 | }
104 | }
105 |
106 | /**
107 | * Set the `isIntersecting` for the IntersectionObserver of a specific element.
108 | *
109 | * @param element {Element}
110 | * @param isIntersecting {boolean | number}
111 | */
112 | export function mockIsIntersecting(element, isIntersecting) {
113 | const observer = intersectionMockInstance(element);
114 | if (!observer) {
115 | throw new Error(
116 | 'No IntersectionObserver instance found for element. Is it still mounted in the DOM?'
117 | );
118 | }
119 | const item = observers.get(observer);
120 | if (item) {
121 | triggerIntersection([element], isIntersecting, observer, item);
122 | }
123 | }
124 |
125 | /**
126 | * Call the `intersectionMockInstance` method with an element, to get the (mocked)
127 | * `IntersectionObserver` instance. You can use this to spy on the `observe` and
128 | * `unobserve` methods.
129 | * @param element {Element}
130 | * @return IntersectionObserver
131 | */
132 | export function intersectionMockInstance(element) {
133 | for (let [observer, item] of observers) {
134 | if (item.elements.has(element)) {
135 | return observer;
136 | }
137 | }
138 |
139 | throw new Error(
140 | 'Failed to find IntersectionObserver for element. Is it being observer?'
141 | );
142 | }
143 |
--------------------------------------------------------------------------------
/src/test/server/index.js:
--------------------------------------------------------------------------------
1 | module.exports = require('./test-server');
2 |
--------------------------------------------------------------------------------
/src/test/server/server-handlers.js:
--------------------------------------------------------------------------------
1 | import { rest } from 'msw';
2 | import { buildProfile, buildRepo } from 'test/generate';
3 |
4 | const API_URL = process.env.REACT_APP_API_URL;
5 |
6 | export const handlers = [
7 | rest.get(`${API_URL}/users/:username`, (req, res, ctx) => {
8 | const { username } = req.params;
9 |
10 | if (username === '404') {
11 | return res(ctx.status(404), ctx.json({ message: 'Not Found' }));
12 | }
13 |
14 | const profile = buildProfile({ login: username });
15 |
16 | return res(ctx.json(profile));
17 | }),
18 | rest.get(`${API_URL}/users/:username/repos`, (req, res, ctx) => {
19 | const { username } = req.params;
20 |
21 | if (username === '404') {
22 | return res(ctx.status(404), ctx.json({ message: 'Not Found' }));
23 | }
24 |
25 | return res(
26 | ctx.json([
27 | buildRepo(),
28 | buildRepo({ language: null }),
29 | buildRepo({ description: null }),
30 | ])
31 | );
32 | }),
33 | ];
34 |
--------------------------------------------------------------------------------
/src/test/server/test-server.js:
--------------------------------------------------------------------------------
1 | import { setupServer } from 'msw/node';
2 | import { handlers } from './server-handlers';
3 |
4 | export * from 'msw';
5 | export const server = setupServer(...handlers);
6 |
--------------------------------------------------------------------------------
/src/test/test-utils.js:
--------------------------------------------------------------------------------
1 | import {
2 | render as rtlRender,
3 | screen,
4 | waitForElementToBeRemoved,
5 | } from '@testing-library/react';
6 | import { Provider } from 'app';
7 |
8 | export * from '@testing-library/react';
9 |
10 | export const render = (ui, options) =>
11 | rtlRender(ui, { wrapper: Provider, ...options });
12 |
13 | export const waitForLoadingToFinish = () =>
14 | waitForElementToBeRemoved(
15 | () => [
16 | ...screen.queryAllByLabelText(/loading/i),
17 | ...screen.queryAllByText(/loading/i),
18 | ],
19 | { timeout: 4000 }
20 | );
21 |
--------------------------------------------------------------------------------
/src/theme.js:
--------------------------------------------------------------------------------
1 | import { extendTheme } from '@chakra-ui/react';
2 |
3 | const config = {
4 | useSystemColorMode: false,
5 | initialColorMode: 'dark',
6 | };
7 |
8 | export default extendTheme({ config });
9 |
--------------------------------------------------------------------------------
/src/utils/__test__/api-client.test.js:
--------------------------------------------------------------------------------
1 | import client from '../api-client';
2 |
3 | describe('api-client', () => {
4 | test(`calls fetch at the endpoint with the arguments for GET requests`, async () => {
5 | const endpoint = 'test-endpoint';
6 | const mockResult = { mockValue: 'VALUE' };
7 |
8 | const originalFetch = window.fetch;
9 | window.fetch = async (url, config) => {
10 | if (url.endsWith(endpoint)) {
11 | return {
12 | ok: true,
13 | json: async () => mockResult,
14 | };
15 | }
16 |
17 | return originalFetch(url, config);
18 | };
19 |
20 | const result = await client(endpoint);
21 |
22 | expect(result).toEqual(mockResult);
23 | });
24 |
25 | test(`allows for config overrides`, async () => {
26 | let request;
27 | const endpoint = 'test-endpoint';
28 | const mockResult = { mockValue: 'VALUE' };
29 |
30 | const originalFetch = window.fetch;
31 | window.fetch = async (url, config) => {
32 | request = config;
33 | if (url.endsWith(endpoint)) {
34 | return {
35 | ok: true,
36 | json: async () => mockResult,
37 | };
38 | }
39 |
40 | return originalFetch(url, config);
41 | };
42 |
43 | const customConfig = {
44 | mode: 'cors',
45 | method: 'PUT',
46 | headers: { 'Content-Type': 'fake-type' },
47 | };
48 |
49 | await client(endpoint, customConfig);
50 |
51 | expect(request.mode).toBe(customConfig.mode);
52 | expect(request.method).toBe(customConfig.method);
53 | expect(request.headers['Content-Type']).toBe(
54 | customConfig.headers['Content-Type']
55 | );
56 | });
57 |
58 | test(`correctly rejects the promise if there's an error`, async () => {
59 | const endpoint = 'test-endpoint';
60 | const mockResult = { message: 'ERROR' };
61 | const originalFetch = window.fetch;
62 |
63 | window.fetch = async (url, config) => {
64 | if (url.endsWith(endpoint)) {
65 | return {
66 | ok: false,
67 | json: async () => mockResult,
68 | };
69 | }
70 |
71 | return originalFetch(url, config);
72 | };
73 |
74 | await expect(client(endpoint)).rejects.toEqual(mockResult);
75 | });
76 |
77 | test(`when data is provided, it's stringified and the method defaults to POST`, async () => {
78 | const endpoint = 'test-endpoint';
79 | const originalFetch = window.fetch;
80 |
81 | window.fetch = async (url, config) => {
82 | if (url.endsWith(endpoint)) {
83 | return {
84 | ok: true,
85 | json: async () => JSON.parse(config.body),
86 | };
87 | }
88 |
89 | return originalFetch(url, config);
90 | };
91 |
92 | const data = { a: 'b' };
93 | const result = await client(endpoint, { data });
94 |
95 | expect(result).toEqual(data);
96 | });
97 | });
98 |
--------------------------------------------------------------------------------
/src/utils/__test__/hooks.test.js:
--------------------------------------------------------------------------------
1 | import { renderHook, act } from '@testing-library/react-hooks';
2 | import { useDebounce } from '../hooks';
3 |
4 | test('update value after specified delay', () => {
5 | jest.useFakeTimers();
6 |
7 | const { result, rerender } = renderHook(
8 | ({ value, delay }) => useDebounce(value, delay),
9 | { initialProps: { value: '', delay: 500 } }
10 | );
11 |
12 | expect(result.current).toBe('');
13 | act(() => jest.advanceTimersByTime(510));
14 | expect(result.current).toBe('');
15 |
16 | rerender({ value: 'Hello World', delay: 500 });
17 |
18 | expect(result.current).toBe('');
19 | act(() => jest.advanceTimersByTime(498));
20 | expect(result.current).toBe('');
21 |
22 | act(() => jest.advanceTimersByTime(3));
23 | expect(result.current).toBe('Hello World');
24 | });
25 |
26 | // TODO: test intersection observer
27 | // @see https://github.com/thebuilder/react-intersection-observer/blob/master/src/test-utils.ts
28 | // @see https://github.com/thebuilder/react-intersection-observer/blob/master/src/__tests__/hooks.test.tsx
29 | test.todo('useIntersection');
30 |
--------------------------------------------------------------------------------
/src/utils/__test__/validation.test.js:
--------------------------------------------------------------------------------
1 | import { username as validateUsername } from '../validation';
2 |
3 | test('validate username with the valid username', () => {
4 | const username = 'USERNAME';
5 | const result = validateUsername(username);
6 |
7 | expect(result).toHaveProperty('isValid', true);
8 | expect(result).toHaveProperty('error', undefined);
9 | });
10 |
11 | describe('console error', () => {
12 | beforeAll(() => {
13 | jest.spyOn(console, 'error').mockImplementation(() => {});
14 | });
15 |
16 | afterAll(() => {
17 | console.error.mockRestore();
18 | });
19 |
20 | test('validate username with invalid username', () => {
21 | const username = 'INVALID_USERNAME';
22 | const result = validateUsername(username);
23 |
24 | expect(result).toHaveProperty('isValid', false);
25 | expect(result).toHaveProperty('error.message');
26 | });
27 |
28 | test('validate username without any arguments', () => {
29 | const result = validateUsername();
30 |
31 | expect(result).toHaveProperty('isValid', false);
32 | expect(result).toHaveProperty('error.message');
33 | });
34 | });
35 |
--------------------------------------------------------------------------------
/src/utils/api-client.js:
--------------------------------------------------------------------------------
1 | const API_URL = process.env.REACT_APP_API_URL;
2 |
3 | export default function client(
4 | endpoint,
5 | { data, headers: customHeaders, ...customConfig } = {}
6 | ) {
7 | const config = {
8 | method: data ? 'POST' : 'GET',
9 | body: data ? JSON.stringify(data) : undefined,
10 | headers: {
11 | 'Content-Type': data ? 'application/json' : undefined,
12 | ...customHeaders,
13 | },
14 | ...customConfig,
15 | };
16 |
17 | return window.fetch(`${API_URL}${endpoint}`, config).then(async response => {
18 | const data = await response.json();
19 | return response.ok ? data : Promise.reject(data);
20 | });
21 | }
22 |
--------------------------------------------------------------------------------
/src/utils/hooks.js:
--------------------------------------------------------------------------------
1 | import * as React from 'react';
2 | import { useSWRInfinite } from 'swr';
3 |
4 | export function useDebounce(value, delay = 500) {
5 | const [debouncedValue, setDebouncedValue] = React.useState(value);
6 |
7 | React.useEffect(() => {
8 | const handler = setTimeout(() => {
9 | setDebouncedValue(value);
10 | }, delay);
11 |
12 | return () => {
13 | clearTimeout(handler);
14 | };
15 | }, [value, delay]);
16 |
17 | return debouncedValue;
18 | }
19 |
20 | export function useIntersection({ threshold, rootMargin, root }) {
21 | const node = React.useRef(null);
22 | const [entry, setEntry] = React.useState({});
23 |
24 | React.useEffect(() => {
25 | const observer = new window.IntersectionObserver(
26 | ([entries]) => setEntry(entries),
27 | { threshold, rootMargin, root }
28 | );
29 |
30 | if (node.current) {
31 | observer.observe(node.current);
32 | }
33 |
34 | return () => {
35 | observer.disconnect();
36 | };
37 | }, [node, threshold, rootMargin, root]);
38 |
39 | return [node, entry];
40 | }
41 |
42 | export function useInfiniteScroll(
43 | endpoint,
44 | { pageSize = 10, observer: customObserver, ...swrConfig } = {}
45 | ) {
46 | const [ref, { isIntersecting }] = useIntersection({
47 | threshold: 0.75,
48 | ...customObserver,
49 | });
50 |
51 | const { data, size, setSize, ...swrResult } = useSWRInfinite(
52 | index =>
53 | endpoint ? `${endpoint}?per_page=${pageSize}&page=${index + 1}` : null,
54 | swrConfig
55 | );
56 |
57 | const items = data ? [].concat(...data) : [];
58 | const isEmpty = data?.[0]?.length === 0;
59 | const isLoadingMore =
60 | size > 0 && data && typeof data[size - 1] === 'undefined';
61 | const isReachingEnd =
62 | isEmpty || (data && data[data.length - 1].length < pageSize);
63 |
64 | React.useEffect(() => {
65 | if (isIntersecting && !isLoadingMore && !isReachingEnd) {
66 | setSize(currentSize => currentSize + 1);
67 | }
68 | }, [setSize, isIntersecting, isLoadingMore, isReachingEnd]);
69 |
70 | return { ref, items, isEmpty, isLoadingMore, isReachingEnd, ...swrResult };
71 | }
72 |
--------------------------------------------------------------------------------
/src/utils/validation.js:
--------------------------------------------------------------------------------
1 | export function username(username) {
2 | if (!username) {
3 | return {
4 | isValid: false,
5 | error: new Error('Field username is required'),
6 | };
7 | }
8 |
9 | if (!/^[a-z\d](?:[a-z\d]|-(?=[a-z\d])){0,38}$/i.test(username)) {
10 | return {
11 | isValid: false,
12 | error: new Error('Invalid username'),
13 | };
14 | }
15 |
16 | return {
17 | isValid: true,
18 | error: undefined,
19 | };
20 | }
21 |
--------------------------------------------------------------------------------