├── .gitignore ├── .prettierrc ├── .travis.yml ├── @types └── global.d.ts ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── LICENSE.md ├── README.md ├── index.js ├── jest.config.js ├── jsconfig.json ├── package-lock.json ├── package.json ├── src ├── __tests__ │ ├── a11y.js │ ├── app-01.js │ ├── app-02.js │ ├── app-03.js │ ├── custom-hook-01.js │ ├── custom-hook-02.js │ ├── custom-hook-03.js │ ├── custom-hook-04.js │ ├── dependency-injection.js │ ├── dom-testing-library.js │ ├── error-boundary-01.js │ ├── error-boundary-02.js │ ├── error-boundary-03.js │ ├── error-boundary-04.js │ ├── http-jest-mock.js │ ├── http-msw-mock.js │ ├── jest-dom.js │ ├── mock-component.js │ ├── portals.js │ ├── prop-updates-01.js │ ├── prop-updates-02.js │ ├── react-dom.js │ ├── react-router-01.js │ ├── react-router-02.js │ ├── react-router-03.js │ ├── react-testing-library.js │ ├── redux-01.js │ ├── redux-02.js │ ├── redux-03.js │ ├── state-user-event.js │ ├── state.js │ ├── tdd-01-markup.js │ ├── tdd-02-state.js │ ├── tdd-03-api.js │ ├── tdd-04-router-redirect.js │ ├── tdd-05-dates.js │ ├── tdd-06-generate-data.js │ ├── tdd-07-error-state.js │ ├── tdd-08-custom-render.js │ └── unmounting.js ├── api.js ├── app-reach-router.js ├── app.js ├── countdown.js ├── error-boundary.js ├── favorite-number.js ├── greeting-loader-01-mocking.js ├── greeting-loader-02-dependency-injection.js ├── hidden-message.js ├── main.js ├── modal.js ├── post-editor-01-markup.js ├── post-editor-02-state.js ├── post-editor-03-api.js ├── post-editor-04-router-redirect.js ├── post-editor-05-dates.js ├── post-editor-06-generate-data.js ├── post-editor-07-error-state.js ├── post-editor-08-custom-render.js ├── redux-counter.js ├── redux-reducer.js ├── redux-store.js └── use-counter.js └── tests └── setup-env.js /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | coverage 3 | dist 4 | .opt-in 5 | .opt-out 6 | .DS_Store 7 | .eslintcache 8 | 9 | # these cause more harm than good 10 | # when working with contributors 11 | package-lock.json 12 | .eslintcache 13 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "arrowParens": "avoid", 3 | "bracketSpacing": false, 4 | "endOfLine": "lf", 5 | "htmlWhitespaceSensitivity": "css", 6 | "insertPragma": false, 7 | "jsxBracketSameLine": false, 8 | "jsxSingleQuote": false, 9 | "printWidth": 80, 10 | "proseWrap": "always", 11 | "quoteProps": "as-needed", 12 | "requirePragma": false, 13 | "semi": false, 14 | "singleQuote": true, 15 | "tabWidth": 2, 16 | "trailingComma": "all", 17 | "useTabs": false 18 | } 19 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | sudo: false 2 | language: node_js 3 | cache: 4 | directories: 5 | - ~/.npm 6 | notifications: 7 | email: false 8 | node_js: '12' 9 | install: npm install 10 | script: npm run validate 11 | after_success: npx codecov@3 12 | branches: 13 | only: master 14 | -------------------------------------------------------------------------------- /@types/global.d.ts: -------------------------------------------------------------------------------- 1 | import '@testing-library/jest-dom/extend-expect' 2 | import 'jest-axe/extend-expect' 3 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | Please refer to [kentcdodds.com/conduct/](https://kentcdodds.com/conduct/) 2 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | Thanks for being willing to contribute! 4 | 5 | **Working on your first Pull Request?** You can learn how from this _free_ 6 | series [How to Contribute to an Open Source Project on GitHub][egghead] 7 | 8 | ## Project setup 9 | 10 | 1. Fork and clone the repo 11 | 2. Run `npm run setup -s` to install dependencies and run validation 12 | 3. Create a branch for your PR with `git checkout -b pr/your-branch-name` 13 | 14 | > Tip: Keep your `master` branch pointing at the original repository and make 15 | > pull requests from branches on your fork. To do this, run: 16 | > 17 | > ``` 18 | > git remote add upstream https://github.com/kentcdodds/react-testing-library-course.git 19 | > git fetch upstream 20 | > git branch --set-upstream-to=upstream/master master 21 | > ``` 22 | > 23 | > This will add the original repository as a "remote" called "upstream," Then 24 | > fetch the git information from that remote, then set your local `master` 25 | > branch to use the upstream master branch whenever you run `git pull`. Then you 26 | > can make all of your pull request branches based on this `master` branch. 27 | > Whenever you want to update your version of `master`, do a regular `git pull`. 28 | 29 | ## Help needed 30 | 31 | Please checkout the [the open issues][issues] 32 | 33 | Also, please watch the repo and respond to questions/bug reports/feature 34 | requests! Thanks! 35 | 36 | [egghead]: 37 | https://egghead.io/series/how-to-contribute-to-an-open-source-project-on-github 38 | [issues]: https://github.com/kentcdodds/react-testing-library-course/issues 39 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | This material is available for private, non-commercial use under the 2 | [GPL version 3](http://www.gnu.org/licenses/gpl-3.0-standalone.html). If you 3 | would like to use this material to conduct your own workshop, please contact me 4 | at me@kentcdodds.com 5 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | Test React Components with Jest and React Testing Library 3 |

4 | 5 |
6 |

TestingJavaScript.com

