├── .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 |
4 |
5 |
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 |
12 | )
13 | }
14 |
15 | function AccessibleForm() {
16 | return (
17 |
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 |
59 | Go Home | Next
60 | >
61 | )
62 | }
63 |
64 | function Page2() {
65 | const {form, setFormValues} = useMultiPageForm()
66 | return (
67 | <>
68 | Page 2
69 |
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 |
59 | Go Home | Next
60 | >
61 | )
62 | }
63 |
64 | function Page2({history}) {
65 | const {form, setFormValues} = useMultiPageForm()
66 | return (
67 | <>
68 | Page 2
69 |
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 |
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 |
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 |
17 | )
18 | }
19 |
20 | export {Editor}
21 |
--------------------------------------------------------------------------------
/src/post-editor-02-state.js:
--------------------------------------------------------------------------------
1 | import * as React from 'react'
2 |
3 | function Editor() {
4 | const [isSaving, setIsSaving] = React.useState(false)
5 | function handleSubmit(e) {
6 | e.preventDefault()
7 | setIsSaving(true)
8 | }
9 | return (
10 |
24 | )
25 | }
26 |
27 | export {Editor}
28 |
--------------------------------------------------------------------------------
/src/post-editor-03-api.js:
--------------------------------------------------------------------------------
1 | import * as React from 'react'
2 | import {savePost} from './api'
3 |
4 | function Editor({user}) {
5 | const [isSaving, setIsSaving] = React.useState(false)
6 | function handleSubmit(e) {
7 | e.preventDefault()
8 | const {title, content, tags} = e.target.elements
9 | const newPost = {
10 | title: title.value,
11 | content: content.value,
12 | tags: tags.value.split(',').map((t) => t.trim()),
13 | authorId: user.id,
14 | }
15 | setIsSaving(true)
16 | savePost(newPost)
17 | }
18 | return (
19 |
33 | )
34 | }
35 |
36 | export {Editor}
37 |
--------------------------------------------------------------------------------
/src/post-editor-04-router-redirect.js:
--------------------------------------------------------------------------------
1 | import * as React from 'react'
2 | import {Redirect} from 'react-router'
3 | import {savePost} from './api'
4 |
5 | function Editor({user}) {
6 | const [isSaving, setIsSaving] = React.useState(false)
7 | const [redirect, setRedirect] = React.useState(false)
8 | function handleSubmit(e) {
9 | e.preventDefault()
10 | const {title, content, tags} = e.target.elements
11 | const newPost = {
12 | title: title.value,
13 | content: content.value,
14 | tags: tags.value.split(',').map((t) => t.trim()),
15 | authorId: user.id,
16 | }
17 | setIsSaving(true)
18 | savePost(newPost).then(() => setRedirect(true))
19 | }
20 | if (redirect) {
21 | return
22 | }
23 | return (
24 |
38 | )
39 | }
40 |
41 | export {Editor}
42 |
--------------------------------------------------------------------------------
/src/post-editor-05-dates.js:
--------------------------------------------------------------------------------
1 | import * as React from 'react'
2 | import {Redirect} from 'react-router'
3 | import {savePost} from './api'
4 |
5 | function Editor({user}) {
6 | const [isSaving, setIsSaving] = React.useState(false)
7 | const [redirect, setRedirect] = React.useState(false)
8 | function handleSubmit(e) {
9 | e.preventDefault()
10 | const {title, content, tags} = e.target.elements
11 | const newPost = {
12 | title: title.value,
13 | content: content.value,
14 | tags: tags.value.split(',').map((t) => t.trim()),
15 | date: new Date().toISOString(),
16 | authorId: user.id,
17 | }
18 | setIsSaving(true)
19 | savePost(newPost).then(() => setRedirect(true))
20 | }
21 | if (redirect) {
22 | return
23 | }
24 | return (
25 |
39 | )
40 | }
41 |
42 | export {Editor}
43 |
--------------------------------------------------------------------------------
/src/post-editor-06-generate-data.js:
--------------------------------------------------------------------------------
1 | import * as React from 'react'
2 | import {Redirect} from 'react-router'
3 | import {savePost} from './api'
4 |
5 | function Editor({user}) {
6 | const [isSaving, setIsSaving] = React.useState(false)
7 | const [redirect, setRedirect] = React.useState(false)
8 | function handleSubmit(e) {
9 | e.preventDefault()
10 | const {title, content, tags} = e.target.elements
11 | const newPost = {
12 | title: title.value,
13 | content: content.value,
14 | tags: tags.value.split(',').map((t) => t.trim()),
15 | date: new Date().toISOString(),
16 | authorId: user.id,
17 | }
18 | setIsSaving(true)
19 | savePost(newPost).then(() => setRedirect(true))
20 | }
21 | if (redirect) {
22 | return
23 | }
24 | return (
25 |
39 | )
40 | }
41 |
42 | export {Editor}
43 |
--------------------------------------------------------------------------------
/src/post-editor-07-error-state.js:
--------------------------------------------------------------------------------
1 | import * as React from 'react'
2 | import {Redirect} from 'react-router'
3 | import {savePost} from './api'
4 |
5 | function Editor({user}) {
6 | const [isSaving, setIsSaving] = React.useState(false)
7 | const [redirect, setRedirect] = React.useState(false)
8 | const [error, setError] = React.useState(null)
9 | function handleSubmit(e) {
10 | e.preventDefault()
11 | const {title, content, tags} = e.target.elements
12 | const newPost = {
13 | title: title.value,
14 | content: content.value,
15 | tags: tags.value.split(',').map((t) => t.trim()),
16 | date: new Date().toISOString(),
17 | authorId: user.id,
18 | }
19 | setIsSaving(true)
20 | savePost(newPost).then(
21 | () => setRedirect(true),
22 | (response) => {
23 | setIsSaving(false)
24 | setError(response.data.error)
25 | },
26 | )
27 | }
28 | if (redirect) {
29 | return
30 | }
31 | return (
32 |
47 | )
48 | }
49 |
50 | export {Editor}
51 |
--------------------------------------------------------------------------------
/src/post-editor-08-custom-render.js:
--------------------------------------------------------------------------------
1 | import * as React from 'react'
2 | import {Redirect} from 'react-router'
3 | import {savePost} from './api'
4 |
5 | function Editor({user}) {
6 | const [isSaving, setIsSaving] = React.useState(false)
7 | const [redirect, setRedirect] = React.useState(false)
8 | const [error, setError] = React.useState(null)
9 | function handleSubmit(e) {
10 | e.preventDefault()
11 | const {title, content, tags} = e.target.elements
12 | const newPost = {
13 | title: title.value,
14 | content: content.value,
15 | tags: tags.value.split(',').map((t) => t.trim()),
16 | date: new Date().toISOString(),
17 | authorId: user.id,
18 | }
19 | setIsSaving(true)
20 | savePost(newPost).then(
21 | () => setRedirect(true),
22 | (response) => {
23 | setIsSaving(false)
24 | setError(response.data.error)
25 | },
26 | )
27 | }
28 | if (redirect) {
29 | return
30 | }
31 | return (
32 |
47 | )
48 | }
49 |
50 | export {Editor}
51 |
--------------------------------------------------------------------------------
/src/redux-counter.js:
--------------------------------------------------------------------------------
1 | import * as React from 'react'
2 | import {useSelector, useDispatch} from 'react-redux'
3 |
4 | function Counter() {
5 | const count = useSelector((state) => state.count)
6 | const dispatch = useDispatch()
7 | const increment = () => dispatch({type: 'INCREMENT'})
8 | const decrement = () => dispatch({type: 'DECREMENT'})
9 | return (
10 |
11 |
Counter
12 |
13 |
14 | {count}
15 |
16 |
17 |
18 | )
19 | }
20 |
21 | export {Counter}
22 |
--------------------------------------------------------------------------------
/src/redux-reducer.js:
--------------------------------------------------------------------------------
1 | const initialState = {count: 0}
2 | function reducer(state = initialState, action) {
3 | switch (action.type) {
4 | case 'INCREMENT':
5 | return {
6 | count: state.count + 1,
7 | }
8 | case 'DECREMENT':
9 | return {
10 | count: state.count - 1,
11 | }
12 | default:
13 | return state
14 | }
15 | }
16 |
17 | export {reducer}
18 |
--------------------------------------------------------------------------------
/src/redux-store.js:
--------------------------------------------------------------------------------
1 | import {createStore} from 'redux'
2 | import {reducer} from './redux-reducer'
3 |
4 | const store = createStore(reducer)
5 |
6 | export {store}
7 |
--------------------------------------------------------------------------------
/src/use-counter.js:
--------------------------------------------------------------------------------
1 | import * as React from 'react'
2 |
3 | function useCounter({initialCount = 0, step = 1} = {}) {
4 | const [count, setCount] = React.useState(initialCount)
5 | const increment = () => setCount((c) => c + step)
6 | const decrement = () => setCount((c) => c - step)
7 | return {count, increment, decrement}
8 | }
9 |
10 | export {useCounter}
11 |
--------------------------------------------------------------------------------
/tests/setup-env.js:
--------------------------------------------------------------------------------
1 | import '@testing-library/jest-dom/extend-expect'
2 | import 'jest-axe/extend-expect'
3 |
--------------------------------------------------------------------------------