├── .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 | --------------------------------------------------------------------------------