7 | 8 | Learn the smart, efficient way to test any JavaScript application. 13 | 14 |
15 | 16 |
17 | 18 | _Course material for testing React components using react-testing-library_ 19 | 20 | 21 | 22 | 23 | 24 | 25 | - `react-dom.js` - Render a React component for testing 26 | - `jest-dom.js` - Use jest-dom for improved assertions 27 | - `dom-testing-library.js` - Use dom-testing-library to write more maintainable 28 | React tests 29 | - `react-testing-library.js` - Use react-testing-library to render and test 30 | React Components 31 | - `localized.js` - Testing localized content with react-testing-library 32 | - `state.js` - Test React Component state changes with react-testing-library 33 | - `prop-updates.js` - Test prop updates with react-testing-library 34 | - `a11y.js` - Test accessibility of rendered React Components with jest-axe 35 | - `dependency-injection.js` - Mock HTTP Requests with Dependency Injection in 36 | React Component Tests 37 | - `http-jest-mock.js` - Mock HTTP Requests with jest.mock in React Component 38 | Tests 39 | - `mock-component.js` - Mock react-transition-group in React Component Tests 40 | with jest.mock 41 | - `error-boundaries.js` - Test componentDidCatch handler error boundaries with 42 | react-testing-library 43 | - `tdd-markup.js` - Test drive the development of a React Form with 44 | react-testing-library 45 | - `tdd-functionality.js` - TDD the functionality of a React Form with 46 | react-testing-library 47 | - `react-router.js` - Test react-router Provider history object in React 48 | Component Tests with createMemoryHistory 49 | - `redux.js` - Test a redux connected React Component 50 | - `custom-hook.js` - Test a custom hook 51 | - `portals.js` - Test React portals 52 | - `unmounting.js` - Test Unmounting a React Component with react-testing-library 53 | - `app.js` - Testing the full application. 54 | 55 | > Note: the setup for this project uses kcd-scripts. Don't worry about that. You 56 | > can learn about how to configure jest properly in the "Configure Jest for 57 | > Testing JavaScript Applications" module of TestingJavaScript.com 58 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | module.exports = ` 2 | Open the tests! 3 | ` 4 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | const config = require('kcd-scripts/jest') 2 | 3 | module.exports = { 4 | ...config, 5 | // we have no coverageThreshold on this project... 6 | coverageThreshold: {}, 7 | } 8 | -------------------------------------------------------------------------------- /jsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": "./", 4 | "paths": { 5 | "*": ["src/*", "test/*"] 6 | } 7 | }, 8 | "include": ["src", "test/*"] 9 | } 10 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-testing-library-course", 3 | "version": "1.0.0", 4 | "private": true, 5 | "description": "Course material for testing React components using react-testing-library", 6 | "main": "index.js", 7 | "scripts": { 8 | "test": "kcd-scripts test", 9 | "lint": "kcd-scripts lint", 10 | "validate": "kcd-scripts validate", 11 | "setup": "npm install --silent && npm run validate --silent" 12 | }, 13 | "keywords": [], 14 | "author": "Kent C. Dodds (http://kentcdodds.com/)", 15 | "license": "MIT", 16 | "dependencies": { 17 | "@reach/router": "^1.3.4", 18 | "@testing-library/dom": "^7.28.1", 19 | "@testing-library/jest-dom": "^5.11.6", 20 | "@testing-library/react": "^11.2.2", 21 | "@testing-library/react-hooks": "^3.4.2", 22 | "@testing-library/user-event": "^12.4.0", 23 | "history": "^5.0.0", 24 | "jest": "^26.6.3", 25 | "jest-axe": "^4.1.0", 26 | "kcd-scripts": "^7.5.1", 27 | "msw": "^0.24.1", 28 | "react": "^17.0.1", 29 | "react-dom": "^17.0.1", 30 | "react-redux": "^7.2.2", 31 | "react-router": "^5.2.0", 32 | "react-router-dom": "^5.2.0", 33 | "react-test-renderer": "^17.0.1", 34 | "react-transition-group": "^4.4.1", 35 | "redux": "^4.0.5", 36 | "test-data-bot": "^0.8.0", 37 | "whatwg-fetch": "^3.5.0" 38 | }, 39 | "husky": { 40 | "hooks": { 41 | "pre-commit": "kcd-scripts pre-commit" 42 | } 43 | }, 44 | "babel": { 45 | "presets": [ 46 | "kcd-scripts/babel" 47 | ] 48 | }, 49 | "eslintConfig": { 50 | "extends": "./node_modules/kcd-scripts/eslint.js", 51 | "rules": { 52 | "import/prefer-default-export": "off", 53 | "jsx-a11y/label-has-for": "off", 54 | "react/prop-types": "off", 55 | "import/no-unassigned-import": "off", 56 | "no-console": "off", 57 | "jsx-a11y/accessible-emoji": "off", 58 | "consistent-return": "off" 59 | } 60 | }, 61 | "eslintIgnore": [ 62 | "node_modules", 63 | "coverage", 64 | "@types" 65 | ], 66 | "prettier": { 67 | "printWidth": 80, 68 | "tabWidth": 2, 69 | "useTabs": false, 70 | "semi": false, 71 | "singleQuote": true, 72 | "trailingComma": "all", 73 | "bracketSpacing": false, 74 | "jsxBracketSameLine": false, 75 | "proseWrap": "always" 76 | }, 77 | "repository": { 78 | "type": "git", 79 | "url": "git+https://github.com/kentcdodds/react-testing-library-course.git" 80 | }, 81 | "bugs": { 82 | "url": "https://github.com/kentcdodds/react-testing-library-course/issues" 83 | }, 84 | "homepage": "https://github.com/kentcdodds/react-testing-library-course#readme" 85 | } 86 | -------------------------------------------------------------------------------- /src/__tests__/a11y.js: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import {render} from '@testing-library/react' 3 | import {axe} from 'jest-axe' 4 | 5 | function InaccessibleForm() { 6 | // Form inputs must have an accessible name 7 | // Ref: 4.1.1 of W3C HTML Accessibility API Mappings 1.0 8 | return ( 9 |
10 | 11 |
12 | ) 13 | } 14 | 15 | function AccessibleForm() { 16 | return ( 17 |
18 | 19 | 20 |
21 | ) 22 | } 23 | 24 | test('inaccessible forms fail axe', async () => { 25 | const {container} = render() 26 | const axeResult = await axe(container) 27 | expect(() => expect(axeResult).toHaveNoViolations()).toThrow() 28 | // NOTE: I can't think of a situation where you'd want to test that some HTML 29 | // actually _does_ have accessibility issues... This is only here for 30 | // demonstration purposes. 31 | }) 32 | 33 | test('accessible forms pass axe', async () => { 34 | const {container} = render() 35 | expect(await axe(container)).toHaveNoViolations() 36 | }) 37 | -------------------------------------------------------------------------------- /src/__tests__/app-01.js: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import {render, screen, fireEvent} from '@testing-library/react' 3 | // NOTE: for this one we're not using userEvent because 4 | // I wanted to show you how userEvent can improve this test 5 | // in the final lesson. 6 | import {submitForm as mockSubmitForm} from '../api' 7 | import App from '../app' 8 | 9 | jest.mock('../api') 10 | 11 | test('Can fill out a form across multiple pages', async () => { 12 | mockSubmitForm.mockResolvedValueOnce({success: true}) 13 | const testData = {food: 'test food', drink: 'test drink'} 14 | render() 15 | 16 | fireEvent.click(screen.getByText(/fill.*form/i)) 17 | 18 | fireEvent.change(screen.getByLabelText(/food/i), { 19 | target: {value: testData.food}, 20 | }) 21 | fireEvent.click(screen.getByText(/next/i)) 22 | 23 | fireEvent.change(screen.getByLabelText(/drink/i), { 24 | target: {value: testData.drink}, 25 | }) 26 | fireEvent.click(screen.getByText(/review/i)) 27 | 28 | expect(screen.getByLabelText(/food/i)).toHaveTextContent(testData.food) 29 | expect(screen.getByLabelText(/drink/i)).toHaveTextContent(testData.drink) 30 | 31 | fireEvent.click(screen.getByText(/confirm/i, {selector: 'button'})) 32 | 33 | expect(mockSubmitForm).toHaveBeenCalledWith(testData) 34 | expect(mockSubmitForm).toHaveBeenCalledTimes(1) 35 | 36 | fireEvent.click(await screen.findByText(/home/i)) 37 | 38 | expect(screen.getByText(/welcome home/i)).toBeInTheDocument() 39 | }) 40 | -------------------------------------------------------------------------------- /src/__tests__/app-02.js: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import {render, screen, fireEvent} from '@testing-library/react' 3 | // NOTE: for this one we're not using userEvent because 4 | // I wanted to show you how userEvent can improve this test 5 | // in the next lesson. 6 | import {submitForm as mockSubmitForm} from '../api' 7 | import App from '../app' 8 | 9 | jest.mock('../api') 10 | 11 | test('Can fill out a form across multiple pages', async () => { 12 | mockSubmitForm.mockResolvedValueOnce({success: true}) 13 | const testData = {food: 'test food', drink: 'test drink'} 14 | render() 15 | 16 | fireEvent.click(await screen.findByText(/fill.*form/i)) 17 | 18 | fireEvent.change(await screen.findByLabelText(/food/i), { 19 | target: {value: testData.food}, 20 | }) 21 | fireEvent.click(await screen.findByText(/next/i)) 22 | 23 | fireEvent.change(await screen.findByLabelText(/drink/i), { 24 | target: {value: testData.drink}, 25 | }) 26 | fireEvent.click(await screen.findByText(/review/i)) 27 | 28 | expect(await screen.findByLabelText(/food/i)).toHaveTextContent(testData.food) 29 | expect(await screen.findByLabelText(/drink/i)).toHaveTextContent( 30 | testData.drink, 31 | ) 32 | 33 | fireEvent.click(await screen.findByText(/confirm/i, {selector: 'button'})) 34 | 35 | expect(mockSubmitForm).toHaveBeenCalledWith(testData) 36 | expect(mockSubmitForm).toHaveBeenCalledTimes(1) 37 | 38 | fireEvent.click(await screen.findByText(/home/i)) 39 | 40 | expect(await screen.findByText(/welcome home/i)).toBeInTheDocument() 41 | }) 42 | -------------------------------------------------------------------------------- /src/__tests__/app-03.js: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import {render, screen} from '@testing-library/react' 3 | // NOTE: in the videos I called this "user" but I think 4 | // it makes more sense to call it "userEvent" 5 | // (you'll run into fewer "variable shadowing" problems) that way. 6 | import userEvent from '@testing-library/user-event' 7 | import {submitForm as mockSubmitForm} from '../api' 8 | import App from '../app-reach-router' 9 | 10 | jest.mock('../api') 11 | 12 | test('Can fill out a form across multiple pages', async () => { 13 | mockSubmitForm.mockResolvedValueOnce({success: true}) 14 | const testData = {food: 'test food', drink: 'test drink'} 15 | render() 16 | 17 | userEvent.click(await screen.findByText(/fill.*form/i)) 18 | 19 | userEvent.type(await screen.findByLabelText(/food/i), testData.food) 20 | userEvent.click(await screen.findByText(/next/i)) 21 | 22 | userEvent.type(await screen.findByLabelText(/drink/i), testData.drink) 23 | userEvent.click(await screen.findByText(/review/i)) 24 | 25 | expect(await screen.findByLabelText(/food/i)).toHaveTextContent(testData.food) 26 | expect(await screen.findByLabelText(/drink/i)).toHaveTextContent( 27 | testData.drink, 28 | ) 29 | 30 | userEvent.click(await screen.findByText(/confirm/i, {selector: 'button'})) 31 | 32 | expect(mockSubmitForm).toHaveBeenCalledWith(testData) 33 | expect(mockSubmitForm).toHaveBeenCalledTimes(1) 34 | 35 | userEvent.click(await screen.findByText(/home/i)) 36 | 37 | expect(await screen.findByText(/welcome home/i)).toBeInTheDocument() 38 | }) 39 | -------------------------------------------------------------------------------- /src/__tests__/custom-hook-01.js: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import {render, act} from '@testing-library/react' 3 | import {useCounter} from '../use-counter' 4 | 5 | test('exposes the count and increment/decrement functions', () => { 6 | let result 7 | function TestComponent() { 8 | result = useCounter() 9 | return null 10 | } 11 | render() 12 | expect(result.count).toBe(0) 13 | act(() => result.increment()) 14 | expect(result.count).toBe(1) 15 | act(() => result.decrement()) 16 | expect(result.count).toBe(0) 17 | }) 18 | -------------------------------------------------------------------------------- /src/__tests__/custom-hook-02.js: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import {render, act} from '@testing-library/react' 3 | import {useCounter} from '../use-counter' 4 | 5 | function setup({initialProps} = {}) { 6 | const result = {} 7 | function TestComponent(props) { 8 | result.current = useCounter(props) 9 | return null 10 | } 11 | render() 12 | return result 13 | } 14 | 15 | test('exposes the count and increment/decrement functions', () => { 16 | const result = setup() 17 | expect(result.current.count).toBe(0) 18 | act(() => result.current.increment()) 19 | expect(result.current.count).toBe(1) 20 | act(() => result.current.decrement()) 21 | expect(result.current.count).toBe(0) 22 | }) 23 | 24 | test('allows customization of the initial count', () => { 25 | const result = setup({initialProps: {initialCount: 3}}) 26 | expect(result.current.count).toBe(3) 27 | }) 28 | 29 | test('allows customization of the step', () => { 30 | const result = setup({initialProps: {step: 2}}) 31 | expect(result.current.count).toBe(0) 32 | act(() => result.current.increment()) 33 | expect(result.current.count).toBe(2) 34 | act(() => result.current.decrement()) 35 | expect(result.current.count).toBe(0) 36 | }) 37 | -------------------------------------------------------------------------------- /src/__tests__/custom-hook-03.js: -------------------------------------------------------------------------------- 1 | import {renderHook, act} from '@testing-library/react-hooks' 2 | import {useCounter} from '../use-counter' 3 | 4 | test('exposes the count and increment/decrement functions', () => { 5 | const {result} = renderHook(useCounter) 6 | expect(result.current.count).toBe(0) 7 | act(() => result.current.increment()) 8 | expect(result.current.count).toBe(1) 9 | act(() => result.current.decrement()) 10 | expect(result.current.count).toBe(0) 11 | }) 12 | 13 | test('allows customization of the initial count', () => { 14 | const {result} = renderHook(useCounter, {initialProps: {initialCount: 3}}) 15 | expect(result.current.count).toBe(3) 16 | }) 17 | 18 | test('allows customization of the step', () => { 19 | const {result} = renderHook(useCounter, {initialProps: {step: 2}}) 20 | expect(result.current.count).toBe(0) 21 | act(() => result.current.increment()) 22 | expect(result.current.count).toBe(2) 23 | act(() => result.current.decrement()) 24 | expect(result.current.count).toBe(0) 25 | }) 26 | -------------------------------------------------------------------------------- /src/__tests__/custom-hook-04.js: -------------------------------------------------------------------------------- 1 | import {renderHook, act} from '@testing-library/react-hooks' 2 | import {useCounter} from '../use-counter' 3 | 4 | test('exposes the count and increment/decrement functions', () => { 5 | const {result} = renderHook(useCounter) 6 | expect(result.current.count).toBe(0) 7 | act(() => result.current.increment()) 8 | expect(result.current.count).toBe(1) 9 | act(() => result.current.decrement()) 10 | expect(result.current.count).toBe(0) 11 | }) 12 | 13 | test('allows customization of the initial count', () => { 14 | const {result} = renderHook(useCounter, {initialProps: {initialCount: 3}}) 15 | expect(result.current.count).toBe(3) 16 | }) 17 | 18 | test('allows customization of the step', () => { 19 | const {result} = renderHook(useCounter, {initialProps: {step: 2}}) 20 | expect(result.current.count).toBe(0) 21 | act(() => result.current.increment()) 22 | expect(result.current.count).toBe(2) 23 | act(() => result.current.decrement()) 24 | expect(result.current.count).toBe(0) 25 | }) 26 | 27 | test('the step can be changed', () => { 28 | const {result, rerender} = renderHook(useCounter, { 29 | initialProps: {step: 3}, 30 | }) 31 | expect(result.current.count).toBe(0) 32 | act(() => result.current.increment()) 33 | expect(result.current.count).toBe(3) 34 | rerender({step: 2}) 35 | act(() => result.current.decrement()) 36 | expect(result.current.count).toBe(1) 37 | }) 38 | -------------------------------------------------------------------------------- /src/__tests__/dependency-injection.js: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import {render, screen, waitFor} from '@testing-library/react' 3 | import userEvent from '@testing-library/user-event' 4 | import {GreetingLoader} from '../greeting-loader-02-dependency-injection' 5 | 6 | test('loads greetings on click', async () => { 7 | const mockLoadGreeting = jest.fn() 8 | const testGreeting = 'TEST_GREETING' 9 | mockLoadGreeting.mockResolvedValueOnce({data: {greeting: testGreeting}}) 10 | render() 11 | const nameInput = screen.getByLabelText(/name/i) 12 | const loadButton = screen.getByText(/load/i) 13 | nameInput.value = 'Mary' 14 | userEvent.click(loadButton) 15 | expect(mockLoadGreeting).toHaveBeenCalledWith('Mary') 16 | expect(mockLoadGreeting).toHaveBeenCalledTimes(1) 17 | await waitFor(() => 18 | expect(screen.getByLabelText(/greeting/i)).toHaveTextContent(testGreeting), 19 | ) 20 | }) 21 | -------------------------------------------------------------------------------- /src/__tests__/dom-testing-library.js: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import ReactDOM from 'react-dom' 3 | import {getQueriesForElement} from '@testing-library/dom' 4 | import {FavoriteNumber} from '../favorite-number' 5 | 6 | test('renders a number input with a label "Favorite Number"', () => { 7 | const div = document.createElement('div') 8 | ReactDOM.render(, div) 9 | const {getByLabelText} = getQueriesForElement(div) 10 | const input = getByLabelText(/favorite number/i) 11 | expect(input).toHaveAttribute('type', 'number') 12 | }) 13 | 14 | // disabled for the purpose of this lesson. We'll get to this later 15 | /* 16 | eslint 17 | testing-library/no-dom-import: "off", 18 | testing-library/prefer-screen-queries: "off" 19 | */ 20 | -------------------------------------------------------------------------------- /src/__tests__/error-boundary-01.js: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import {render} from '@testing-library/react' 3 | import {reportError as mockReportError} from '../api' 4 | import {ErrorBoundary} from '../error-boundary' 5 | 6 | jest.mock('../api') 7 | 8 | afterEach(() => { 9 | jest.clearAllMocks() 10 | }) 11 | 12 | function Bomb({shouldThrow}) { 13 | if (shouldThrow) { 14 | throw new Error('💣') 15 | } else { 16 | return null 17 | } 18 | } 19 | 20 | test('calls reportError and renders that there was a problem', () => { 21 | mockReportError.mockResolvedValueOnce({success: true}) 22 | const {rerender} = render( 23 | 24 | 25 | , 26 | ) 27 | 28 | rerender( 29 | 30 | 31 | , 32 | ) 33 | 34 | const error = expect.any(Error) 35 | const info = {componentStack: expect.stringContaining('Bomb')} 36 | expect(mockReportError).toHaveBeenCalledWith(error, info) 37 | expect(mockReportError).toHaveBeenCalledTimes(1) 38 | }) 39 | 40 | // this is only here to make the error output not appear in the project's output 41 | // even though in the course we don't include this bit and leave it in it's incomplete state. 42 | beforeEach(() => { 43 | jest.spyOn(console, 'error').mockImplementation(() => {}) 44 | }) 45 | 46 | afterEach(() => { 47 | console.error.mockRestore() 48 | }) 49 | 50 | /* 51 | eslint 52 | jest/prefer-hooks-on-top: off 53 | */ 54 | -------------------------------------------------------------------------------- /src/__tests__/error-boundary-02.js: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import {render} from '@testing-library/react' 3 | import {reportError as mockReportError} from '../api' 4 | import {ErrorBoundary} from '../error-boundary' 5 | 6 | jest.mock('../api') 7 | 8 | beforeAll(() => { 9 | jest.spyOn(console, 'error').mockImplementation(() => {}) 10 | }) 11 | 12 | afterAll(() => { 13 | console.error.mockRestore() 14 | }) 15 | 16 | afterEach(() => { 17 | jest.clearAllMocks() 18 | }) 19 | 20 | function Bomb({shouldThrow}) { 21 | if (shouldThrow) { 22 | throw new Error('💣') 23 | } else { 24 | return null 25 | } 26 | } 27 | 28 | test('calls reportError and renders that there was a problem', () => { 29 | mockReportError.mockResolvedValueOnce({success: true}) 30 | const {rerender} = render( 31 | 32 | 33 | , 34 | ) 35 | 36 | rerender( 37 | 38 | 39 | , 40 | ) 41 | 42 | const error = expect.any(Error) 43 | const info = {componentStack: expect.stringContaining('Bomb')} 44 | expect(mockReportError).toHaveBeenCalledWith(error, info) 45 | expect(mockReportError).toHaveBeenCalledTimes(1) 46 | 47 | expect(console.error).toHaveBeenCalledTimes(2) 48 | }) 49 | -------------------------------------------------------------------------------- /src/__tests__/error-boundary-03.js: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import {render, screen} from '@testing-library/react' 3 | import userEvent from '@testing-library/user-event' 4 | import {reportError as mockReportError} from '../api' 5 | import {ErrorBoundary} from '../error-boundary' 6 | 7 | jest.mock('../api') 8 | 9 | beforeAll(() => { 10 | jest.spyOn(console, 'error').mockImplementation(() => {}) 11 | }) 12 | 13 | afterAll(() => { 14 | console.error.mockRestore() 15 | }) 16 | 17 | afterEach(() => { 18 | jest.clearAllMocks() 19 | }) 20 | 21 | function Bomb({shouldThrow}) { 22 | if (shouldThrow) { 23 | throw new Error('💣') 24 | } else { 25 | return null 26 | } 27 | } 28 | 29 | test('calls reportError and renders that there was a problem', () => { 30 | mockReportError.mockResolvedValueOnce({success: true}) 31 | const {rerender} = render( 32 | 33 | 34 | , 35 | ) 36 | 37 | rerender( 38 | 39 | 40 | , 41 | ) 42 | 43 | const error = expect.any(Error) 44 | const info = {componentStack: expect.stringContaining('Bomb')} 45 | expect(mockReportError).toHaveBeenCalledWith(error, info) 46 | expect(mockReportError).toHaveBeenCalledTimes(1) 47 | 48 | expect(console.error).toHaveBeenCalledTimes(2) 49 | 50 | expect(screen.getByRole('alert').textContent).toMatchInlineSnapshot( 51 | `"There was a problem."`, 52 | ) 53 | 54 | console.error.mockClear() 55 | mockReportError.mockClear() 56 | 57 | rerender( 58 | 59 | 60 | , 61 | ) 62 | 63 | userEvent.click(screen.getByText(/try again/i)) 64 | 65 | expect(mockReportError).not.toHaveBeenCalled() 66 | expect(console.error).not.toHaveBeenCalled() 67 | expect(screen.queryByRole('alert')).not.toBeInTheDocument() 68 | expect(screen.queryByText(/try again/i)).not.toBeInTheDocument() 69 | }) 70 | -------------------------------------------------------------------------------- /src/__tests__/error-boundary-04.js: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import {render, screen} from '@testing-library/react' 3 | import userEvent from '@testing-library/user-event' 4 | import {reportError as mockReportError} from '../api' 5 | import {ErrorBoundary} from '../error-boundary' 6 | 7 | jest.mock('../api') 8 | 9 | beforeAll(() => { 10 | jest.spyOn(console, 'error').mockImplementation(() => {}) 11 | }) 12 | 13 | afterAll(() => { 14 | console.error.mockRestore() 15 | }) 16 | 17 | afterEach(() => { 18 | jest.clearAllMocks() 19 | }) 20 | 21 | function Bomb({shouldThrow}) { 22 | if (shouldThrow) { 23 | throw new Error('💣') 24 | } else { 25 | return null 26 | } 27 | } 28 | 29 | test('calls reportError and renders that there was a problem', () => { 30 | mockReportError.mockResolvedValueOnce({success: true}) 31 | const {rerender} = render(, {wrapper: ErrorBoundary}) 32 | 33 | rerender() 34 | 35 | const error = expect.any(Error) 36 | const info = {componentStack: expect.stringContaining('Bomb')} 37 | expect(mockReportError).toHaveBeenCalledWith(error, info) 38 | expect(mockReportError).toHaveBeenCalledTimes(1) 39 | 40 | expect(console.error).toHaveBeenCalledTimes(2) 41 | 42 | expect(screen.getByRole('alert').textContent).toMatchInlineSnapshot( 43 | `"There was a problem."`, 44 | ) 45 | 46 | console.error.mockClear() 47 | mockReportError.mockClear() 48 | 49 | rerender() 50 | 51 | userEvent.click(screen.getByText(/try again/i)) 52 | 53 | expect(mockReportError).not.toHaveBeenCalled() 54 | expect(console.error).not.toHaveBeenCalled() 55 | expect(screen.queryByRole('alert')).not.toBeInTheDocument() 56 | expect(screen.queryByText(/try again/i)).not.toBeInTheDocument() 57 | }) 58 | -------------------------------------------------------------------------------- /src/__tests__/http-jest-mock.js: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import {render, screen, waitFor} from '@testing-library/react' 3 | import userEvent from '@testing-library/user-event' 4 | import {loadGreeting as mockLoadGreeting} from '../api' 5 | import {GreetingLoader} from '../greeting-loader-01-mocking' 6 | 7 | jest.mock('../api') 8 | 9 | test('loads greetings on click', async () => { 10 | const testGreeting = 'TEST_GREETING' 11 | mockLoadGreeting.mockResolvedValueOnce({data: {greeting: testGreeting}}) 12 | render() 13 | const nameInput = screen.getByLabelText(/name/i) 14 | const loadButton = screen.getByText(/load/i) 15 | userEvent.type(nameInput, 'Mary') 16 | userEvent.click(loadButton) 17 | expect(mockLoadGreeting).toHaveBeenCalledWith('Mary') 18 | expect(mockLoadGreeting).toHaveBeenCalledTimes(1) 19 | await waitFor(() => 20 | expect(screen.getByLabelText(/greeting/i)).toHaveTextContent(testGreeting), 21 | ) 22 | }) 23 | -------------------------------------------------------------------------------- /src/__tests__/http-msw-mock.js: -------------------------------------------------------------------------------- 1 | import 'whatwg-fetch' 2 | import * as React from 'react' 3 | import {render, screen, waitFor} from '@testing-library/react' 4 | import userEvent from '@testing-library/user-event' 5 | import {rest} from 'msw' 6 | import {setupServer} from 'msw/node' 7 | import {GreetingLoader} from '../greeting-loader-01-mocking' 8 | 9 | const server = setupServer( 10 | rest.post('/greeting', (req, res, ctx) => { 11 | return res(ctx.json({data: {greeting: `Hello ${req.body.subject}`}})) 12 | }), 13 | ) 14 | 15 | beforeAll(() => server.listen({onUnhandledRequest: 'error'})) 16 | afterAll(() => server.close()) 17 | afterEach(() => server.resetHandlers()) 18 | 19 | test('loads greetings on click', async () => { 20 | render() 21 | const nameInput = screen.getByLabelText(/name/i) 22 | const loadButton = screen.getByText(/load/i) 23 | userEvent.type(nameInput, 'Mary') 24 | userEvent.click(loadButton) 25 | await waitFor(() => 26 | expect(screen.getByLabelText(/greeting/i)).toHaveTextContent('Hello Mary'), 27 | ) 28 | }) 29 | -------------------------------------------------------------------------------- /src/__tests__/jest-dom.js: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import ReactDOM from 'react-dom' 3 | import {FavoriteNumber} from '../favorite-number' 4 | 5 | test('renders a number input with a label "Favorite Number"', () => { 6 | const div = document.createElement('div') 7 | ReactDOM.render(, div) 8 | expect(div.querySelector('input')).toHaveAttribute('type', 'number') 9 | expect(div.querySelector('label')).toHaveTextContent('Favorite Number') 10 | }) 11 | -------------------------------------------------------------------------------- /src/__tests__/mock-component.js: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import {render, screen} from '@testing-library/react' 3 | import userEvent from '@testing-library/user-event' 4 | import {HiddenMessage} from '../hidden-message' 5 | 6 | jest.mock('react-transition-group', () => { 7 | return { 8 | CSSTransition: (props) => (props.in ? props.children : null), 9 | } 10 | }) 11 | 12 | test('shows hidden message when toggle is clicked', () => { 13 | const myMessage = 'hello world' 14 | render({myMessage}) 15 | const toggleButton = screen.getByText(/toggle/i) 16 | expect(screen.queryByText(myMessage)).not.toBeInTheDocument() 17 | userEvent.click(toggleButton) 18 | expect(screen.getByText(myMessage)).toBeInTheDocument() 19 | userEvent.click(toggleButton) 20 | expect(screen.queryByText(myMessage)).not.toBeInTheDocument() 21 | }) 22 | -------------------------------------------------------------------------------- /src/__tests__/portals.js: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import {render, within} from '@testing-library/react' 3 | import {Modal} from '../modal' 4 | 5 | test('modal shows the children', () => { 6 | render( 7 | 8 |
9 | , 10 | ) 11 | const {getByTestId} = within(document.getElementById('modal-root')) 12 | expect(getByTestId('test')).toBeInTheDocument() 13 | }) 14 | -------------------------------------------------------------------------------- /src/__tests__/prop-updates-01.js: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import user from '@testing-library/user-event' 3 | import {render, screen} from '@testing-library/react' 4 | import {FavoriteNumber} from '../favorite-number' 5 | 6 | test('entering an invalid value shows an error message', () => { 7 | const {debug, rerender} = render() 8 | const input = screen.getByLabelText(/favorite number/i) 9 | user.type(input, '10') 10 | expect(screen.getByRole('alert')).toHaveTextContent(/the number is invalid/i) 11 | debug() 12 | rerender() 13 | debug() 14 | }) 15 | 16 | // disabling the rule for the purposes of the exercise 17 | /* 18 | eslint 19 | testing-library/no-debug: "off", 20 | */ 21 | -------------------------------------------------------------------------------- /src/__tests__/prop-updates-02.js: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import user from '@testing-library/user-event' 3 | import {render, screen} from '@testing-library/react' 4 | import {FavoriteNumber} from '../favorite-number' 5 | 6 | test('entering an invalid value shows an error message', () => { 7 | const {rerender} = render() 8 | const input = screen.getByLabelText(/favorite number/i) 9 | user.type(input, '10') 10 | expect(screen.getByRole('alert')).toHaveTextContent(/the number is invalid/i) 11 | rerender() 12 | expect(screen.queryByRole('alert')).not.toBeInTheDocument() 13 | }) 14 | -------------------------------------------------------------------------------- /src/__tests__/react-dom.js: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import ReactDOM from 'react-dom' 3 | import {FavoriteNumber} from '../favorite-number' 4 | 5 | test('renders a number input with a label "Favorite Number"', () => { 6 | const div = document.createElement('div') 7 | ReactDOM.render(, div) 8 | expect(div.querySelector('input').type).toBe('number') 9 | expect(div.querySelector('label')).toHaveTextContent('Favorite Number') 10 | }) 11 | -------------------------------------------------------------------------------- /src/__tests__/react-router-01.js: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import {BrowserRouter} from 'react-router-dom' 3 | import {render, screen} from '@testing-library/react' 4 | import userEvent from '@testing-library/user-event' 5 | import {Main} from '../main' 6 | 7 | test('main renders about and home and I can navigate to those pages', () => { 8 | window.history.pushState({}, 'Test page', '/') 9 | render( 10 | 11 |
12 | , 13 | ) 14 | expect(screen.getByRole('heading')).toHaveTextContent(/home/i) 15 | userEvent.click(screen.getByText(/about/i)) 16 | expect(screen.getByRole('heading')).toHaveTextContent(/about/i) 17 | }) 18 | -------------------------------------------------------------------------------- /src/__tests__/react-router-02.js: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import {BrowserRouter} from 'react-router-dom' 3 | import {render, screen} from '@testing-library/react' 4 | import userEvent from '@testing-library/user-event' 5 | import {Main} from '../main' 6 | 7 | test('main renders about and home and I can navigate to those pages', () => { 8 | window.history.pushState({}, 'Test page', '/') 9 | render( 10 | 11 |
12 | , 13 | ) 14 | expect(screen.getByRole('heading')).toHaveTextContent(/home/i) 15 | userEvent.click(screen.getByText(/about/i)) 16 | expect(screen.getByRole('heading')).toHaveTextContent(/about/i) 17 | }) 18 | 19 | test('landing on a bad page shows no match component', () => { 20 | window.history.pushState({}, 'Test page', '/something-that-does-not-match') 21 | render( 22 | 23 |
24 | , 25 | ) 26 | expect(screen.getByRole('heading')).toHaveTextContent(/404/i) 27 | }) 28 | -------------------------------------------------------------------------------- /src/__tests__/react-router-03.js: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import {BrowserRouter} from 'react-router-dom' 3 | import {render as rtlRender, screen} from '@testing-library/react' 4 | import userEvent from '@testing-library/user-event' 5 | import {Main} from '../main' 6 | 7 | // normally you'd put this logic in your test utility file so it can be used 8 | // for all of your tests. 9 | function render(ui, {route = '/', ...renderOptions} = {}) { 10 | // we'll set our route properly here 11 | window.history.pushState({}, 'Test page', route) 12 | 13 | function Wrapper({children}) { 14 | // originally this rendered a Router with a memory history 15 | // but using the actual BrowserRouter is more correct and 16 | // is actually easier anyway. 17 | return {children} 18 | } 19 | return rtlRender(ui, { 20 | wrapper: Wrapper, 21 | ...renderOptions, 22 | // originally this exposed history, but that's really 23 | // an implementation detail, so we don't recommend that anymore 24 | }) 25 | } 26 | 27 | test('main renders about and home and I can navigate to those pages', () => { 28 | render(
) 29 | expect(screen.getByRole('heading')).toHaveTextContent(/home/i) 30 | userEvent.click(screen.getByText(/about/i)) 31 | expect(screen.getByRole('heading')).toHaveTextContent(/about/i) 32 | // you can use the `within` function to get queries for elements within the 33 | // about screen 34 | }) 35 | 36 | test('landing on a bad page shows no match component', () => { 37 | render(
, { 38 | route: '/something-that-does-not-match', 39 | }) 40 | expect(screen.getByRole('heading')).toHaveTextContent(/404/i) 41 | }) 42 | -------------------------------------------------------------------------------- /src/__tests__/react-testing-library.js: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import {render, screen} from '@testing-library/react' 3 | import {FavoriteNumber} from '../favorite-number' 4 | 5 | test('renders a number input with a label "Favorite Number"', () => { 6 | render() 7 | const input = screen.getByLabelText(/favorite number/i) 8 | expect(input).toHaveAttribute('type', 'number') 9 | }) 10 | -------------------------------------------------------------------------------- /src/__tests__/redux-01.js: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import {Provider} from 'react-redux' 3 | import {render, screen} from '@testing-library/react' 4 | import userEvent from '@testing-library/user-event' 5 | import {Counter} from '../redux-counter' 6 | import {store} from '../redux-store' 7 | 8 | test('can render with redux with defaults', () => { 9 | render( 10 | 11 | 12 | , 13 | ) 14 | userEvent.click(screen.getByText('+')) 15 | expect(screen.getByLabelText(/count/i)).toHaveTextContent('1') 16 | }) 17 | -------------------------------------------------------------------------------- /src/__tests__/redux-02.js: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import {createStore} from 'redux' 3 | import {Provider} from 'react-redux' 4 | import {render, screen} from '@testing-library/react' 5 | import userEvent from '@testing-library/user-event' 6 | import {Counter} from '../redux-counter' 7 | import {store as appStore} from '../redux-store' 8 | import {reducer} from '../redux-reducer' 9 | 10 | test('can render with redux with defaults', () => { 11 | render( 12 | 13 | 14 | , 15 | ) 16 | userEvent.click(screen.getByText('+')) 17 | expect(screen.getByLabelText(/count/i)).toHaveTextContent('1') 18 | }) 19 | 20 | test('can render with redux with custom initial state', () => { 21 | const store = createStore(reducer, {count: 3}) 22 | render( 23 | 24 | 25 | , 26 | ) 27 | userEvent.click(screen.getByText('-')) 28 | expect(screen.getByLabelText(/count/i)).toHaveTextContent('2') 29 | }) 30 | -------------------------------------------------------------------------------- /src/__tests__/redux-03.js: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import {createStore} from 'redux' 3 | import {Provider} from 'react-redux' 4 | import {render as rtlRender, screen} from '@testing-library/react' 5 | import userEvent from '@testing-library/user-event' 6 | import {Counter} from '../redux-counter' 7 | import {reducer} from '../redux-reducer' 8 | 9 | // this is a handy function that I normally make available for all my tests 10 | // that deal with connected components. 11 | // you can provide initialState or the entire store that the ui is rendered with 12 | function render( 13 | ui, 14 | { 15 | initialState, 16 | store = createStore(reducer, initialState), 17 | ...renderOptions 18 | } = {}, 19 | ) { 20 | function Wrapper({children}) { 21 | return {children} 22 | } 23 | return { 24 | ...rtlRender(ui, { 25 | wrapper: Wrapper, 26 | ...renderOptions, 27 | }), 28 | // adding `store` to the returned utilities to allow us 29 | // to reference it in our tests (just try to avoid using 30 | // this to test implementation details). 31 | store, 32 | } 33 | } 34 | 35 | test('can increment the value', () => { 36 | render() 37 | userEvent.click(screen.getByText('+')) 38 | expect(screen.getByLabelText(/count/i)).toHaveTextContent('1') 39 | }) 40 | 41 | test('can decrement the value', () => { 42 | render(, { 43 | initialState: {count: 3}, 44 | }) 45 | userEvent.click(screen.getByText('-')) 46 | expect(screen.getByLabelText(/count/i)).toHaveTextContent('2') 47 | }) 48 | -------------------------------------------------------------------------------- /src/__tests__/state-user-event.js: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import user from '@testing-library/user-event' 3 | import {render, screen} from '@testing-library/react' 4 | import {FavoriteNumber} from '../favorite-number' 5 | 6 | test('entering an invalid value shows an error message', () => { 7 | render() 8 | const input = screen.getByLabelText(/favorite number/i) 9 | user.type(input, '10') 10 | expect(screen.getByRole('alert')).toHaveTextContent(/the number is invalid/i) 11 | }) 12 | -------------------------------------------------------------------------------- /src/__tests__/state.js: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import {render, screen} from '@testing-library/react' 3 | import userEvent from '@testing-library/user-event' 4 | import {FavoriteNumber} from '../favorite-number' 5 | 6 | test('entering an invalid value shows an error message', () => { 7 | render() 8 | const input = screen.getByLabelText(/favorite number/i) 9 | userEvent.type(input, '10') 10 | expect(screen.getByRole('alert')).toHaveTextContent(/the number is invalid/i) 11 | }) 12 | -------------------------------------------------------------------------------- /src/__tests__/tdd-01-markup.js: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import {render, screen} from '@testing-library/react' 3 | import {Editor} from '../post-editor-01-markup' 4 | 5 | test('renders a form with title, content, tags, and a submit button', () => { 6 | render() 7 | screen.getByLabelText(/title/i) 8 | screen.getByLabelText(/content/i) 9 | screen.getByLabelText(/tags/i) 10 | screen.getByText(/submit/i) 11 | }) 12 | 13 | // disabling this rule for now. We'll get to this later 14 | /* 15 | eslint 16 | testing-library/prefer-explicit-assert: "off", 17 | */ 18 | -------------------------------------------------------------------------------- /src/__tests__/tdd-02-state.js: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import {render, screen} from '@testing-library/react' 3 | import userEvent from '@testing-library/user-event' 4 | import {Editor} from '../post-editor-02-state' 5 | 6 | test('renders a form with title, content, tags, and a submit button', () => { 7 | render() 8 | screen.getByLabelText(/title/i) 9 | screen.getByLabelText(/content/i) 10 | screen.getByLabelText(/tags/i) 11 | const submitButton = screen.getByText(/submit/i) 12 | 13 | userEvent.click(submitButton) 14 | 15 | expect(submitButton).toBeDisabled() 16 | }) 17 | 18 | // disabling this rule for now. We'll get to this later 19 | /* 20 | eslint 21 | testing-library/prefer-explicit-assert: "off", 22 | */ 23 | -------------------------------------------------------------------------------- /src/__tests__/tdd-03-api.js: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import {render, screen} from '@testing-library/react' 3 | import userEvent from '@testing-library/user-event' 4 | import {savePost as mockSavePost} from '../api' 5 | import {Editor} from '../post-editor-03-api' 6 | 7 | jest.mock('../api') 8 | 9 | afterEach(() => { 10 | jest.clearAllMocks() 11 | }) 12 | 13 | test('renders a form with title, content, tags, and a submit button', () => { 14 | mockSavePost.mockResolvedValueOnce() 15 | const fakeUser = {id: 'user-1'} 16 | render() 17 | const fakePost = { 18 | title: 'Test Title', 19 | content: 'Test content', 20 | tags: ['tag1', 'tag2'], 21 | } 22 | screen.getByLabelText(/title/i).value = fakePost.title 23 | screen.getByLabelText(/content/i).value = fakePost.content 24 | screen.getByLabelText(/tags/i).value = fakePost.tags.join(', ') 25 | const submitButton = screen.getByText(/submit/i) 26 | 27 | userEvent.click(submitButton) 28 | 29 | expect(submitButton).toBeDisabled() 30 | 31 | expect(mockSavePost).toHaveBeenCalledWith({ 32 | ...fakePost, 33 | authorId: fakeUser.id, 34 | }) 35 | expect(mockSavePost).toHaveBeenCalledTimes(1) 36 | }) 37 | -------------------------------------------------------------------------------- /src/__tests__/tdd-04-router-redirect.js: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import {render, screen, waitFor} from '@testing-library/react' 3 | import userEvent from '@testing-library/user-event' 4 | import {Redirect as MockRedirect} from 'react-router' 5 | import {savePost as mockSavePost} from '../api' 6 | import {Editor} from '../post-editor-04-router-redirect' 7 | 8 | jest.mock('react-router', () => { 9 | return { 10 | Redirect: jest.fn(() => null), 11 | } 12 | }) 13 | 14 | jest.mock('../api') 15 | 16 | afterEach(() => { 17 | jest.clearAllMocks() 18 | }) 19 | 20 | test('renders a form with title, content, tags, and a submit button', async () => { 21 | mockSavePost.mockResolvedValueOnce() 22 | const fakeUser = {id: 'user-1'} 23 | render() 24 | const fakePost = { 25 | title: 'Test Title', 26 | content: 'Test content', 27 | tags: ['tag1', 'tag2'], 28 | } 29 | screen.getByLabelText(/title/i).value = fakePost.title 30 | screen.getByLabelText(/content/i).value = fakePost.content 31 | screen.getByLabelText(/tags/i).value = fakePost.tags.join(', ') 32 | const submitButton = screen.getByText(/submit/i) 33 | 34 | userEvent.click(submitButton) 35 | 36 | expect(submitButton).toBeDisabled() 37 | 38 | expect(mockSavePost).toHaveBeenCalledWith({ 39 | ...fakePost, 40 | authorId: fakeUser.id, 41 | }) 42 | expect(mockSavePost).toHaveBeenCalledTimes(1) 43 | 44 | await waitFor(() => expect(MockRedirect).toHaveBeenCalledWith({to: '/'}, {})) 45 | }) 46 | -------------------------------------------------------------------------------- /src/__tests__/tdd-05-dates.js: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import {render, screen, waitFor} from '@testing-library/react' 3 | import userEvent from '@testing-library/user-event' 4 | import {Redirect as MockRedirect} from 'react-router' 5 | import {savePost as mockSavePost} from '../api' 6 | import {Editor} from '../post-editor-05-dates' 7 | 8 | jest.mock('react-router', () => { 9 | return { 10 | Redirect: jest.fn(() => null), 11 | } 12 | }) 13 | 14 | jest.mock('../api') 15 | 16 | afterEach(() => { 17 | jest.clearAllMocks() 18 | }) 19 | 20 | test('renders a form with title, content, tags, and a submit button', async () => { 21 | mockSavePost.mockResolvedValueOnce() 22 | const fakeUser = {id: 'user-1'} 23 | render() 24 | const fakePost = { 25 | title: 'Test Title', 26 | content: 'Test content', 27 | tags: ['tag1', 'tag2'], 28 | } 29 | const preDate = new Date().getTime() 30 | 31 | screen.getByLabelText(/title/i).value = fakePost.title 32 | screen.getByLabelText(/content/i).value = fakePost.content 33 | screen.getByLabelText(/tags/i).value = fakePost.tags.join(', ') 34 | const submitButton = screen.getByText(/submit/i) 35 | 36 | userEvent.click(submitButton) 37 | 38 | expect(submitButton).toBeDisabled() 39 | 40 | expect(mockSavePost).toHaveBeenCalledWith({ 41 | ...fakePost, 42 | date: expect.any(String), 43 | authorId: fakeUser.id, 44 | }) 45 | expect(mockSavePost).toHaveBeenCalledTimes(1) 46 | 47 | const postDate = new Date().getTime() 48 | const date = new Date(mockSavePost.mock.calls[0][0].date).getTime() 49 | expect(date).toBeGreaterThanOrEqual(preDate) 50 | expect(date).toBeLessThanOrEqual(postDate) 51 | 52 | await waitFor(() => expect(MockRedirect).toHaveBeenCalledWith({to: '/'}, {})) 53 | }) 54 | -------------------------------------------------------------------------------- /src/__tests__/tdd-06-generate-data.js: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import {render, screen, waitFor} from '@testing-library/react' 3 | import userEvent from '@testing-library/user-event' 4 | import {build, fake, sequence} from 'test-data-bot' 5 | import {Redirect as MockRedirect} from 'react-router' 6 | import {savePost as mockSavePost} from '../api' 7 | import {Editor} from '../post-editor-06-generate-data' 8 | 9 | jest.mock('react-router', () => { 10 | return { 11 | Redirect: jest.fn(() => null), 12 | } 13 | }) 14 | jest.mock('../api') 15 | 16 | afterEach(() => { 17 | jest.clearAllMocks() 18 | }) 19 | 20 | const postBuilder = build('Post').fields({ 21 | title: fake((f) => f.lorem.words()), 22 | content: fake((f) => f.lorem.paragraphs().replace(/\r/g, '')), 23 | tags: fake((f) => [f.lorem.word(), f.lorem.word(), f.lorem.word()]), 24 | }) 25 | 26 | const userBuilder = build('User').fields({ 27 | id: sequence((s) => `user-${s}`), 28 | }) 29 | 30 | test('renders a form with title, content, tags, and a submit button', async () => { 31 | mockSavePost.mockResolvedValueOnce() 32 | const fakeUser = userBuilder() 33 | render() 34 | const fakePost = postBuilder() 35 | const preDate = new Date().getTime() 36 | 37 | screen.getByLabelText(/title/i).value = fakePost.title 38 | screen.getByLabelText(/content/i).value = fakePost.content 39 | screen.getByLabelText(/tags/i).value = fakePost.tags.join(', ') 40 | const submitButton = screen.getByText(/submit/i) 41 | 42 | userEvent.click(submitButton) 43 | 44 | expect(submitButton).toBeDisabled() 45 | 46 | expect(mockSavePost).toHaveBeenCalledWith({ 47 | ...fakePost, 48 | date: expect.any(String), 49 | authorId: fakeUser.id, 50 | }) 51 | expect(mockSavePost).toHaveBeenCalledTimes(1) 52 | 53 | const postDate = new Date().getTime() 54 | const date = new Date(mockSavePost.mock.calls[0][0].date).getTime() 55 | expect(date).toBeGreaterThanOrEqual(preDate) 56 | expect(date).toBeLessThanOrEqual(postDate) 57 | 58 | await waitFor(() => expect(MockRedirect).toHaveBeenCalledWith({to: '/'}, {})) 59 | }) 60 | -------------------------------------------------------------------------------- /src/__tests__/tdd-07-error-state.js: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import {render, screen, waitFor} from '@testing-library/react' 3 | import userEvent from '@testing-library/user-event' 4 | import {build, fake, sequence} from 'test-data-bot' 5 | import {Redirect as MockRedirect} from 'react-router' 6 | import {savePost as mockSavePost} from '../api' 7 | import {Editor} from '../post-editor-07-error-state' 8 | 9 | jest.mock('react-router', () => { 10 | return { 11 | Redirect: jest.fn(() => null), 12 | } 13 | }) 14 | jest.mock('../api') 15 | 16 | afterEach(() => { 17 | jest.clearAllMocks() 18 | }) 19 | 20 | const postBuilder = build('Post').fields({ 21 | title: fake((f) => f.lorem.words()), 22 | content: fake((f) => f.lorem.paragraphs().replace(/\r/g, '')), 23 | tags: fake((f) => [f.lorem.word(), f.lorem.word(), f.lorem.word()]), 24 | }) 25 | 26 | const userBuilder = build('User').fields({ 27 | id: sequence((s) => `user-${s}`), 28 | }) 29 | 30 | test('renders a form with title, content, tags, and a submit button', async () => { 31 | mockSavePost.mockResolvedValueOnce() 32 | const fakeUser = userBuilder() 33 | render() 34 | const fakePost = postBuilder() 35 | const preDate = new Date().getTime() 36 | 37 | screen.getByLabelText(/title/i).value = fakePost.title 38 | screen.getByLabelText(/content/i).value = fakePost.content 39 | screen.getByLabelText(/tags/i).value = fakePost.tags.join(', ') 40 | const submitButton = screen.getByText(/submit/i) 41 | 42 | userEvent.click(submitButton) 43 | 44 | expect(submitButton).toBeDisabled() 45 | 46 | expect(mockSavePost).toHaveBeenCalledWith({ 47 | ...fakePost, 48 | date: expect.any(String), 49 | authorId: fakeUser.id, 50 | }) 51 | expect(mockSavePost).toHaveBeenCalledTimes(1) 52 | 53 | const postDate = new Date().getTime() 54 | const date = new Date(mockSavePost.mock.calls[0][0].date).getTime() 55 | expect(date).toBeGreaterThanOrEqual(preDate) 56 | expect(date).toBeLessThanOrEqual(postDate) 57 | 58 | await waitFor(() => expect(MockRedirect).toHaveBeenCalledWith({to: '/'}, {})) 59 | }) 60 | 61 | test('renders an error message from the server', async () => { 62 | const testError = 'test error' 63 | mockSavePost.mockRejectedValueOnce({data: {error: testError}}) 64 | const fakeUser = userBuilder() 65 | render() 66 | const submitButton = screen.getByText(/submit/i) 67 | 68 | userEvent.click(submitButton) 69 | 70 | const postError = await screen.findByRole('alert') 71 | expect(postError).toHaveTextContent(testError) 72 | expect(submitButton).toBeEnabled() 73 | }) 74 | -------------------------------------------------------------------------------- /src/__tests__/tdd-08-custom-render.js: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import {render, screen, waitFor} from '@testing-library/react' 3 | import userEvent from '@testing-library/user-event' 4 | import {build, fake, sequence} from 'test-data-bot' 5 | import {Redirect as MockRedirect} from 'react-router' 6 | import {savePost as mockSavePost} from '../api' 7 | import {Editor} from '../post-editor-08-custom-render' 8 | 9 | jest.mock('react-router', () => { 10 | return { 11 | Redirect: jest.fn(() => null), 12 | } 13 | }) 14 | jest.mock('../api') 15 | 16 | afterEach(() => { 17 | jest.clearAllMocks() 18 | }) 19 | 20 | const postBuilder = build('Post').fields({ 21 | title: fake((f) => f.lorem.words()), 22 | content: fake((f) => f.lorem.paragraphs().replace(/\r/g, '')), 23 | tags: fake((f) => [f.lorem.word(), f.lorem.word(), f.lorem.word()]), 24 | }) 25 | 26 | const userBuilder = build('User').fields({ 27 | id: sequence((s) => `user-${s}`), 28 | }) 29 | 30 | function renderEditor() { 31 | const fakeUser = userBuilder() 32 | const utils = render() 33 | const fakePost = postBuilder() 34 | 35 | screen.getByLabelText(/title/i).value = fakePost.title 36 | screen.getByLabelText(/content/i).value = fakePost.content 37 | screen.getByLabelText(/tags/i).value = fakePost.tags.join(', ') 38 | const submitButton = screen.getByText(/submit/i) 39 | return { 40 | ...utils, 41 | submitButton, 42 | fakeUser, 43 | fakePost, 44 | } 45 | } 46 | 47 | test('renders a form with title, content, tags, and a submit button', async () => { 48 | mockSavePost.mockResolvedValueOnce() 49 | const {submitButton, fakePost, fakeUser} = renderEditor() 50 | const preDate = new Date().getTime() 51 | 52 | userEvent.click(submitButton) 53 | 54 | expect(submitButton).toBeDisabled() 55 | 56 | expect(mockSavePost).toHaveBeenCalledWith({ 57 | ...fakePost, 58 | date: expect.any(String), 59 | authorId: fakeUser.id, 60 | }) 61 | expect(mockSavePost).toHaveBeenCalledTimes(1) 62 | 63 | const postDate = new Date().getTime() 64 | const date = new Date(mockSavePost.mock.calls[0][0].date).getTime() 65 | expect(date).toBeGreaterThanOrEqual(preDate) 66 | expect(date).toBeLessThanOrEqual(postDate) 67 | 68 | await waitFor(() => expect(MockRedirect).toHaveBeenCalledWith({to: '/'}, {})) 69 | }) 70 | 71 | test('renders an error message from the server', async () => { 72 | const testError = 'test error' 73 | mockSavePost.mockRejectedValueOnce({data: {error: testError}}) 74 | const {submitButton} = renderEditor() 75 | 76 | userEvent.click(submitButton) 77 | 78 | const postError = await screen.findByRole('alert') 79 | expect(postError).toHaveTextContent(testError) 80 | expect(submitButton).toBeEnabled() 81 | }) 82 | -------------------------------------------------------------------------------- /src/__tests__/unmounting.js: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import {render, act} from '@testing-library/react' 3 | import {Countdown} from '../countdown' 4 | 5 | beforeAll(() => { 6 | jest.spyOn(console, 'error').mockImplementation(() => {}) 7 | }) 8 | 9 | afterAll(() => { 10 | console.error.mockRestore() 11 | }) 12 | 13 | afterEach(() => { 14 | jest.clearAllMocks() 15 | jest.useRealTimers() 16 | }) 17 | 18 | test('does not attempt to set state when unmounted (to prevent memory leaks)', () => { 19 | jest.useFakeTimers() 20 | const {unmount} = render() 21 | unmount() 22 | act(() => jest.runOnlyPendingTimers()) 23 | expect(console.error).not.toHaveBeenCalled() 24 | }) 25 | -------------------------------------------------------------------------------- /src/api.js: -------------------------------------------------------------------------------- 1 | function client( 2 | endpoint, 3 | {data, token, headers: customHeaders, ...customConfig} = {}, 4 | ) { 5 | const config = { 6 | method: data ? 'POST' : 'GET', 7 | body: data ? JSON.stringify(data) : undefined, 8 | headers: { 9 | Authorization: token ? `Bearer ${token}` : undefined, 10 | 'Content-Type': data ? 'application/json' : undefined, 11 | ...customHeaders, 12 | }, 13 | ...customConfig, 14 | } 15 | 16 | return window.fetch(`/${endpoint}`, config).then(async (response) => { 17 | const responseData = await response.json() 18 | if (response.ok) { 19 | return responseData 20 | } else { 21 | return Promise.reject(responseData) 22 | } 23 | }) 24 | } 25 | 26 | const savePost = (postData) => client(`post/${postData.id}`, {data: postData}) 27 | const loadGreeting = (subject) => client(`greeting`, {data: {subject}}) 28 | const reportError = (data) => client(`error`, {data}) 29 | const submitForm = (data) => client(`form`, {data}) 30 | 31 | export {savePost, loadGreeting, reportError, submitForm} 32 | -------------------------------------------------------------------------------- /src/app-reach-router.js: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import {Router, Link, navigate} from '@reach/router' 3 | import {submitForm} from './api' 4 | 5 | const MultiPageForm = React.createContext() 6 | 7 | function MultiPageFormProvider({initialValues = {}, ...props}) { 8 | const [initState] = React.useState(initialValues) 9 | const [form, setFormValues] = React.useReducer( 10 | (s, a) => ({...s, ...a}), 11 | initState, 12 | ) 13 | const resetForm = () => setFormValues(initialValues) 14 | return ( 15 | 19 | ) 20 | } 21 | 22 | function useMultiPageForm() { 23 | const context = React.useContext(MultiPageForm) 24 | if (!context) { 25 | throw new Error( 26 | 'useMultiPageForm must be used within a MiltiPageFormProvider', 27 | ) 28 | } 29 | return context 30 | } 31 | 32 | function Main() { 33 | return ( 34 | <> 35 |

