├── .dockerignore ├── .eslintignore ├── .eslintrc.js ├── .eslintrc.production.js ├── .github └── workflows │ ├── storybook.yml │ └── website.yml ├── .gitignore ├── .prettierrc ├── .storybook ├── main.js ├── preview.ts └── providers.tsx ├── .testcaferc.json ├── .vscode └── settings.json ├── Dockerfile ├── LICENCE ├── README.md ├── config-overrides.js ├── e2e ├── __pages__ │ └── HomePage.ts └── specs │ └── HomePage.e2e.ts ├── package.json ├── public ├── favicon.ico ├── index.html └── manifest.json ├── scripts └── run.sh ├── src ├── __mocks__ │ └── ky.ts ├── components │ ├── Loading │ │ ├── Loading.spec.tsx │ │ ├── Loading.stories.tsx │ │ ├── Loading.tsx │ │ └── index.tsx │ ├── Navbar │ │ ├── Navbar.spec.tsx │ │ ├── Navbar.stories.tsx │ │ ├── Navbar.tsx │ │ └── index.tsx │ └── PageRoute │ │ ├── PageRoute.tsx │ │ └── index.tsx ├── constants │ └── navigation.ts ├── containers │ └── AppProviders │ │ ├── AppProviders.tsx │ │ └── index.ts ├── hooks │ └── useSelector.ts ├── index.tsx ├── modules │ └── coinmarketcap │ │ └── index.ts ├── pages │ ├── Home │ │ ├── HomePage.spec.tsx │ │ ├── HomePage.stories.tsx │ │ ├── HomePage.tsx │ │ └── index.tsx │ ├── Ticker │ │ ├── Ticker.spec.tsx │ │ ├── Ticker.stories.tsx │ │ ├── Ticker.tsx │ │ ├── index.ts │ │ └── useTicker.ts │ └── index.tsx ├── react-app-env.d.ts ├── reducers │ ├── auth │ │ ├── auth.spec.ts │ │ ├── auth.ts │ │ └── index.ts │ └── index.ts ├── setupTests.ts ├── storybook │ └── utils │ │ ├── configureStory.ts │ │ ├── index.ts │ │ ├── mockDecorator.ts │ │ └── useSandbox.ts ├── test │ └── utils │ │ ├── configure.ts │ │ ├── index.ts │ │ ├── render.tsx │ │ └── sandbox.ts ├── types │ └── utils.ts ├── typings │ └── images.d.ts └── utils │ ├── history.ts │ └── store.ts ├── tsconfig.json └── yarn.lock /.dockerignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | coverage 3 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | react-app-env.d.ts 2 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: './.eslintrc.production.js', 3 | // We can relax some settings here for nicer development experience; warnings will crash in CI 4 | rules: { 5 | '@typescript-eslint/no-unused-vars': [ 6 | 'warn', 7 | { argsIgnorePattern: '^_', varsIgnorePattern: '^_' }, 8 | ], 9 | }, 10 | }; 11 | -------------------------------------------------------------------------------- /.eslintrc.production.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: [ 3 | 'eslint:recommended', 4 | 'airbnb', 5 | 'prettier', 6 | 'prettier/react', 7 | 'plugin:jest/recommended', 8 | 'plugin:testing-library/react', 9 | 'plugin:lodash/recommended', 10 | 'plugin:testcafe/recommended', 11 | 'plugin:@typescript-eslint/recommended', 12 | ], 13 | plugins: [ 14 | 'react', 15 | 'prettier', 16 | 'jest', 17 | 'react-hooks', 18 | 'import', 19 | 'testing-library', 20 | 'lodash', 21 | 'testcafe', 22 | ], 23 | env: { 24 | browser: true, 25 | jest: true, 26 | node: true, 27 | es6: true, 28 | }, 29 | rules: { 30 | 'no-unused-vars': 'off', 31 | 32 | 'import/no-extraneous-dependencies': ['off'], 33 | 'import/extensions': ['off'], 34 | 'import/prefer-default-export': ['off'], 35 | 36 | 'react/jsx-filename-extension': [1, { extensions: ['.tsx'] }], 37 | 'react/prop-types': ['off'], 38 | 'react/jsx-props-no-spreading': ['off'], 39 | 40 | 'lodash/prefer-lodash-method': ['off'], 41 | 42 | 'react-hooks/rules-of-hooks': 'error', 43 | 'react-hooks/exhaustive-deps': 'warn', 44 | 45 | '@typescript-eslint/explicit-function-return-type': ['off'], 46 | '@typescript-eslint/no-unused-vars': [ 47 | 'error', 48 | { argsIgnorePattern: '^_', varsIgnorePattern: '^_' }, 49 | ], 50 | }, 51 | overrides: [ 52 | { 53 | files: ['*.e2e.ts'], 54 | rules: { 55 | 'jest/expect-expect': ['off'], 56 | 'jest/no-test-callback': ['off'], 57 | }, 58 | }, 59 | ], 60 | settings: { 61 | react: { 62 | pragma: 'React', 63 | version: 'detect', 64 | }, 65 | 'import/resolver': { 66 | typescript: {}, // this loads /tsconfig.json to eslint 67 | }, 68 | }, 69 | parser: '@typescript-eslint/parser', 70 | }; 71 | -------------------------------------------------------------------------------- /.github/workflows/storybook.yml: -------------------------------------------------------------------------------- 1 | name: storybook 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | 8 | jobs: 9 | storybook-deploy: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v1 13 | - name: Setup Node 14 | uses: actions/setup-node@v1 15 | with: 16 | node-version: '12.x' 17 | - name: Get yarn cache 18 | id: yarn-cache 19 | run: echo "::set-output name=dir::$(yarn cache dir)" 20 | - name: Cache dependencies 21 | uses: actions/cache@v1 22 | with: 23 | path: ${{ steps.yarn-cache.outputs.dir }} 24 | key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }} 25 | restore-keys: | 26 | ${{ runner.os }}-yarn- 27 | 28 | - name: Install dependencies & build Storybook 29 | run: | 30 | yarn install 31 | yarn storybook:build 32 | 33 | - name: deploy 34 | uses: peaceiris/actions-gh-pages@v2 35 | env: 36 | PERSONAL_TOKEN: ${{ secrets.PERSONAL_TOKEN }} 37 | PUBLISH_BRANCH: gh-pages 38 | PUBLISH_DIR: ./storybook-static 39 | -------------------------------------------------------------------------------- /.github/workflows/website.yml: -------------------------------------------------------------------------------- 1 | name: website 2 | on: [push] 3 | 4 | jobs: 5 | test_unit: 6 | name: Unit tests 7 | runs-on: ubuntu-latest 8 | 9 | steps: 10 | - uses: actions/checkout@v1 11 | with: 12 | fetch-depth: 1 13 | - uses: actions/setup-node@v1 14 | with: 15 | node-version: '12.x' 16 | - name: npm install, lint and unit test 17 | run: | 18 | yarn install 19 | yarn lint 20 | yarn test --coverage 21 | env: 22 | CI: true 23 | - name: Upload coverage to Codecov 24 | uses: codecov/codecov-action@v1 25 | with: 26 | token: ${{ secrets.CODECOV_TOKEN }} 27 | 28 | test_e2e: 29 | name: E2E tests 30 | runs-on: ubuntu-latest 31 | 32 | steps: 33 | - uses: actions/checkout@v1 34 | with: 35 | fetch-depth: 1 36 | - uses: actions/setup-node@v1 37 | with: 38 | node-version: '12.x' 39 | - name: Install Packages & Build App 40 | run: | 41 | yarn install 42 | yarn build 43 | - name: Serve App 44 | run: | 45 | mkdir -p artifacts/testface 46 | yarn serve -s build -l 3000 2>&1 | tee artifacts/testface/serve.log & 47 | node_modules/wait-on/bin/wait-on http-get://localhost:3000 --timeout 60000 48 | - uses: DevExpress/testcafe-action@latest 49 | with: 50 | args: 'chrome:headless e2e/specs' 51 | - uses: actions/upload-artifact@v1 52 | if: failure() 53 | with: 54 | name: testface-screenshots 55 | path: artifacts/testcafe 56 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/ignore-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | node_modules 5 | 6 | # testing 7 | coverage 8 | 9 | # production 10 | build 11 | 12 | # misc 13 | .DS_Store 14 | .env.local 15 | .env.development.local 16 | .env.test.local 17 | .env.production.local 18 | 19 | npm-debug.log* 20 | yarn-debug.log* 21 | yarn-error.log* 22 | package-lock.json 23 | 24 | # storybook 25 | storybook-static 26 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "trailingComma": "es5", 3 | "singleQuote": true 4 | } 5 | -------------------------------------------------------------------------------- /.storybook/main.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | stories: ['../src/**/*.stories.tsx'], 3 | addons: ['@storybook/preset-create-react-app'], 4 | }; 5 | -------------------------------------------------------------------------------- /.storybook/preview.ts: -------------------------------------------------------------------------------- 1 | import { addDecorator, configure } from '@storybook/react'; 2 | import withProviders from './providers'; 3 | 4 | addDecorator(withProviders); 5 | -------------------------------------------------------------------------------- /.storybook/providers.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { makeDecorator } from '@storybook/addons'; 3 | 4 | import { 5 | createTestBrowserHistory, 6 | createTestStore, 7 | BaseRenderOptions, 8 | } from '../src/test/utils/configure'; 9 | import AppProviders from '../src/containers/AppProviders'; 10 | 11 | const APP_PROVIDERS_PARAMETER_NAME = 'withAppProviders'; 12 | 13 | type CustomWrapperOptions = { 14 | parameters: BaseRenderOptions; 15 | }; 16 | 17 | export default makeDecorator({ 18 | name: 'AppProviders', 19 | parameterName: APP_PROVIDERS_PARAMETER_NAME, 20 | skipIfNoParametersOrOptions: false, 21 | wrapper: (story, context, { parameters = {} }: CustomWrapperOptions) => { 22 | const store = createTestStore(parameters.redux); 23 | const history = createTestBrowserHistory(parameters.pathname); 24 | 25 | return ( 26 | 27 | {story(context)} 28 | 29 | ); 30 | }, 31 | }); 32 | -------------------------------------------------------------------------------- /.testcaferc.json: -------------------------------------------------------------------------------- 1 | { 2 | "quarantineMode": true, 3 | "skipJsErrors": true, 4 | "screenshots": { 5 | "takeOnFails": true, 6 | "pathPattern": "${DATE}_${TIME}/test-${TEST_INDEX}/${USERAGENT}/${FILE_INDEX}.png", 7 | "path": "artifacts/testcafe/screenshots/" 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "typescript.tsdk": "node_modules/typescript/lib", 3 | "files.insertFinalNewline": true, 4 | "editor.codeActionsOnSave": { 5 | "source.fixAll.eslint": true 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:13-alpine 2 | 3 | RUN apk add --no-cache bash 4 | 5 | ADD yarn.lock /yarn.lock 6 | ADD package.json /package.json 7 | 8 | ENV NODE_PATH=/node_modules 9 | ENV PATH=$PATH:/node_modules/.bin 10 | 11 | WORKDIR /app 12 | ADD ./src /app/src 13 | ADD ./*.json /app/ 14 | ADD ./yarn.lock /app 15 | ADD ./public /app/public 16 | ADD ./scripts /app/scripts 17 | RUN yarn install --frozen-lockfile --production --ignore-scripts --prefer-offline 18 | 19 | EXPOSE 3000 20 | EXPOSE 35729 21 | 22 | ENTRYPOINT ["/bin/bash", "/app/scripts/run.sh"] 23 | CMD ["start"] 24 | -------------------------------------------------------------------------------- /LICENCE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) Microsoft Corporation. All rights reserved. 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | 3 |

