├── .eslintrc.js ├── .github └── workflows │ └── pipeline.yml ├── .gitignore ├── .prettierrc.json ├── LICENSE ├── README.md ├── package-lock.json ├── package.json ├── public ├── favicon.ico └── index.html ├── src ├── components │ ├── App │ │ ├── App.slice.ts │ │ ├── App.test.tsx │ │ └── App.tsx │ ├── ArticleEditor │ │ ├── ArticleEditor.slice.tsx │ │ ├── ArticleEditor.test.tsx │ │ └── ArticleEditor.tsx │ ├── ArticlePreview │ │ ├── ArticlePreview.test.tsx │ │ └── ArticlePreview.tsx │ ├── ArticlesViewer │ │ ├── ArticlesViewer.slice.ts │ │ └── ArticlesViewer.tsx │ ├── ContainerPage │ │ └── ContainerPage.tsx │ ├── Errors │ │ └── Errors.tsx │ ├── Footer │ │ ├── Footer.test.tsx │ │ └── Footer.tsx │ ├── FormGroup │ │ ├── FormGroup.test.tsx │ │ └── FormGroup.tsx │ ├── GenericForm │ │ └── GenericForm.tsx │ ├── Header │ │ ├── Header.tsx │ │ └── Heater.test.tsx │ ├── Pages │ │ ├── ArticlePage │ │ │ ├── ArticlePage.slice.ts │ │ │ ├── ArticlePage.test.tsx │ │ │ └── ArticlePage.tsx │ │ ├── EditArticle │ │ │ ├── EditArticle.test.tsx │ │ │ └── EditArticle.tsx │ │ ├── Home │ │ │ ├── Home.slice.ts │ │ │ ├── Home.test.tsx │ │ │ └── Home.tsx │ │ ├── Login │ │ │ ├── Login.slice.ts │ │ │ ├── Login.test.tsx │ │ │ └── Login.tsx │ │ ├── NewArticle │ │ │ ├── NewArticle.test.tsx │ │ │ └── NewArticle.tsx │ │ ├── ProfilePage │ │ │ ├── ProfilePage.slice.ts │ │ │ ├── ProfilePage.test.tsx │ │ │ └── ProfilePage.tsx │ │ ├── Register │ │ │ ├── Register.slice.ts │ │ │ ├── Register.test.tsx │ │ │ └── Register.tsx │ │ └── Settings │ │ │ ├── Settings.slice.ts │ │ │ ├── Settings.test.tsx │ │ │ └── Settings.tsx │ ├── Pagination │ │ ├── Pagination.test.tsx │ │ └── Pagination.tsx │ └── UserInfo │ │ ├── UserInfo.test.tsx │ │ └── UserInfo.tsx ├── config │ └── settings.ts ├── index.css ├── index.tsx ├── react-app-env.d.ts ├── services │ ├── conduit.test.tsx │ └── conduit.ts ├── setupTests.ts ├── state │ ├── store.ts │ └── storeHooks.ts └── types │ ├── article.ts │ ├── comment.ts │ ├── error.ts │ ├── genericFormField.tsx │ ├── location.ts │ ├── object.ts │ ├── profile.ts │ ├── style.ts │ └── user.ts └── tsconfig.json /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | env: { 3 | browser: true, 4 | es2020: true, 5 | }, 6 | extends: ['eslint:recommended', 'plugin:@typescript-eslint/recommended'], 7 | parser: '@typescript-eslint/parser', 8 | parserOptions: { 9 | ecmaVersion: 11, 10 | sourceType: 'module', 11 | }, 12 | plugins: ['@typescript-eslint'], 13 | rules: { 14 | '@typescript-eslint/no-empty-function': ['error', { allow: ['methods', 'arrowFunctions'] }], 15 | '@typescript-eslint/no-unused-vars': ['error'], 16 | '@typescript-eslint/no-explicit-any': ['error'], 17 | 'no-console': ['error'], 18 | 'no-debugger': ['error'], 19 | '@typescript-eslint/explicit-module-boundary-types': 'off', 20 | }, 21 | }; 22 | -------------------------------------------------------------------------------- /.github/workflows/pipeline.yml: -------------------------------------------------------------------------------- 1 | name: Pipeline 2 | on: push 3 | 4 | jobs: 5 | code-style: 6 | name: Code Style 7 | runs-on: ubuntu-latest 8 | 9 | strategy: 10 | matrix: 11 | node-version: [12.x, 14.x, 16.x] 12 | 13 | steps: 14 | - uses: actions/checkout@v2 15 | 16 | - name: Use Node.js ${{ matrix.node-version }} 17 | uses: actions/setup-node@v1 18 | with: 19 | node-version: ${{ matrix.node-version }} 20 | 21 | - name: Initialize 22 | run: npm ci 23 | 24 | - name: Lint 25 | run: npm run lint 26 | 27 | - name: Prettier 28 | run: npm run prettier:check 29 | 30 | test: 31 | name: Unit Tests 32 | runs-on: ubuntu-latest 33 | env: 34 | CI: true 35 | 36 | strategy: 37 | matrix: 38 | node-version: [12.x, 14.x, 16.x] 39 | 40 | steps: 41 | - uses: actions/checkout@v2 42 | 43 | - name: Use Node.js ${{ matrix.node-version }} 44 | uses: actions/setup-node@v1 45 | with: 46 | node-version: ${{ matrix.node-version }} 47 | 48 | - name: Initialize 49 | run: npm ci 50 | 51 | - name: Test 52 | run: npm run test -- --coverage 53 | 54 | build: 55 | name: Build 56 | runs-on: ubuntu-latest 57 | env: 58 | CI: true 59 | 60 | strategy: 61 | matrix: 62 | node-version: [12.x, 14.x, 16.x] 63 | 64 | steps: 65 | - uses: actions/checkout@v2 66 | 67 | - name: Use Node.js ${{ matrix.node-version }} 68 | uses: actions/setup-node@v1 69 | with: 70 | node-version: ${{ matrix.node-version }} 71 | 72 | - name: Initialize 73 | run: npm ci 74 | 75 | - name: Build 76 | run: npm run build 77 | 78 | deploy: 79 | name: Deploy to Netlify 80 | runs-on: ubuntu-latest 81 | needs: [code-style, test, build] 82 | if: github.ref == 'refs/heads/master' 83 | env: 84 | CI: true 85 | 86 | steps: 87 | - uses: actions/checkout@v2 88 | 89 | - name: Use Node.js 16.x 90 | uses: actions/setup-node@v1 91 | with: 92 | node-version: 16.x 93 | 94 | - name: Initialize 95 | run: npm ci 96 | 97 | - name: Build 98 | run: npm run build 99 | 100 | - name: Deploy to netlify 101 | uses: netlify/actions/cli@master 102 | env: 103 | NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }} 104 | NETLIFY_SITE_ID: ${{ secrets.NETLIFY_SITE_ID }} 105 | with: 106 | args: deploy --dir=build --prod 107 | secrets: '["NETLIFY_AUTH_TOKEN", "NETLIFY_SITE_ID"]' -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # production 12 | /build 13 | 14 | # misc 15 | .DS_Store 16 | .env.local 17 | .env.development.local 18 | .env.test.local 19 | .env.production.local 20 | 21 | npm-debug.log* 22 | yarn-debug.log* 23 | yarn-error.log* 24 | -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 120, 3 | "singleQuote": true, 4 | "jsxSingleQuote": true 5 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Angel Guzman 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. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Netlify Status](https://api.netlify.com/api/v1/badges/a85b9a22-32b1-479a-a00a-26277493613b/deploy-status)](https://app.netlify.com/sites/react-ts-redux-realworld-example-app/deploys) 2 | ![Pipeline](https://github.com/angelguzmaning/ts-redux-react-realworld-example-app/actions/workflows/pipeline.yml/badge.svg) 3 | 4 | > ### React codebase containing real world examples (CRUD, auth, advanced patterns, etc) that adheres to the [RealWorld](https://github.com/gothinkster/realworld) spec and API. 5 | 6 | ### [Demo](https://react-ts-redux-realworld-example-app.netlify.app/)    [RealWorld](https://github.com/gothinkster/realworld) 7 | 8 | This codebase was created to demonstrate a fully fledged fullstack application built with React, Typescript, and Redux Toolkit including CRUD operations, authentication, routing, pagination, and more. 9 | 10 | For more information on how this works with other frontends/backends, head over to the [RealWorld](https://github.com/gothinkster/realworld) repo. 11 | 12 | 13 | # How it works 14 | The root of the application is the `src/components/App` component. The App component uses react-router's HashRouter to display the different pages. Each page is represented by a [function component](https://reactjs.org/docs/components-and-props.html). 15 | 16 | Some components include a `.slice` file that contains the definition of its state and reducers, which might also be used by other components. These slice files follow the [Redux Toolkit](https://redux-toolkit.js.org/) guidelines. Components connect to the state by using [custom hooks](https://reactjs.org/docs/hooks-custom.html#using-a-custom-hook). 17 | 18 | This application is built following (as much as practicable) functional programming principles: 19 | * Immutable Data 20 | * No classes 21 | * No let or var 22 | * Use of monads (Option, Result) 23 | * No side effects 24 | 25 | The code avoids runtime type-related errors by using Typescript and decoders for data coming from the API. 26 | 27 | Some components include a `.test` file that contains unit tests. This project enforces a 100% code coverage. 28 | 29 | This project uses prettier and eslint to enforce a consistent code syntax. 30 | 31 | ## Folder structure 32 | * `src/components` Contains all the functional components. 33 | * `src/components/Pages` Contains the components used by the router as pages. 34 | * `src/state` Contains redux related code. 35 | * `src/services` Contains the code that interacts with external systems (API requests). 36 | * `src/types` Contains type definitions alongside the code related to those types. 37 | * `src/config` Contains configuration files. 38 | 39 | # Getting started 40 | 41 | This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app), using the [Redux](https://redux.js.org/) and [Redux Toolkit](https://redux-toolkit.js.org/) template. 42 | 43 | ## Available Scripts 44 | In the project directory, you can run: 45 | 46 | ### `npm start` 47 | Runs the app in the development mode.
48 | Open [http://localhost:3000](http://localhost:3000) to view it in the browser. 49 | 50 | The page will reload if you make edits.
51 | 52 | Note: This project will run the app even if linting fails. 53 | 54 | ### `npm test` 55 | Launches the test runner in the interactive watch mode.
56 | See the section about [running tests](https://facebook.github.io/create-react-app/docs/running-tests) for more information. 57 | 58 | ### `npm run build` 59 | 60 | Builds the app for production to the `build` folder.
61 | It correctly bundles React in production mode and optimizes the build for the best performance. 62 | 63 | The build is minified and the filenames include the hashes.
64 | Your app is ready to be deployed! 65 | 66 | See the section about [deployment](https://facebook.github.io/create-react-app/docs/deployment) for more information. 67 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ts-redux-react-realworld-example-app", 3 | "version": "0.1.0", 4 | "private": true, 5 | "dependencies": { 6 | "@hqoss/monads": "^0.5.0", 7 | "@reduxjs/toolkit": "^1.6.1", 8 | "@testing-library/jest-dom": "^4.2.4", 9 | "@testing-library/react": "^9.5.0", 10 | "@testing-library/user-event": "^7.2.1", 11 | "@types/axios": "^0.14.0", 12 | "@types/date-fns": "^2.6.0", 13 | "@types/jest": "^24.9.1", 14 | "@types/node": "^12.20.18", 15 | "@types/ramda": "^0.27.44", 16 | "@types/react": "^16.14.11", 17 | "@types/react-dom": "^16.9.14", 18 | "@types/react-redux": "^7.1.18", 19 | "@types/react-router-dom": "^5.1.8", 20 | "axios": "^0.21.1", 21 | "date-fns": "^2.23.0", 22 | "decoders": "^1.25.1", 23 | "ramda": "^0.27.1", 24 | "react": "^17.0.2", 25 | "react-dom": "^17.0.2", 26 | "react-redux": "^7.2.4", 27 | "react-router-dom": "^5.2.0", 28 | "react-scripts": "4.0.3", 29 | "typescript": "^4.3.5" 30 | }, 31 | "scripts": { 32 | "start": "cross-env DISABLE_ESLINT_PLUGIN=true react-scripts start", 33 | "build": "react-scripts build", 34 | "test": "react-scripts test", 35 | "eject": "react-scripts eject", 36 | "prettier:check": "prettier --check src", 37 | "prettier": "prettier --write src", 38 | "lint": "eslint src" 39 | }, 40 | "pre-commit": [ 41 | "prettier:check", 42 | "lint" 43 | ], 44 | "eslintConfig": { 45 | "extends": "react-app" 46 | }, 47 | "browserslist": { 48 | "production": [ 49 | ">0.2%", 50 | "not dead", 51 | "not op_mini all" 52 | ], 53 | "development": [ 54 | "last 1 chrome version", 55 | "last 1 firefox version", 56 | "last 1 safari version" 57 | ] 58 | }, 59 | "devDependencies": { 60 | "@typescript-eslint/eslint-plugin": "^4.28.5", 61 | "@typescript-eslint/parser": "^4.28.5", 62 | "cross-env": "^7.0.3", 63 | "eslint": "^7.32.0", 64 | "pre-commit": "^1.2.2", 65 | "prettier": "^2.3.2" 66 | }, 67 | "jest": { 68 | "clearMocks": true, 69 | "collectCoverageFrom": [ 70 | "!/src/index.tsx", 71 | "!/src/helpers/testsHelpers.ts" 72 | ], 73 | "coverageThreshold": { 74 | "global": { 75 | "branches": 100, 76 | "functions": 100, 77 | "lines": 100, 78 | "statements": 100 79 | } 80 | } 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/angelguzmaning/ts-redux-react-realworld-example-app/cb397562724ea7f53758d5511c220690b99fe2f5/public/favicon.ico -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 11 | Conduit 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 |
20 | 21 | 22 | -------------------------------------------------------------------------------- /src/components/App/App.slice.ts: -------------------------------------------------------------------------------- 1 | import { None, Option, Some } from '@hqoss/monads'; 2 | import { createSlice, PayloadAction } from '@reduxjs/toolkit'; 3 | import { User } from '../../types/user'; 4 | 5 | export interface AppState { 6 | user: Option; 7 | loading: boolean; 8 | } 9 | 10 | const initialState: AppState = { 11 | user: None, 12 | loading: true, 13 | }; 14 | 15 | const slice = createSlice({ 16 | name: 'app', 17 | initialState, 18 | reducers: { 19 | initializeApp: () => initialState, 20 | loadUser: (state, { payload: user }: PayloadAction) => { 21 | state.user = Some(user); 22 | state.loading = false; 23 | }, 24 | logout: (state) => { 25 | state.user = None; 26 | }, 27 | endLoad: (state) => { 28 | state.loading = false; 29 | }, 30 | }, 31 | }); 32 | 33 | export const { loadUser, logout, endLoad, initializeApp } = slice.actions; 34 | 35 | export default slice.reducer; 36 | -------------------------------------------------------------------------------- /src/components/App/App.test.tsx: -------------------------------------------------------------------------------- 1 | import { act, render, screen } from '@testing-library/react'; 2 | import axios from 'axios'; 3 | import { getArticles, getFeed, getTags, getUser } from '../../services/conduit'; 4 | import { store } from '../../state/store'; 5 | import { App } from './App'; 6 | import { initializeApp } from './App.slice'; 7 | 8 | jest.mock('../../services/conduit'); 9 | jest.mock('axios'); 10 | 11 | const mockedGetArticles = getArticles as jest.Mock>; 12 | const mockedGetFeed = getFeed as jest.Mock>; 13 | const mockedGetTags = getTags as jest.Mock>; 14 | const mockedGetUser = getUser as jest.Mock>; 15 | 16 | it('Should render home', async () => { 17 | act(() => { 18 | store.dispatch(initializeApp()); 19 | }); 20 | mockedGetArticles.mockResolvedValueOnce({ 21 | articles: [], 22 | articlesCount: 0, 23 | }); 24 | mockedGetTags.mockResolvedValueOnce({ tags: [] }); 25 | mockedGetUser.mockImplementationOnce(jest.fn()); 26 | localStorage.clear(); 27 | 28 | await act(async () => { 29 | await render(); 30 | }); 31 | 32 | expect(screen.getByText('A place to share your knowledge.')).toBeInTheDocument(); 33 | expect(mockedGetUser.mock.calls.length).toBe(0); 34 | mockedGetUser.mockClear(); 35 | }); 36 | 37 | it('Should get user if token is on storage', async () => { 38 | act(() => { 39 | store.dispatch(initializeApp()); 40 | }); 41 | mockedGetUser.mockResolvedValueOnce({ 42 | email: 'jake@jake.jake', 43 | token: 'my-token', 44 | username: 'jake', 45 | bio: 'I work at statefarm', 46 | image: null, 47 | }); 48 | mockedGetFeed.mockResolvedValueOnce({ 49 | articles: [], 50 | articlesCount: 0, 51 | }); 52 | mockedGetTags.mockResolvedValueOnce({ tags: [] }); 53 | localStorage.setItem('token', 'my-token'); 54 | 55 | await act(async () => { 56 | await render(); 57 | }); 58 | 59 | expect(axios.defaults.headers.Authorization).toMatch('Token my-token'); 60 | const optionUser = store.getState().app.user; 61 | expect(optionUser.isSome()).toBe(true); 62 | 63 | const user = optionUser.unwrap(); 64 | expect(user).toHaveProperty('email', 'jake@jake.jake'); 65 | expect(user).toHaveProperty('token', 'my-token'); 66 | expect(store.getState().app.loading).toBe(false); 67 | }); 68 | 69 | it('Should end load if get user fails', async () => { 70 | await act(async () => { 71 | store.dispatch(initializeApp()); 72 | }); 73 | mockedGetUser.mockRejectedValueOnce({}); 74 | mockedGetArticles.mockResolvedValueOnce({ 75 | articles: [], 76 | articlesCount: 0, 77 | }); 78 | mockedGetTags.mockResolvedValueOnce({ tags: [] }); 79 | localStorage.setItem('token', 'my-token'); 80 | 81 | await act(async () => { 82 | await render(); 83 | }); 84 | 85 | expect(store.getState().app.user.isNone()).toBeTruthy(); 86 | expect(store.getState().app.loading).toBe(false); 87 | }); 88 | -------------------------------------------------------------------------------- /src/components/App/App.tsx: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | import { Fragment } from 'react'; 3 | import { HashRouter, Redirect, Route, RouteProps, Switch } from 'react-router-dom'; 4 | import { getUser } from '../../services/conduit'; 5 | import { store } from '../../state/store'; 6 | import { useStoreWithInitializer } from '../../state/storeHooks'; 7 | import { EditArticle } from '../Pages/EditArticle/EditArticle'; 8 | import { Footer } from '../Footer/Footer'; 9 | import { Header } from '../Header/Header'; 10 | import { Home } from '../Pages/Home/Home'; 11 | import { Login } from '../Pages/Login/Login'; 12 | import { NewArticle } from '../Pages/NewArticle/NewArticle'; 13 | import { Register } from '../Pages/Register/Register'; 14 | import { Settings } from '../Pages/Settings/Settings'; 15 | import { endLoad, loadUser } from './App.slice'; 16 | import { ProfilePage } from '../Pages/ProfilePage/ProfilePage'; 17 | import { ArticlePage } from '../Pages/ArticlePage/ArticlePage'; 18 | 19 | export function App() { 20 | const { loading, user } = useStoreWithInitializer(({ app }) => app, load); 21 | 22 | const userIsLogged = user.isSome(); 23 | 24 | return ( 25 | 26 | {!loading && ( 27 | 28 |
29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 |