Welcome home

36 | Fill out the form 37 | 38 | ) 39 | } 40 | 41 | function Page1() { 42 | const {form, setFormValues} = useMultiPageForm() 43 | return ( 44 | <> 45 |

Page 1

46 |
{ 48 | e.preventDefault() 49 | navigate('/page-2') 50 | }} 51 | > 52 | 53 | setFormValues({food: e.target.value})} 57 | /> 58 |
59 | Go Home | Next 60 | 61 | ) 62 | } 63 | 64 | function Page2() { 65 | const {form, setFormValues} = useMultiPageForm() 66 | return ( 67 | <> 68 |

Page 2

69 |
{ 71 | e.preventDefault() 72 | navigate('/confirm') 73 | }} 74 | > 75 | 76 | setFormValues({drink: e.target.value})} 80 | /> 81 |
82 | Go Back | Review 83 | 84 | ) 85 | } 86 | 87 | function Confirm() { 88 | const {form, resetForm} = useMultiPageForm() 89 | function handleConfirmClick() { 90 | submitForm(form).then( 91 | () => { 92 | resetForm() 93 | navigate('/success') 94 | }, 95 | (error) => { 96 | navigate('/error', {state: {error}}) 97 | }, 98 | ) 99 | } 100 | return ( 101 | <> 102 |

Confirm

103 |
104 | Please confirm your choices 105 |
106 |
107 | Favorite Food:{' '} 108 | {form.food} 109 |
110 |
111 | Favorite Drink:{' '} 112 | {form.drink} 113 |
114 | Go Back |{' '} 115 | 116 | 117 | ) 118 | } 119 | 120 | function Success() { 121 | return ( 122 | <> 123 |

Congrats. You did it.

124 |
125 | Go home 126 |
127 | 128 | ) 129 | } 130 | 131 | function Error({ 132 | location: { 133 | state: {error}, 134 | }, 135 | }) { 136 | return ( 137 | <> 138 |
Oh no. There was an error.
139 |
{error.message}
140 | Go Home 141 | Try again 142 | 143 | ) 144 | } 145 | 146 | function App() { 147 | return ( 148 | 149 | 150 |
151 | 152 | 153 | 154 | 155 | 156 | 157 | 158 | ) 159 | } 160 | 161 | export default App 162 | 163 | /* 164 | eslint 165 | react/no-adjacent-inline-elements: "off", 166 | */ 167 | -------------------------------------------------------------------------------- /src/app.js: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import {BrowserRouter as Router, Route, Link, Switch} from 'react-router-dom' 3 | import {submitForm} from './api' 4 | 5 | const MultiPageForm = React.createContext() 6 | 7 | function MultiPageFormProvider({initialValues = {}, ...props}) { 8 | const [initState] = React.useState(initialValues) 9 | const [form, setFormValues] = React.useReducer( 10 | (s, a) => ({...s, ...a}), 11 | initState, 12 | ) 13 | const resetForm = () => setFormValues(initialValues) 14 | return ( 15 | 19 | ) 20 | } 21 | 22 | function useMultiPageForm() { 23 | const context = React.useContext(MultiPageForm) 24 | if (!context) { 25 | throw new Error( 26 | 'useMultiPageForm must be used within a MultiPageFormProvider', 27 | ) 28 | } 29 | return context 30 | } 31 | 32 | function Main() { 33 | return ( 34 | <> 35 |

Welcome home

36 | Fill out the form 37 | 38 | ) 39 | } 40 | 41 | function Page1({history}) { 42 | const {form, setFormValues} = useMultiPageForm() 43 | return ( 44 | <> 45 |

Page 1

46 |
{ 48 | e.preventDefault() 49 | history.push('/page-2') 50 | }} 51 | > 52 | 53 | setFormValues({food: e.target.value})} 57 | /> 58 |
59 | Go Home | Next 60 | 61 | ) 62 | } 63 | 64 | function Page2({history}) { 65 | const {form, setFormValues} = useMultiPageForm() 66 | return ( 67 | <> 68 |

Page 2

69 |
{ 71 | e.preventDefault() 72 | history.push('/confirm') 73 | }} 74 | > 75 | 76 | setFormValues({drink: e.target.value})} 80 | /> 81 |
82 | Go Back | Review 83 | 84 | ) 85 | } 86 | 87 | function Confirm({history}) { 88 | const {form, resetForm} = useMultiPageForm() 89 | function handleConfirmClick() { 90 | submitForm(form).then( 91 | () => { 92 | resetForm() 93 | history.push('/success') 94 | }, 95 | (error) => { 96 | history.push('/error', {error}) 97 | }, 98 | ) 99 | } 100 | return ( 101 | <> 102 |

Confirm

103 |
104 | Please confirm your choices 105 |
106 |
107 | Favorite Food:{' '} 108 | {form.food} 109 |
110 |
111 | Favorite Drink:{' '} 112 | {form.drink} 113 |
114 | Go Back |{' '} 115 | 116 | 117 | ) 118 | } 119 | 120 | function Success() { 121 | return ( 122 | <> 123 |

Congrats. You did it.

124 |
125 | Go home 126 |
127 | 128 | ) 129 | } 130 | 131 | function Error({ 132 | location: { 133 | state: {error}, 134 | }, 135 | }) { 136 | return ( 137 | <> 138 |
Oh no. There was an error.
139 |
{error.message}
140 | Go Home 141 | Try again 142 | 143 | ) 144 | } 145 | 146 | function App() { 147 | return ( 148 | 149 | 150 | 151 | 152 | 153 | 154 | 155 | 156 | 157 | 158 | 159 | 160 | ) 161 | } 162 | 163 | export default App 164 | 165 | /* 166 | eslint 167 | react/no-adjacent-inline-elements: "off", 168 | */ 169 | -------------------------------------------------------------------------------- /src/countdown.js: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | 3 | function Countdown() { 4 | const [remainingTime, setRemainingTime] = React.useState(10000) 5 | const end = React.useRef(new Date().getTime() + remainingTime) 6 | React.useEffect(() => { 7 | const interval = setInterval(() => { 8 | const newRemainingTime = end.current - new Date().getTime() 9 | if (newRemainingTime <= 0) { 10 | clearInterval(interval) 11 | setRemainingTime(0) 12 | } else { 13 | setRemainingTime(newRemainingTime) 14 | } 15 | }) 16 | return () => clearInterval(interval) 17 | }, []) 18 | return remainingTime 19 | } 20 | 21 | export {Countdown} 22 | -------------------------------------------------------------------------------- /src/error-boundary.js: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import {reportError} from './api' 3 | 4 | class ErrorBoundary extends React.Component { 5 | state = {hasError: false} 6 | componentDidCatch(error, info) { 7 | this.setState({hasError: true}) 8 | reportError(error, info) 9 | } 10 | tryAgain = () => this.setState({hasError: false}) 11 | render() { 12 | return this.state.hasError ? ( 13 |
14 |
There was a problem.
{' '} 15 | 16 |
17 | ) : ( 18 | this.props.children 19 | ) 20 | } 21 | } 22 | 23 | export {ErrorBoundary} 24 | -------------------------------------------------------------------------------- /src/favorite-number.js: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | 3 | function FavoriteNumber({min = 1, max = 9}) { 4 | const [number, setNumber] = React.useState(0) 5 | const [numberEntered, setNumberEntered] = React.useState(false) 6 | function handleChange(event) { 7 | setNumber(Number(event.target.value)) 8 | setNumberEntered(true) 9 | } 10 | const isValid = !numberEntered || (number >= min && number <= max) 11 | return ( 12 |
13 | 14 | 20 | {isValid ? null :
The number is invalid
} 21 |
22 | ) 23 | } 24 | 25 | export {FavoriteNumber} 26 | -------------------------------------------------------------------------------- /src/greeting-loader-01-mocking.js: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import {loadGreeting} from './api' 3 | 4 | function GreetingLoader() { 5 | const [greeting, setGreeting] = React.useState('') 6 | async function loadGreetingForInput(e) { 7 | e.preventDefault() 8 | const {data} = await loadGreeting(e.target.elements.name.value) 9 | setGreeting(data.greeting) 10 | } 11 | return ( 12 |
13 | 14 | 15 | 16 |
{greeting}
17 |
18 | ) 19 | } 20 | 21 | export {GreetingLoader} 22 | -------------------------------------------------------------------------------- /src/greeting-loader-02-dependency-injection.js: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import * as api from './api' 3 | 4 | function GreetingLoader({loadGreeting = api.loadGreeting}) { 5 | const [greeting, setGreeting] = React.useState('') 6 | async function loadGreetingForInput(e) { 7 | e.preventDefault() 8 | const {data} = await loadGreeting(e.target.elements.name.value) 9 | setGreeting(data.greeting) 10 | } 11 | return ( 12 |
13 | 14 | 15 | 16 |
{greeting}
17 |
18 | ) 19 | } 20 | 21 | export {GreetingLoader} 22 | -------------------------------------------------------------------------------- /src/hidden-message.js: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import {CSSTransition} from 'react-transition-group' 3 | 4 | function Fade(props) { 5 | return ( 6 | 7 | ) 8 | } 9 | 10 | function HiddenMessage({children}) { 11 | const [show, setShow] = React.useState(false) 12 | const toggle = () => setShow((s) => !s) 13 | return ( 14 |
15 | 16 | 17 |
{children}
18 |
19 |
20 | ) 21 | } 22 | 23 | export {HiddenMessage} 24 | -------------------------------------------------------------------------------- /src/main.js: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import {Switch, Route, Link} from 'react-router-dom' 3 | 4 | const About = () => ( 5 |
6 |

About

7 |

You are on the about page

8 |
9 | ) 10 | const Home = () => ( 11 |
12 |

Home

13 |

You are home

14 |
15 | ) 16 | const NoMatch = () => ( 17 |
18 |

404

19 |

No match

20 |
21 | ) 22 | 23 | function Main() { 24 | return ( 25 |
26 | Home 27 | About 28 | 29 | 30 | 31 | 32 | 33 |
34 | ) 35 | } 36 | 37 | export {Main} 38 | -------------------------------------------------------------------------------- /src/modal.js: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import ReactDOM from 'react-dom' 3 | 4 | let modalRoot = document.getElementById('modal-root') 5 | if (!modalRoot) { 6 | modalRoot = document.createElement('div') 7 | modalRoot.setAttribute('id', 'modal-root') 8 | document.body.appendChild(modalRoot) 9 | } 10 | 11 | // don't use this for your modals. 12 | // you need to think about accessibility and styling. 13 | // Look into: https://ui.reach.tech/dialog 14 | function Modal({children}) { 15 | const el = React.useRef(document.createElement('div')) 16 | React.useLayoutEffect(() => { 17 | const currentEl = el.current 18 | modalRoot.appendChild(currentEl) 19 | return () => modalRoot.removeChild(currentEl) 20 | }, []) 21 | return ReactDOM.createPortal(children, el.current) 22 | } 23 | 24 | export {Modal} 25 | -------------------------------------------------------------------------------- /src/post-editor-01-markup.js: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | 3 | function Editor() { 4 | return ( 5 |
6 | 7 | 8 | 9 | 10 |