4 | React Redux Typescript Boilerplate 5 |

6 | 7 | 8 | 9 |

10 | 11 | 12 | Github Action 13 | 14 | 15 | 16 | Make a pull request 17 | 18 | 19 | 20 | Open Source 21 | 22 | 23 | 24 | TestCafe 25 | 26 | 27 | 28 | Codecov 29 | 30 | 31 |

32 | 33 | Opinionated `react-redux-typescript-boilerplate` with focus on best practices and painless developer experience. 34 | 35 | > This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app). 36 | 37 | # Features 38 | 39 | - State Management with [Redux Toolkit](https://redux-toolkit.js.org/) 40 | - Linting with [prettier](https://github.com/prettier/prettier) and [eslint](https://eslint.org/) 41 | - Up to date [Storybook](https://meemaw.github.io/react-redux-typescript-boilerplate) 42 | - [Docker](https://www.docker.com/) support 43 | - Code splitting with [React Suspense](https://reactjs.org/docs/code-splitting.html) 44 | - CI integration with [Github Actions](https://github.com/actions) 45 | - Unit testing with [Jest](https://jestjs.io/) and [RTL](https://testing-library.com/docs/react-testing-library/intro) 46 | - E2E testing with [Testcafe](https://devexpress.github.io/testcafe/) 47 | 48 | # Getting started 49 | 50 | ## Locally 51 | 52 | ```sh 53 | ➜ yarn install // install dependencies 54 | ➜ yarn start // start the app 55 | ➜ yarn build // build the app 56 | ➜ yarn test // run unit tests 57 | ➜ yarn test:e2e // run e2e tests 58 | ``` 59 | 60 | ## Docker 61 | 62 | ```sh 63 | ➜ docker build . -t react:app // build the react docker image 64 | ➜ docker run -it -p 3000:3000 react:app // runs react app on port 3000 65 | ➜ docker container run -it -p 3000:3000 -p 35729:35729 -v $(pwd):/app react:app // runs react app with hot realoding 66 | ➜ docker container run -it -v $(pwd):/app react:app test // runs tests inside docker 67 | ``` 68 | -------------------------------------------------------------------------------- /config-overrides.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-var-requires */ 2 | const { override } = require('customize-cra'); 3 | const { addReactRefresh } = require('customize-cra-react-refresh'); 4 | 5 | /* config-overrides.js */ 6 | module.exports = override(addReactRefresh({ disableRefreshCheck: true })); 7 | -------------------------------------------------------------------------------- /e2e/__pages__/HomePage.ts: -------------------------------------------------------------------------------- 1 | class HomePage { 2 | headerSelector = 'h1'; 3 | } 4 | 5 | export default HomePage; 6 | -------------------------------------------------------------------------------- /e2e/specs/HomePage.e2e.ts: -------------------------------------------------------------------------------- 1 | import { Selector } from 'testcafe'; 2 | 3 | import HomePage from '../__pages__/HomePage'; 4 | 5 | const baseUrl = 'http://localhost:3000/'; 6 | 7 | const homePage = new HomePage(); 8 | 9 | fixture('').page(baseUrl); 10 | 11 | test('Matches correct Home header', async (t) => { 12 | const header = await Selector(homePage.headerSelector); 13 | await t.expect(header.innerText).eql('React Redux Typescript Boilerplate'); 14 | }); 15 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-redux-typescript-boilerplate", 3 | "version": "0.0.0-development", 4 | "author": "Meemaw ", 5 | "license": "MIT", 6 | "engines": { 7 | "node": "^8.10.0 || ^10.13.0 || >=11.10.1" 8 | }, 9 | "repository": { 10 | "type": "git", 11 | "url": "https://github.com/Meemaw/react-redux-typescript-boilerplate" 12 | }, 13 | "bugs": { 14 | "url": "https://github.com/Meemaw/react-redux-typescript-boilerplate/issues" 15 | }, 16 | "scripts": { 17 | "start": "EXTEND_ESLINT=true react-app-rewired start", 18 | "build": "react-app-rewired build", 19 | "test": "react-app-rewired test", 20 | "test:e2e": "testcafe chrome e2e/specs", 21 | "lint": "concurrently \"npm run prettier\" \"npm run eslint\"", 22 | "eslint": "eslint --max-warnings 0 'src/**/*.{ts,tsx}' 'e2e/**/*.{ts,tsx}' --config .eslintrc.production.js", 23 | "prettier": "prettier -l 'src/**/*' 'e2e/**/*'", 24 | "prettier:fix": "yarn prettier --write", 25 | "storybook": "start-storybook -p 6006 -s public", 26 | "storybook:build": "build-storybook" 27 | }, 28 | "dependencies": { 29 | "@reduxjs/toolkit": "^1.3.1", 30 | "ky": "^0.19.0", 31 | "react": "^16.13.1", 32 | "react-dom": "^16.13.1", 33 | "react-redux": "^7.2.0", 34 | "react-router-dom": "^5.1.2", 35 | "redux": "^4.0.5" 36 | }, 37 | "devDependencies": { 38 | "@storybook/addons": "^5.3.17", 39 | "@storybook/preset-create-react-app": "^2.1.1", 40 | "@storybook/react": "^5.3.17", 41 | "@testing-library/jest-dom": "^5.3.0", 42 | "@testing-library/react": "^9.4.0", 43 | "@types/lodash": "^4.14.149", 44 | "@types/react": "^16.9.26", 45 | "@types/react-dom": "^16.9.5", 46 | "@types/react-redux": "^7.1.7", 47 | "@types/react-router-dom": "^5.1.3", 48 | "@types/sinon": "^7.5.2", 49 | "@typescript-eslint/eslint-plugin": "^2.25.0", 50 | "@typescript-eslint/parser": "^2.25.0", 51 | "babel-jest": "24.9.0", 52 | "concurrently": "^5.1.0", 53 | "customize-cra": "^0.9.1", 54 | "customize-cra-react-refresh": "^1.0.1", 55 | "eslint": "^6.8.0", 56 | "eslint-config-airbnb": "^18.1.0", 57 | "eslint-config-prettier": "^6.10.1", 58 | "eslint-import-resolver-typescript": "^2.0.0", 59 | "eslint-plugin-import": "^2.20.1", 60 | "eslint-plugin-jest": "^23.8.2", 61 | "eslint-plugin-lodash": "^6.0.0", 62 | "eslint-plugin-prettier": "^3.1.2", 63 | "eslint-plugin-testcafe": "^0.2.1", 64 | "eslint-plugin-testing-library": "^2.2.3", 65 | "husky": "^4.2.3", 66 | "prettier": "^2.0.2", 67 | "pretty-quick": "^2.0.1", 68 | "react-app-rewired": "^2.1.5", 69 | "react-scripts": "3.4.1", 70 | "serve": "^11.3.0", 71 | "sinon": "^9.0.1", 72 | "testcafe": "^1.8.3", 73 | "typescript": "^3.7.5", 74 | "wait-on": "^4.0.1" 75 | }, 76 | "husky": { 77 | "hooks": { 78 | "pre-commit": "pretty-quick --staged" 79 | } 80 | }, 81 | "browserslist": [ 82 | ">0.2%", 83 | "not dead", 84 | "not ie <= 11", 85 | "not op_mini all" 86 | ] 87 | } 88 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Meemaw/react-redux-typescript-boilerplate/c0ef201977b0998c54a0ce5de424df16a258ac81/public/favicon.ico -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 9 | 10 | 11 | 12 | 13 | 14 | React App 15 | 16 | 17 | 18 | 21 |
22 | 23 | 24 | -------------------------------------------------------------------------------- /public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "React Typescript Boilerplate", 3 | "name": "React Typescript Boilerplat Sample", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | } 10 | ], 11 | "start_url": "./index.html", 12 | "display": "standalone", 13 | "theme_color": "#000000", 14 | "background_color": "#ffffff" 15 | } 16 | -------------------------------------------------------------------------------- /scripts/run.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -eo pipefail 3 | 4 | case $1 in 5 | start) 6 | # The '| cat' is to trick Node that this is an non-TTY terminal 7 | # then react-scripts won't clear the console. 8 | npm start | cat 9 | ;; 10 | build) 11 | npm run build 12 | ;; 13 | test) 14 | npm test $@ 15 | ;; 16 | *) 17 | exec "$@" 18 | ;; 19 | esac 20 | -------------------------------------------------------------------------------- /src/__mocks__/ky.ts: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line @typescript-eslint/no-var-requires 2 | import ky from 'ky/umd'; 3 | 4 | export default ky; 5 | -------------------------------------------------------------------------------- /src/components/Loading/Loading.spec.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { render } from 'test/utils'; 3 | 4 | import { Base } from './Loading.stories'; 5 | 6 | describe('', () => { 7 | it('Should render loading text', () => { 8 | const { queryByText } = render(); 9 | expect(queryByText('Loading...')).toBeInTheDocument(); 10 | }); 11 | }); 12 | -------------------------------------------------------------------------------- /src/components/Loading/Loading.stories.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import Loading from './Loading'; 4 | 5 | export default { 6 | title: 'Loading', 7 | }; 8 | 9 | export const Base = () => { 10 | return ; 11 | }; 12 | -------------------------------------------------------------------------------- /src/components/Loading/Loading.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | const Loading = () =>
Loading...
; 4 | 5 | export default React.memo(Loading); 6 | -------------------------------------------------------------------------------- /src/components/Loading/index.tsx: -------------------------------------------------------------------------------- 1 | export { default } from './Loading'; 2 | -------------------------------------------------------------------------------- /src/components/Navbar/Navbar.spec.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { render } from 'test/utils'; 3 | import { fireEvent } from '@testing-library/react'; 4 | import * as Paths from 'constants/navigation'; 5 | 6 | import { Base } from './Navbar.stories'; 7 | 8 | describe('', () => { 9 | it('Can navigate between Ticker and Home page', () => { 10 | const { getByText } = render(); 11 | expect(window.location.pathname).toEqual(Paths.ROOT_PATH); 12 | 13 | fireEvent.click(getByText('Ticker')); 14 | expect(window.location.pathname).toEqual(Paths.TICKER_PATH); 15 | 16 | fireEvent.click(getByText('Home')); 17 | expect(window.location.pathname).toEqual(Paths.ROOT_PATH); 18 | }); 19 | }); 20 | -------------------------------------------------------------------------------- /src/components/Navbar/Navbar.stories.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import Navbar from './Navbar'; 4 | 5 | export default { 6 | title: 'Navbar', 7 | }; 8 | 9 | export const Base = () => { 10 | return ; 11 | }; 12 | -------------------------------------------------------------------------------- /src/components/Navbar/Navbar.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Link } from 'react-router-dom'; 3 | 4 | const Navbar = () => { 5 | return ( 6 |
7 | Home 8 | 9 | Ticker 10 | 11 |
12 | ); 13 | }; 14 | 15 | export default React.memo(Navbar); 16 | -------------------------------------------------------------------------------- /src/components/Navbar/index.tsx: -------------------------------------------------------------------------------- 1 | export { default } from './Navbar'; 2 | -------------------------------------------------------------------------------- /src/components/PageRoute/PageRoute.tsx: -------------------------------------------------------------------------------- 1 | import React, { Suspense } from 'react'; 2 | import { RouteProps, RouteComponentProps, Route } from 'react-router'; 3 | import Loading from 'components/Loading'; 4 | 5 | const PageRoute = ({ component: Component, render, ...rest }: RouteProps) => { 6 | const fallbackRenderFn = (props: RouteComponentProps) => { 7 | if (!Component) { 8 | // eslint-disable-next-line no-console 9 | console.error('[PageRoute]: component is expected!'); 10 | return null; 11 | } 12 | 13 | return ; 14 | }; 15 | 16 | return ; 17 | }; 18 | 19 | type SuspendedRouteComponentProps = RouteComponentProps & { 20 | component: React.ComponentType; 21 | }; 22 | 23 | const SuspendedRouteComponent = ({ 24 | component: Component, 25 | ...props 26 | }: SuspendedRouteComponentProps) => { 27 | return ( 28 | }> 29 | 30 | 31 | ); 32 | }; 33 | 34 | export default PageRoute; 35 | -------------------------------------------------------------------------------- /src/components/PageRoute/index.tsx: -------------------------------------------------------------------------------- 1 | export { default } from './PageRoute'; 2 | -------------------------------------------------------------------------------- /src/constants/navigation.ts: -------------------------------------------------------------------------------- 1 | export const ROOT_PATH = '/'; 2 | export const TICKER_PATH = '/ticker'; 3 | -------------------------------------------------------------------------------- /src/containers/AppProviders/AppProviders.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Provider } from 'react-redux'; 3 | import { Router } from 'react-router-dom'; 4 | import { History } from 'history'; 5 | import configureStore from 'utils/store'; 6 | 7 | type Props = { 8 | children: React.ReactNode; 9 | history: History; 10 | store: ReturnType; 11 | }; 12 | 13 | const AppProvider = ({ children, history, store }: Props) => { 14 | return ( 15 | 16 | {children} 17 | 18 | ); 19 | }; 20 | 21 | export default AppProvider; 22 | -------------------------------------------------------------------------------- /src/containers/AppProviders/index.ts: -------------------------------------------------------------------------------- 1 | export { default } from './AppProviders'; 2 | -------------------------------------------------------------------------------- /src/hooks/useSelector.ts: -------------------------------------------------------------------------------- 1 | import { useSelector } from 'react-redux'; 2 | import { RootState } from 'utils/store'; 3 | 4 | export default ( 5 | selector: (state: T) => TSelected, 6 | equalityFn?: (left: TSelected, right: TSelected) => boolean 7 | ) => { 8 | return useSelector(selector, equalityFn); 9 | }; 10 | -------------------------------------------------------------------------------- /src/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import AppProviders from 'containers/AppProviders'; 4 | import App from 'pages'; 5 | import createBrowserHistory from 'utils/history'; 6 | import configureStore from 'utils/store'; 7 | 8 | ReactDOM.render( 9 | 10 | 11 | , 12 | document.getElementById('root') 13 | ); 14 | -------------------------------------------------------------------------------- /src/modules/coinmarketcap/index.ts: -------------------------------------------------------------------------------- 1 | import ky from 'ky'; 2 | 3 | export type Coin = { 4 | id: number; 5 | name: string; 6 | symbol: string; 7 | rank: number; 8 | quotes: { 9 | USD: { price: number }; 10 | }; 11 | }; 12 | 13 | type TickerResponse = { 14 | data: Record; 15 | }; 16 | 17 | const CoinmarketcapResource = { 18 | getTicker: () => 19 | ky.get('https://api.coinmarketcap.com/v2/ticker/').json(), 20 | }; 21 | 22 | export default CoinmarketcapResource; 23 | -------------------------------------------------------------------------------- /src/pages/Home/HomePage.spec.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { render } from 'test/utils'; 3 | 4 | import { Base } from './HomePage.stories'; 5 | 6 | describe('', () => { 7 | it('Should render welcome message', () => { 8 | const { queryByText } = render(); 9 | expect( 10 | queryByText('React Redux Typescript Boilerplate') 11 | ).toBeInTheDocument(); 12 | }); 13 | }); 14 | -------------------------------------------------------------------------------- /src/pages/Home/HomePage.stories.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import HomePage from './HomePage'; 4 | 5 | export default { 6 | title: 'HomePage', 7 | }; 8 | 9 | export const Base = () => { 10 | return ; 11 | }; 12 | -------------------------------------------------------------------------------- /src/pages/Home/HomePage.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | const HomePage = () => { 4 | return

React Redux Typescript Boilerplate

; 5 | }; 6 | 7 | export default React.memo(HomePage); 8 | -------------------------------------------------------------------------------- /src/pages/Home/index.tsx: -------------------------------------------------------------------------------- 1 | export { default } from './HomePage'; 2 | -------------------------------------------------------------------------------- /src/pages/Ticker/Ticker.spec.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { render, sandbox } from 'test/utils'; 3 | import { waitForElementToBeRemoved } from '@testing-library/react'; 4 | 5 | import { Base } from './Ticker.stories'; 6 | 7 | describe('', () => { 8 | it('Should display listing', async () => { 9 | const { getTicker } = Base.story.setupMocks(sandbox); 10 | const { queryByText, getByTestId } = render(); 11 | expect(queryByText('Ticker')).toBeInTheDocument(); 12 | expect(queryByText('Loading...')).toBeInTheDocument(); 13 | sandbox.assert.calledOnce(getTicker); 14 | 15 | await waitForElementToBeRemoved(() => queryByText('Loading...')); 16 | 17 | const tickerList = getByTestId('ticker-list'); 18 | 19 | const firstListing = tickerList.firstChild as HTMLElement; 20 | expect(firstListing.textContent).toContain('Bitcoin'); 21 | expect(firstListing.textContent).toContain('500.00$'); // mocked response 22 | }); 23 | }); 24 | -------------------------------------------------------------------------------- /src/pages/Ticker/Ticker.stories.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { configureStory } from 'storybook/utils'; 3 | import CoinmarketcapResource from 'modules/coinmarketcap'; 4 | 5 | import TickerPage from './Ticker'; 6 | 7 | export default { 8 | title: 'Ticker', 9 | }; 10 | 11 | export const Base = () => { 12 | return ; 13 | }; 14 | Base.story = configureStory({ 15 | setupMocks: (sandbox) => { 16 | const getTicker = sandbox 17 | .stub(CoinmarketcapResource, 'getTicker') 18 | .resolves({ 19 | data: { 20 | 1: { 21 | id: 1, 22 | name: 'Bitcoin', 23 | quotes: { USD: { price: 500 } }, 24 | rank: 1, 25 | symbol: 'BTC', 26 | }, 27 | }, 28 | }); 29 | 30 | return { getTicker }; 31 | }, 32 | }); 33 | -------------------------------------------------------------------------------- /src/pages/Ticker/Ticker.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect } from 'react'; 2 | import CoinmarketcapResource from 'modules/coinmarketcap'; 3 | import Loading from 'components/Loading'; 4 | import useTicker from './useTicker'; 5 | 6 | const TickerPage = () => { 7 | const { coins, setCoins, setLoading, loading } = useTicker(); 8 | 9 | useEffect(() => { 10 | setLoading(true); 11 | CoinmarketcapResource.getTicker().then((response) => { 12 | setCoins(Object.values(response.data)); 13 | }); 14 | }, [setCoins, setLoading]); 15 | 16 | return ( 17 |
18 |

Ticker

19 | {loading ? ( 20 | 21 | ) : ( 22 |
    23 | {coins.map((coin) => { 24 | return ( 25 |
  • {`${ 26 | coin.name 27 | } - ${coin.quotes.USD.price.toFixed(2)}$`}
  • 28 | ); 29 | })} 30 |
31 | )} 32 |
33 | ); 34 | }; 35 | 36 | export default React.memo(TickerPage); 37 | -------------------------------------------------------------------------------- /src/pages/Ticker/index.ts: -------------------------------------------------------------------------------- 1 | export { default } from './Ticker'; 2 | -------------------------------------------------------------------------------- /src/pages/Ticker/useTicker.ts: -------------------------------------------------------------------------------- 1 | import { createAction } from '@reduxjs/toolkit'; 2 | import { Coin } from 'modules/coinmarketcap'; 3 | import { useReducer, useCallback } from 'react'; 4 | 5 | const INITIAL_STATE = { 6 | loading: false, 7 | coins: [] as Coin[], 8 | }; 9 | 10 | type State = typeof INITIAL_STATE; 11 | 12 | const setLoadingAction = createAction('setLoading'); 13 | const setCoinsAction = createAction('setCoinds'); 14 | 15 | type StateAction = 16 | | ReturnType 17 | | ReturnType; 18 | 19 | function stateReducer(state: State, action: StateAction): State { 20 | if (setLoadingAction.match(action)) { 21 | return { ...state, loading: action.payload }; 22 | } 23 | if (setCoinsAction.match(action)) { 24 | return { ...state, loading: false, coins: action.payload }; 25 | } 26 | 27 | return state; 28 | } 29 | 30 | const useTicker = () => { 31 | const [state, dispatch] = useReducer(stateReducer, INITIAL_STATE); 32 | 33 | const setLoading = useCallback( 34 | (loading: boolean) => { 35 | dispatch(setLoadingAction(loading)); 36 | }, 37 | [dispatch] 38 | ); 39 | 40 | const setCoins = useCallback( 41 | (coins: Coin[]) => { 42 | dispatch(setCoinsAction(coins)); 43 | }, 44 | [dispatch] 45 | ); 46 | 47 | return { ...state, setLoading, setCoins }; 48 | }; 49 | 50 | export default useTicker; 51 | -------------------------------------------------------------------------------- /src/pages/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Switch } from 'react-router-dom'; 3 | import PageRoute from 'components/PageRoute'; 4 | import Navbar from 'components/Navbar'; 5 | 6 | const HomePage = React.lazy(() => import('./Home')); 7 | const TickerPage = React.lazy(() => import('./Ticker')); 8 | 9 | const App = () => { 10 | return ( 11 |
12 | 13 | 14 | 15 | 16 | 17 |
18 | ); 19 | }; 20 | 21 | export default App; 22 | -------------------------------------------------------------------------------- /src/react-app-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /src/reducers/auth/auth.spec.ts: -------------------------------------------------------------------------------- 1 | import configureStore from 'utils/store'; 2 | import auth from './auth'; 3 | 4 | describe('auth', () => { 5 | it('should update the loggeIn state', () => { 6 | const store = configureStore(); 7 | expect(store.getState().auth.loggedIn).toEqual(false); 8 | store.dispatch(auth.actions.setLoggedIn(true)); 9 | expect(store.getState().auth.loggedIn).toEqual(true); 10 | }); 11 | }); 12 | -------------------------------------------------------------------------------- /src/reducers/auth/auth.ts: -------------------------------------------------------------------------------- 1 | import { createSlice, PayloadAction } from '@reduxjs/toolkit'; 2 | 3 | const auth = createSlice({ 4 | name: 'auth', 5 | initialState: { 6 | loggedIn: false, 7 | }, 8 | reducers: { 9 | setLoggedIn: (state, action: PayloadAction) => { 10 | return { ...state, loggedIn: action.payload }; 11 | }, 12 | }, 13 | }); 14 | 15 | export default auth; 16 | -------------------------------------------------------------------------------- /src/reducers/auth/index.ts: -------------------------------------------------------------------------------- 1 | export { default } from './auth'; 2 | -------------------------------------------------------------------------------- /src/reducers/index.ts: -------------------------------------------------------------------------------- 1 | import { combineReducers } from 'redux'; 2 | import auth from './auth'; 3 | 4 | const rootReducer = combineReducers({ 5 | auth: auth.reducer, 6 | }); 7 | 8 | export default rootReducer; 9 | -------------------------------------------------------------------------------- /src/setupTests.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-console */ 2 | import '@testing-library/jest-dom/extend-expect'; 3 | 4 | const originalConsoleError = console.error; 5 | 6 | // Throw Error if any of those occurs during unit tests 7 | const THROWING_MESSAGES = ['SyntaxError:', 'ECONNREFUSED']; 8 | 9 | console.error = (message: string) => { 10 | originalConsoleError(message); 11 | 12 | if (THROWING_MESSAGES.includes(String(message))) { 13 | throw new Error(message); 14 | } 15 | }; 16 | -------------------------------------------------------------------------------- /src/storybook/utils/configureStory.ts: -------------------------------------------------------------------------------- 1 | import { DecoratorFunction } from '@storybook/addons'; 2 | 3 | import { SetupMocks } from './useSandbox'; 4 | import mockDecorator from './mockDecorator'; 5 | 6 | export type StoryConfiguration = { 7 | name?: string; 8 | decorators?: DecoratorFunction[]; 9 | setupMocks?: SetupMocks; 10 | }; 11 | 12 | const configureStory = >({ 13 | setupMocks, 14 | decorators: passedDecorators = [], 15 | ...rest 16 | }: S): S => { 17 | const decorators = setupMocks 18 | ? [...passedDecorators, mockDecorator(setupMocks)] 19 | : passedDecorators; 20 | 21 | return { ...rest, decorators, setupMocks } as S; 22 | }; 23 | 24 | export default configureStory; 25 | -------------------------------------------------------------------------------- /src/storybook/utils/index.ts: -------------------------------------------------------------------------------- 1 | export { default as useSandbox } from './useSandbox'; 2 | export { default as mockDecorator } from './mockDecorator'; 3 | export { default as configureStory } from './configureStory'; 4 | -------------------------------------------------------------------------------- /src/storybook/utils/mockDecorator.ts: -------------------------------------------------------------------------------- 1 | import { StoryFn, StoryContext } from '@storybook/addons'; 2 | 3 | import useSandbox, { SetupMocks } from './useSandbox'; 4 | 5 | function mockDecorator(setupMocks: SetupMocks) { 6 | return (storyFn: StoryFn, context: StoryContext) => { 7 | useSandbox(setupMocks); 8 | return storyFn(context); 9 | }; 10 | } 11 | 12 | export default mockDecorator; 13 | -------------------------------------------------------------------------------- /src/storybook/utils/useSandbox.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useRef } from 'react'; 2 | import sinon, { SinonSandbox } from 'sinon'; 3 | 4 | export type SetupMocks = (sandbox: SinonSandbox) => T; 5 | 6 | const useMocks = (setupMocks: SetupMocks) => { 7 | const sandbox = sinon.createSandbox(); 8 | const hasMocksBeenSet = useRef(false); 9 | if (!hasMocksBeenSet.current) { 10 | setupMocks(sandbox); 11 | } 12 | hasMocksBeenSet.current = true; 13 | 14 | useEffect(() => { 15 | return () => { 16 | sandbox.restore(); 17 | }; 18 | }, [sandbox]); 19 | }; 20 | 21 | export default useMocks; 22 | -------------------------------------------------------------------------------- /src/test/utils/configure.ts: -------------------------------------------------------------------------------- 1 | import { createLocation, History } from 'history'; 2 | import createBrowserHistory from 'utils/history'; 3 | import configureStore, { PartialRootState } from 'utils/store'; 4 | import merge from 'lodash/merge'; 5 | 6 | export type BaseRenderOptions = { 7 | redux?: PartialRootState; 8 | pathname?: string; 9 | }; 10 | 11 | export function createTestBrowserHistory( 12 | maybePathname: string | undefined 13 | ): History { 14 | const history = createBrowserHistory(); 15 | if (maybePathname) { 16 | history.location = createLocation(maybePathname); 17 | } 18 | return history; 19 | } 20 | 21 | export function createTestInitialState(initialState: PartialRootState) { 22 | return merge({}, initialState); 23 | } 24 | 25 | export function createTestStore(initialState: PartialRootState) { 26 | const initialTestState = merge({}, initialState); 27 | return configureStore(initialTestState); 28 | } 29 | -------------------------------------------------------------------------------- /src/test/utils/index.ts: -------------------------------------------------------------------------------- 1 | export { default as render } from './render'; 2 | export { default as sandbox } from './sandbox'; 3 | -------------------------------------------------------------------------------- /src/test/utils/render.tsx: -------------------------------------------------------------------------------- 1 | import { render as renderImpl } from '@testing-library/react'; 2 | import React from 'react'; 3 | import AppProviders from 'containers/AppProviders'; 4 | 5 | import { 6 | createTestBrowserHistory, 7 | createTestStore, 8 | BaseRenderOptions, 9 | } from './configure'; 10 | 11 | function render(component: React.ReactNode, options: BaseRenderOptions = {}) { 12 | const { redux: renderInitialState = {}, pathname } = options; 13 | const history = createTestBrowserHistory(pathname); 14 | const store = createTestStore(renderInitialState); 15 | 16 | const renderResult = renderImpl( 17 | 18 | {component} 19 | 20 | ); 21 | 22 | return { ...renderResult, history, store }; 23 | } 24 | 25 | export default render; 26 | -------------------------------------------------------------------------------- /src/test/utils/sandbox.ts: -------------------------------------------------------------------------------- 1 | import * as sinon from 'sinon'; 2 | 3 | /** 4 | * Sinon Sandbox 5 | * http://sinonjs.org/docs/#sinon-sandbox 6 | * 7 | * A sandbox to house spy(), stub(), mock(), etc. that is automatically reset after each test. 8 | */ 9 | const sandbox = sinon.createSandbox(); 10 | 11 | afterEach(() => { 12 | sandbox.restore(); 13 | }); 14 | 15 | export default sandbox; 16 | -------------------------------------------------------------------------------- /src/types/utils.ts: -------------------------------------------------------------------------------- 1 | export type ValueOf = T[keyof T]; 2 | 3 | export type PickValueOf = ValueOf>; 4 | -------------------------------------------------------------------------------- /src/typings/images.d.ts: -------------------------------------------------------------------------------- 1 | declare module '*.png'; 2 | declare module '*.jpg'; 3 | declare module '*.json'; 4 | declare module '*.svg'; 5 | -------------------------------------------------------------------------------- /src/utils/history.ts: -------------------------------------------------------------------------------- 1 | import { createBrowserHistory as createNewBrowserHistory } from 'history'; 2 | 3 | export const createBrowserHistory = () => { 4 | const history = createNewBrowserHistory(); 5 | 6 | return history; 7 | }; 8 | 9 | export default createBrowserHistory; 10 | -------------------------------------------------------------------------------- /src/utils/store.ts: -------------------------------------------------------------------------------- 1 | import rootReducer from 'reducers'; 2 | import { 3 | configureStore as configureToolkitStore, 4 | ThunkAction, 5 | Action, 6 | getDefaultMiddleware, 7 | DeepPartial, 8 | } from '@reduxjs/toolkit'; 9 | import { PickValueOf } from 'types/utils'; 10 | 11 | export type RootState = ReturnType; 12 | export type PartialRootState = DeepPartial; 13 | 14 | export type AppThunk = ThunkAction>; 15 | 16 | export default function configureStore(initialState: PartialRootState = {}) { 17 | return configureToolkitStore({ 18 | reducer: rootReducer, 19 | preloadedState: initialState, 20 | devTools: process.env.NODE_ENV !== 'production', 21 | middleware: getDefaultMiddleware({ 22 | serializableCheck: false, 23 | immutableCheck: false, 24 | }), 25 | }); 26 | } 27 | 28 | export type RootDispatch = PickValueOf< 29 | ReturnType, 30 | 'dispatch' 31 | >; 32 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "module": "esnext", 5 | "strict": true, 6 | "skipLibCheck": true, 7 | "esModuleInterop": true, 8 | "allowSyntheticDefaultImports": true, 9 | "baseUrl": "src", 10 | "forceConsistentCasingInFileNames": true, 11 | "moduleResolution": "node", 12 | "resolveJsonModule": true, 13 | "isolatedModules": true, 14 | "noEmit": true, 15 | "lib": ["es2018", "dom"], 16 | "jsx": "preserve", 17 | "allowJs": true 18 | }, 19 | "include": ["src"] 20 | } 21 | --------------------------------------------------------------------------------