├── .nvmrc ├── .eslintignore ├── .yarnrc ├── src ├── views │ ├── about-page │ │ ├── AboutPage.module.scss │ │ └── AboutPage.jsx │ ├── home-page │ │ ├── HomePage.module.scss │ │ ├── components │ │ │ ├── actors │ │ │ │ ├── Actors.module.scss │ │ │ │ ├── components │ │ │ │ │ ├── ActorCard.module.scss │ │ │ │ │ └── ActorCard.jsx │ │ │ │ └── Actors.jsx │ │ │ └── main-overview │ │ │ │ ├── MainOverview.module.scss │ │ │ │ └── MainOverview.jsx │ │ └── HomePage.jsx │ ├── components │ │ ├── main-nav │ │ │ ├── MainNav.module.scss │ │ │ ├── components │ │ │ │ └── MenuNavLink.js │ │ │ └── MainNav.jsx │ │ ├── toast-card │ │ │ ├── ToastCard.module.scss │ │ │ └── ToastCard.jsx │ │ ├── toasts │ │ │ ├── Toasts.module.scss │ │ │ └── Toasts.jsx │ │ └── loading-indicator │ │ │ ├── LoadingIndicator.module.scss │ │ │ └── LoadingIndicator.jsx │ ├── episodes-page │ │ ├── EpisodesPage.module.scss │ │ ├── components │ │ │ ├── episodes-table │ │ │ │ ├── EpisodesTable.module.scss │ │ │ │ └── EpisodesTable.jsx │ │ │ └── episodes-table-row │ │ │ │ ├── EpisodesTableRow.module.scss │ │ │ │ └── EpisodesTableRow.jsx │ │ └── EpisodesPage.jsx │ ├── not-found-page │ │ ├── NotFoundPage.module.scss │ │ └── NotFoundPage.jsx │ ├── App.module.scss │ └── App.jsx ├── definitions │ ├── redux-freeze.d.ts │ ├── Constructor.ts │ ├── environment.d.ts │ └── RecursivePartial.d.ts ├── react-app-env.d.ts ├── constants │ ├── RouteEnum.ts │ └── ToastStatusEnum.ts ├── models │ └── HttpErrorResponseModel.js ├── __fixtures__ │ └── reduxFixtures.js ├── environments │ ├── test.js │ ├── production.js │ ├── development.js │ └── base.js ├── stores │ ├── error │ │ ├── ErrorAction.js │ │ ├── ErrorAction.spec.js │ │ ├── ErrorReducer.js │ │ └── ErrorReducer.spec.js │ ├── shows │ │ ├── models │ │ │ ├── shows │ │ │ │ ├── CountryModel.js │ │ │ │ ├── NetworkModel.js │ │ │ │ └── ShowModel.js │ │ │ ├── ImageModel.js │ │ │ ├── cast │ │ │ │ ├── CastModel.js │ │ │ │ ├── CharacterModel.js │ │ │ │ └── PersonModel.js │ │ │ └── episodes │ │ │ │ └── EpisodeModel.js │ │ ├── ShowsReducer.js │ │ ├── ShowsAction.js │ │ ├── ShowsEffect.js │ │ ├── ShowsReducer.spec.js │ │ ├── ShowsEffect.spec.js │ │ └── ShowsAction.spec.js │ ├── toasts │ │ ├── ToastsAction.js │ │ └── ToastsReducer.js │ ├── rootReducer.js │ ├── rootStore.js │ └── requesting │ │ ├── RequestingReducer.js │ │ └── RequestingReducer.spec.js ├── index.scss ├── selectors │ ├── requesting │ │ └── RequestingSelector.js │ ├── episodes │ │ └── EpisodesSelector.js │ └── error │ │ └── ErrorSelector.js ├── middlewares │ └── errorToastMiddleware.js ├── utilities │ ├── StringUtil.js │ ├── ActionUtility.js │ ├── BaseReducer.js │ ├── EffectUtility.js │ └── HttpUtility.js ├── index.jsx └── logo.svg ├── tools ├── templates │ └── react │ │ ├── component │ │ ├── __name__.module.scss │ │ └── __name__.tsx │ │ ├── connected-component │ │ ├── __name__.module.scss │ │ └── __name__.tsx │ │ ├── I__interface__.ts │ │ ├── __enum__Enum.ts │ │ ├── redux-store │ │ ├── models │ │ │ ├── I__store__State.ts │ │ │ └── __model__(kebabCase) │ │ │ │ └── __model__ResponseModel.ts │ │ ├── __store__Effect.ts │ │ ├── __store__Reducer.ts │ │ └── __store__Action.ts │ │ ├── selectors │ │ └── __name__Selector.ts │ │ └── __model__Model.ts └── generate.js ├── appScreenshot.png ├── public ├── favicon.ico ├── manifest.json └── index.html ├── .prettierrc ├── .editorconfig ├── .gitignore ├── .prettierignore ├── tsconfig.json ├── craco.config.js ├── .eslintrc.js ├── README.md └── package.json /.nvmrc: -------------------------------------------------------------------------------- 1 | 13.9.0 2 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | tools/** 2 | -------------------------------------------------------------------------------- /.yarnrc: -------------------------------------------------------------------------------- 1 | save-prefix "" 2 | -------------------------------------------------------------------------------- /src/views/about-page/AboutPage.module.scss: -------------------------------------------------------------------------------- 1 | .wrapper { 2 | } 3 | -------------------------------------------------------------------------------- /src/views/home-page/HomePage.module.scss: -------------------------------------------------------------------------------- 1 | .wrapper { 2 | } 3 | -------------------------------------------------------------------------------- /src/definitions/redux-freeze.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'redux-freeze'; 2 | -------------------------------------------------------------------------------- /src/react-app-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /src/views/components/main-nav/MainNav.module.scss: -------------------------------------------------------------------------------- 1 | .wrapper { 2 | } 3 | -------------------------------------------------------------------------------- /src/views/episodes-page/EpisodesPage.module.scss: -------------------------------------------------------------------------------- 1 | .wrapper { 2 | } 3 | -------------------------------------------------------------------------------- /src/views/not-found-page/NotFoundPage.module.scss: -------------------------------------------------------------------------------- 1 | .wrapper { 2 | } 3 | -------------------------------------------------------------------------------- /src/views/components/toast-card/ToastCard.module.scss: -------------------------------------------------------------------------------- 1 | .wrapper { 2 | } 3 | -------------------------------------------------------------------------------- /src/views/home-page/components/actors/Actors.module.scss: -------------------------------------------------------------------------------- 1 | .wrapper { 2 | } 3 | -------------------------------------------------------------------------------- /tools/templates/react/component/__name__.module.scss: -------------------------------------------------------------------------------- 1 | .wrapper { 2 | 3 | } 4 | -------------------------------------------------------------------------------- /tools/templates/react/connected-component/__name__.module.scss: -------------------------------------------------------------------------------- 1 | .wrapper { 2 | 3 | } 4 | -------------------------------------------------------------------------------- /src/definitions/Constructor.ts: -------------------------------------------------------------------------------- 1 | export type Constructor = new (...args: any[]) => T; 2 | -------------------------------------------------------------------------------- /src/views/home-page/components/actors/components/ActorCard.module.scss: -------------------------------------------------------------------------------- 1 | .wrapper { 2 | } 3 | -------------------------------------------------------------------------------- /src/views/home-page/components/main-overview/MainOverview.module.scss: -------------------------------------------------------------------------------- 1 | .wrapper { 2 | } 3 | -------------------------------------------------------------------------------- /src/views/episodes-page/components/episodes-table/EpisodesTable.module.scss: -------------------------------------------------------------------------------- 1 | .wrapper { 2 | } 3 | -------------------------------------------------------------------------------- /tools/templates/react/I__interface__.ts: -------------------------------------------------------------------------------- 1 | export default interface I__interface__ { 2 | 3 | } 4 | -------------------------------------------------------------------------------- /appScreenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/codeBelt/react-redux-architecture/HEAD/appScreenshot.png -------------------------------------------------------------------------------- /src/views/episodes-page/components/episodes-table-row/EpisodesTableRow.module.scss: -------------------------------------------------------------------------------- 1 | .wrapper { 2 | } 3 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/codeBelt/react-redux-architecture/HEAD/public/favicon.ico -------------------------------------------------------------------------------- /tools/templates/react/__enum__Enum.ts: -------------------------------------------------------------------------------- 1 | enum __enum__Enum { 2 | 3 | } 4 | 5 | export default __enum__Enum; 6 | -------------------------------------------------------------------------------- /src/constants/RouteEnum.ts: -------------------------------------------------------------------------------- 1 | enum RouteEnum { 2 | Home = '/', 3 | Episodes = '/episodes', 4 | About = '/about', 5 | } 6 | 7 | export default RouteEnum; 8 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 150, 3 | "singleQuote": true, 4 | "trailingComma": "es5", 5 | "arrowParens": "always", 6 | "bracketSpacing": true 7 | } 8 | -------------------------------------------------------------------------------- /src/constants/ToastStatusEnum.ts: -------------------------------------------------------------------------------- 1 | enum ToastStatusEnum { 2 | Error = 'error', 3 | Warning = 'warning', 4 | Success = 'success', 5 | } 6 | 7 | export default ToastStatusEnum; 8 | -------------------------------------------------------------------------------- /src/definitions/environment.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'environment' { 2 | import baseEnv from 'environments/base'; 3 | const value: ReturnType; 4 | 5 | export default value; 6 | } 7 | -------------------------------------------------------------------------------- /src/definitions/RecursivePartial.d.ts: -------------------------------------------------------------------------------- 1 | type RecursivePartial = { 2 | [P in keyof T]?: T[P] extends (infer U)[] ? RecursivePartial[] : T[P] extends object ? RecursivePartial : T[P]; 3 | }; 4 | -------------------------------------------------------------------------------- /src/views/components/toasts/Toasts.module.scss: -------------------------------------------------------------------------------- 1 | .wrapper { 2 | display: flex; 3 | flex-direction: column; 4 | overflow: hidden; 5 | padding: 16px; 6 | position: fixed; 7 | right: 0; 8 | top: 0; 9 | z-index: 10; 10 | } 11 | -------------------------------------------------------------------------------- /src/models/HttpErrorResponseModel.js: -------------------------------------------------------------------------------- 1 | import uuid from 'uuid/v4'; 2 | 3 | export default class HttpErrorResponseModel { 4 | id = uuid(); 5 | status = 0; 6 | message = ''; 7 | errors = []; 8 | url = ''; 9 | raw = null; 10 | } 11 | -------------------------------------------------------------------------------- /tools/templates/react/redux-store/models/I__store__State.ts: -------------------------------------------------------------------------------- 1 | import __model__ResponseModel from './__model__(kebabCase)/__model__ResponseModel'; 2 | 3 | export default interface I__store__State { 4 | readonly __model__(camelCase): __model__ResponseModel | null; 5 | } 6 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # http://editorconfig.org 2 | root = true 3 | 4 | [*] 5 | charset = utf-8 6 | end_of_line = lf 7 | indent_size = 2 8 | indent_style = space 9 | insert_final_newline = true 10 | trim_trailing_whitespace = true 11 | 12 | [{.*rc,*.json,*.yml}] 13 | indent_size = 2 14 | -------------------------------------------------------------------------------- /src/views/components/main-nav/components/MenuNavLink.js: -------------------------------------------------------------------------------- 1 | import { NavLink } from 'react-router-dom'; 2 | import React from 'react'; 3 | 4 | const MenuNavLink = (props) => { 5 | return ; 6 | }; 7 | 8 | export default MenuNavLink; 9 | -------------------------------------------------------------------------------- /src/views/not-found-page/NotFoundPage.jsx: -------------------------------------------------------------------------------- 1 | import styles from './NotFoundPage.module.scss'; 2 | 3 | import React from 'react'; 4 | 5 | export default class NotFoundPage extends React.PureComponent { 6 | render() { 7 | return
Not found page
; 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /src/__fixtures__/reduxFixtures.js: -------------------------------------------------------------------------------- 1 | import configureStore from 'redux-mock-store'; 2 | import thunk from 'redux-thunk'; 3 | 4 | export const mockStoreFixture = (state) => { 5 | const middlewares = [thunk]; 6 | const storeCreator = configureStore(middlewares); 7 | 8 | return storeCreator(state); 9 | }; 10 | -------------------------------------------------------------------------------- /src/environments/test.js: -------------------------------------------------------------------------------- 1 | import environment from './base'; 2 | 3 | const baseApi = 'https://api.tvmaze.com'; 4 | const env = environment(baseApi); 5 | 6 | const testEnv = { 7 | ...env, 8 | // override anything that gets added from base. 9 | isProduction: false, 10 | isDevelopment: true, 11 | isTesting: true, 12 | }; 13 | 14 | export default testEnv; 15 | -------------------------------------------------------------------------------- /public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "React App", 3 | "name": "Create React App Sample", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | } 10 | ], 11 | "start_url": ".", 12 | "display": "standalone", 13 | "theme_color": "#000000", 14 | "background_color": "#ffffff" 15 | } 16 | -------------------------------------------------------------------------------- /tools/templates/react/selectors/__name__Selector.ts: -------------------------------------------------------------------------------- 1 | import {createSelector, Selector} from 'reselect'; 2 | 3 | export class __name__Selector { 4 | 5 | public static get__name__(something: unknown): unknown { 6 | return null; 7 | } 8 | 9 | } 10 | 11 | export const get__name__: Selector = createSelector( 12 | (state: IStore) => state.someReducer, 13 | __name__Selector.get__name__, 14 | ); 15 | -------------------------------------------------------------------------------- /src/environments/production.js: -------------------------------------------------------------------------------- 1 | import environment from './base'; 2 | 3 | /* 4 | * base.ts is the default environment for production. 5 | * You shouldn't have override anything. 6 | */ 7 | 8 | const baseApi = 'https://api.tvmaze.com'; 9 | const env = environment(baseApi); 10 | 11 | const productionEnv = { 12 | ...env, 13 | // override anything that gets added from base. 14 | }; 15 | 16 | export default productionEnv; 17 | -------------------------------------------------------------------------------- /.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 | 25 | #editors 26 | .idea 27 | -------------------------------------------------------------------------------- /src/stores/error/ErrorAction.js: -------------------------------------------------------------------------------- 1 | import ActionUtility from '../../utilities/ActionUtility'; 2 | 3 | export default class ErrorAction { 4 | static CLEAR_ALL = 'ErrorAction.CLEAR_ALL'; 5 | static REMOVE = 'ErrorAction.REMOVE'; 6 | 7 | static removeById(id) { 8 | return ActionUtility.createAction(ErrorAction.REMOVE, id); 9 | } 10 | 11 | static clearAll() { 12 | return ActionUtility.createAction(ErrorAction.CLEAR_ALL); 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/index.scss: -------------------------------------------------------------------------------- 1 | @import '~semantic-ui-css/semantic.min.css'; 2 | 3 | body { 4 | margin: 10px; 5 | font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', 6 | sans-serif; 7 | -webkit-font-smoothing: antialiased; 8 | -moz-osx-font-smoothing: grayscale; 9 | } 10 | 11 | code { 12 | font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', monospace; 13 | } 14 | -------------------------------------------------------------------------------- /src/selectors/requesting/RequestingSelector.js: -------------------------------------------------------------------------------- 1 | import { createSelector } from 'reselect'; 2 | 3 | export class RequestingSelector { 4 | static selectRequesting(requestingState, actionTypes) { 5 | return actionTypes.some((actionType) => requestingState[actionType]); 6 | } 7 | } 8 | 9 | export const selectRequesting = createSelector( 10 | (state) => state.requesting, 11 | (state, actionTypes) => actionTypes, 12 | RequestingSelector.selectRequesting 13 | ); 14 | -------------------------------------------------------------------------------- /src/middlewares/errorToastMiddleware.js: -------------------------------------------------------------------------------- 1 | import ToastStatusEnum from '../constants/ToastStatusEnum'; 2 | import ToastsAction from '../stores/toasts/ToastsAction'; 3 | 4 | const errorToastMiddleware = () => (store) => (next) => (action) => { 5 | if (action.error) { 6 | const errorAction = action; 7 | 8 | next(ToastsAction.add(errorAction.payload.message, ToastStatusEnum.Error)); 9 | } 10 | 11 | next(action); 12 | }; 13 | 14 | export default errorToastMiddleware; 15 | -------------------------------------------------------------------------------- /src/environments/development.js: -------------------------------------------------------------------------------- 1 | import environment from './base'; 2 | 3 | const baseApi = 'https://api.tvmaze.com'; 4 | const env = environment(baseApi); 5 | 6 | const developmentEnv = { 7 | ...env, 8 | // override anything that gets added from base. 9 | api: { 10 | ...env.api, 11 | // error200: `${baseApi}/api/v1/error-200`, 12 | // error500: `${baseApi}/api/v1/error-500`, 13 | }, 14 | isProduction: false, 15 | isDevelopment: true, 16 | }; 17 | 18 | export default developmentEnv; 19 | -------------------------------------------------------------------------------- /src/utilities/StringUtil.js: -------------------------------------------------------------------------------- 1 | export default class StringUtil { 2 | /** 3 | * Splits a string in half by a separator 4 | * 5 | * @param str string 6 | * @param separator string 7 | * @example 8 | * splitBySeparator('https://api.tvmaze.com/search/shows?q=Friends', '.com'); 9 | * 10 | * // ['https://api.tvmaze.com', '/search/shows?q=Friends'] 11 | */ 12 | static splitBySeparator = (str, separator) => { 13 | return str.split(new RegExp(`(.*?${separator})`, 'g')).filter(Boolean); 14 | }; 15 | } 16 | -------------------------------------------------------------------------------- /src/views/components/loading-indicator/LoadingIndicator.module.scss: -------------------------------------------------------------------------------- 1 | .wrapper { 2 | min-height: 100px; 3 | position: relative; 4 | pointer-events: none; 5 | } 6 | 7 | .loaderContainer { 8 | align-items: center; 9 | background-color: rgba(0, 0, 0, 0.8); 10 | bottom: 0; 11 | content: ''; 12 | cursor: wait; 13 | display: flex; 14 | flex-direction: column; 15 | justify-content: center; 16 | left: 0; 17 | pointer-events: none; 18 | position: absolute; 19 | right: 0; 20 | top: 0; 21 | z-index: 10; 22 | } 23 | -------------------------------------------------------------------------------- /tools/templates/react/redux-store/__store__Effect.ts: -------------------------------------------------------------------------------- 1 | import environment from 'environment'; 2 | import __model__ResponseModel from './models/__model__(kebabCase)/__model__ResponseModel'; 3 | 4 | export default class __store__Effect { 5 | 6 | public static async request__model__(): Promise<__model__ResponseModel | HttpErrorResponseModel> { 7 | const endpoint: string = environment.api.__model__(camelCase); 8 | 9 | return EffectUtility.getToModel<__model__ResponseModel>(__model__ResponseModel, endpoint); 10 | 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /tools/templates/react/component/__name__.tsx: -------------------------------------------------------------------------------- 1 | import styles from './__name__.module.scss'; 2 | 3 | import * as React from 'react'; 4 | 5 | interface IProps {} 6 | interface IState {} 7 | 8 | export default class __name__ extends React.Component { 9 | 10 | // public static defaultProps: Partial = {}; 11 | 12 | // public state: IState = {}; 13 | 14 | public render(): JSX.Element { 15 | return ( 16 |
17 | __name__(sentenceCase) 18 |
19 | ); 20 | } 21 | 22 | } 23 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | *.eot 2 | *.ico 3 | *.lock 4 | *.log 5 | *.png 6 | *.snap 7 | *.svg 8 | *.ttf 9 | *.woff 10 | *.woff2 11 | .babelrc 12 | .DS_Store 13 | .editorconfig 14 | *.env 15 | *.sh 16 | *.chunk.* 17 | *.min.* 18 | runtime~main.* 19 | .git 20 | .gitattributes 21 | .gitignore 22 | .idea/* 23 | .npmrc 24 | .nvmrc 25 | .prettierignore 26 | .prettierrc 27 | .stylelintrc 28 | .vscode/* 29 | .yarnrc 30 | build 31 | coverage 32 | dist 33 | LICENSE 34 | mock-server/data 35 | node_modules 36 | public 37 | !public/index.html 38 | tools/templates 39 | webpack.config.js 40 | tools 41 | -------------------------------------------------------------------------------- /src/stores/shows/models/shows/CountryModel.js: -------------------------------------------------------------------------------- 1 | import { BaseModel } from 'sjs-base-model'; 2 | 3 | /* 4 | // Returned Api Data Sample 5 | { 6 | "name": "United States", 7 | "code": "US", 8 | "timezone": "America/New_York" 9 | } 10 | */ 11 | export default class CountryModel extends BaseModel { 12 | name = ''; 13 | code = ''; 14 | timezone = ''; 15 | 16 | /* 17 | * Client-Side properties (Not from API) 18 | */ 19 | // noneApiProperties = null; 20 | 21 | constructor(data) { 22 | super(); 23 | 24 | this.update(data); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/stores/toasts/ToastsAction.js: -------------------------------------------------------------------------------- 1 | import ActionUtility from '../../utilities/ActionUtility'; 2 | import uuid from 'uuid/v4'; 3 | 4 | export default class ToastsAction { 5 | static ADD_TOAST = 'ToastsAction.ADD_TOAST'; 6 | static REMOVE_TOAST = 'ToastsAction.REMOVE_TOAST'; 7 | 8 | static add(message, type) { 9 | return ActionUtility.createAction(ToastsAction.ADD_TOAST, { 10 | message, 11 | type, 12 | id: uuid(), 13 | }); 14 | } 15 | 16 | static removeById(toastId) { 17 | return ActionUtility.createAction(ToastsAction.REMOVE_TOAST, toastId); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "skipLibCheck": false, 7 | "esModuleInterop": true, 8 | "allowSyntheticDefaultImports": true, 9 | "strict": true, 10 | "forceConsistentCasingInFileNames": true, 11 | "module": "esnext", 12 | "moduleResolution": "node", 13 | "resolveJsonModule": true, 14 | "isolatedModules": true, 15 | "noEmit": true, 16 | "jsx": "preserve", 17 | "suppressImplicitAnyIndexErrors": true, 18 | "baseUrl": "src" 19 | }, 20 | "include": ["src"] 21 | } 22 | -------------------------------------------------------------------------------- /tools/templates/react/__model__Model.ts: -------------------------------------------------------------------------------- 1 | import {BaseModel} from 'sjs-base-model'; 2 | 3 | /* 4 | // Returned Api Data Sample 5 | { 6 | } 7 | */ 8 | export default class __model__Model extends BaseModel { 9 | 10 | public readonly exampleProperty: string = ''; 11 | 12 | /* 13 | * Client-Side properties (Not from API) 14 | */ 15 | public noneApiProperties: unknown = null; 16 | 17 | constructor(data: Partial<__model__Model>) { 18 | super(); 19 | 20 | this.update(data); 21 | } 22 | 23 | public update(data: Partial<__model__Model>): void { 24 | super.update(data); 25 | } 26 | 27 | } 28 | -------------------------------------------------------------------------------- /src/stores/shows/models/ImageModel.js: -------------------------------------------------------------------------------- 1 | import { BaseModel } from 'sjs-base-model'; 2 | 3 | /* 4 | // Returned Api Data Sample 5 | { 6 | "medium": "http://static.tvmaze.com/uploads/images/medium_portrait/0/526.jpg", 7 | "original": "http://static.tvmaze.com/uploads/images/original_untouched/0/526.jpg" 8 | } 9 | */ 10 | export default class ImageModel extends BaseModel { 11 | medium = ''; 12 | original = ''; 13 | 14 | /* 15 | * Client-Side properties (Not from API) 16 | */ 17 | // noneApiProperties = null; 18 | 19 | constructor(data) { 20 | super(); 21 | 22 | this.update(data); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/stores/toasts/ToastsReducer.js: -------------------------------------------------------------------------------- 1 | import ToastsAction from './ToastsAction'; 2 | import BaseReducer from '../../utilities/BaseReducer'; 3 | 4 | export default class ToastsReducer extends BaseReducer { 5 | initialState = { 6 | items: [], 7 | }; 8 | 9 | [ToastsAction.ADD_TOAST](state, action) { 10 | return { 11 | ...state, 12 | items: [...state.items, action.payload], 13 | }; 14 | } 15 | 16 | [ToastsAction.REMOVE_TOAST](state, action) { 17 | const toastId = action.payload; 18 | 19 | return { 20 | ...state, 21 | items: state.items.filter((model) => model.id !== toastId), 22 | }; 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/views/App.module.scss: -------------------------------------------------------------------------------- 1 | .App { 2 | text-align: center; 3 | } 4 | 5 | .App-logo { 6 | animation: App-logo-spin infinite 20s linear; 7 | height: 40vmin; 8 | pointer-events: none; 9 | } 10 | 11 | .App-header { 12 | background-color: #282c34; 13 | min-height: 100vh; 14 | display: flex; 15 | flex-direction: column; 16 | align-items: center; 17 | justify-content: center; 18 | font-size: calc(10px + 2vmin); 19 | color: white; 20 | } 21 | 22 | .App-link { 23 | color: #61dafb; 24 | } 25 | 26 | @keyframes App-logo-spin { 27 | from { 28 | transform: rotate(0deg); 29 | } 30 | to { 31 | transform: rotate(360deg); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/utilities/ActionUtility.js: -------------------------------------------------------------------------------- 1 | import HttpErrorResponseModel from '../models/HttpErrorResponseModel'; 2 | 3 | export default class ActionUtility { 4 | static async createThunkEffect(dispatch, actionType, effect, ...args) { 5 | dispatch(ActionUtility.createAction(actionType)); 6 | 7 | const model = await effect(...args); 8 | const isError = model instanceof HttpErrorResponseModel; 9 | 10 | dispatch(ActionUtility.createAction(`${actionType}_FINISHED`, model, isError)); 11 | 12 | return model; 13 | } 14 | 15 | static createAction(type, payload, error = false, meta = null) { 16 | return { type, payload, error, meta }; 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/views/episodes-page/components/episodes-table-row/EpisodesTableRow.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Image, Table } from 'semantic-ui-react'; 3 | 4 | export default class EpisodesTableRow extends React.PureComponent { 5 | render() { 6 | const { rowData } = this.props; 7 | 8 | return ( 9 | 10 | 11 | 12 | 13 | {rowData.episode} 14 | {rowData.date} 15 | {rowData.name} 16 | 17 | ); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/stores/rootReducer.js: -------------------------------------------------------------------------------- 1 | import { combineReducers } from 'redux'; 2 | import { connectRouter } from 'connected-react-router'; 3 | import RequestingReducer from './requesting/RequestingReducer'; 4 | import ErrorReducer from './error/ErrorReducer'; 5 | import ShowsReducer from './shows/ShowsReducer'; 6 | import ToastsReducer from './toasts/ToastsReducer'; 7 | 8 | export default (history) => { 9 | const reducerMap = { 10 | error: ErrorReducer.reducer, 11 | requesting: RequestingReducer.reducer, 12 | router: connectRouter(history), 13 | shows: new ShowsReducer().reducer, 14 | toasts: new ToastsReducer().reducer, 15 | }; 16 | 17 | return combineReducers(reducerMap); 18 | }; 19 | -------------------------------------------------------------------------------- /tools/templates/react/redux-store/__store__Reducer.ts: -------------------------------------------------------------------------------- 1 | import I__store__State from './models/I__store__State'; 2 | import __store__Action from './__store__Action'; 3 | import __model__ResponseModel from './models/__model__(kebabCase)/__model__ResponseModel'; 4 | 5 | export default class __store__Reducer extends BaseReducer { 6 | public readonly initialState: I__store__State = { 7 | __model__(camelCase): null, 8 | }; 9 | 10 | public [__store__Action.REQUEST___model__(constantCase)_FINISHED](state: I__store__State, action: IAction<__model__ResponseModel>): I__store__State { 11 | return { 12 | ...state, 13 | __model__(camelCase): action.payload!, 14 | }; 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/stores/shows/models/cast/CastModel.js: -------------------------------------------------------------------------------- 1 | import { BaseModel } from 'sjs-base-model'; 2 | import PersonModel from './PersonModel'; 3 | import CharacterModel from './CharacterModel'; 4 | 5 | /* 6 | // Returned Api Data Sample 7 | { 8 | "person": {}, 9 | "character": {}, 10 | "self": false, 11 | "voice": false 12 | } 13 | */ 14 | export default class CastModel extends BaseModel { 15 | person = PersonModel; 16 | character = CharacterModel; 17 | self = false; 18 | voice = false; 19 | 20 | /* 21 | * Client-Side properties (Not from API) 22 | */ 23 | // noneApiProperties = null; 24 | 25 | constructor(data) { 26 | super(); 27 | 28 | this.update(data); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/stores/shows/models/shows/NetworkModel.js: -------------------------------------------------------------------------------- 1 | import { BaseModel } from 'sjs-base-model'; 2 | import CountryModel from './CountryModel'; 3 | 4 | /* 5 | // Returned Api Data Sample 6 | { 7 | "id": 20, 8 | "name": "AMC", 9 | "country": { 10 | "name": "United States", 11 | "code": "US", 12 | "timezone": "America/New_York" 13 | } 14 | } 15 | */ 16 | export default class NetworkModel extends BaseModel { 17 | id = 0; 18 | name = ''; 19 | country = CountryModel; 20 | 21 | /* 22 | * Client-Side properties (Not from API) 23 | */ 24 | // noneApiProperties = null; 25 | 26 | constructor(data) { 27 | super(); 28 | 29 | this.update(data); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/utilities/BaseReducer.js: -------------------------------------------------------------------------------- 1 | export default class BaseReducer { 2 | initialState = {}; 3 | 4 | reducer = (state = this.initialState, action) => { 5 | // if the action type is used for a method name then this be a reference to 6 | // that class method. 7 | // if the action type is not found then the "method" const will be undefined. 8 | const method = this[action.type]; 9 | 10 | // if the action type "method" const is undefined or the action is an error 11 | // return the state. 12 | if (!method || action.error) { 13 | return state; 14 | } 15 | 16 | // Calls the method with the correct "this" and returns the modified state. 17 | return method.call(this, state, action); 18 | }; 19 | } 20 | -------------------------------------------------------------------------------- /src/views/components/main-nav/MainNav.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Menu, Segment } from 'semantic-ui-react'; 3 | import MenuNavLink from './components/MenuNavLink'; 4 | import RouteEnum from '../../../constants/RouteEnum'; 5 | 6 | export default class MainNav extends React.PureComponent { 7 | render() { 8 | return ( 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | ); 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/environments/base.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Base is the default environment for production. 3 | * Add everything here and override value in other files if needed. 4 | * https://blog.usejournal.com/my-awesome-custom-react-environment-variables-setup-8ebb0797d8ac 5 | */ 6 | export default function baseEnv(baseApi) { 7 | return { 8 | route: { 9 | baseRoute: '/react-redux-architecture', // Fixes issue with Github Pages 10 | }, 11 | api: { 12 | cast: `${baseApi}/shows/:showId/cast`, 13 | episodes: `${baseApi}/shows/:showId/episodes`, 14 | shows: `${baseApi}/shows/:showId`, 15 | errorExample: 'https://httpstat.us/520', 16 | }, 17 | isProduction: true, 18 | isDevelopment: false, 19 | isTesting: false, 20 | }; 21 | } 22 | -------------------------------------------------------------------------------- /src/stores/shows/ShowsReducer.js: -------------------------------------------------------------------------------- 1 | import ShowsAction from './ShowsAction'; 2 | import BaseReducer from '../../utilities/BaseReducer'; 3 | 4 | export default class ShowsReducer extends BaseReducer { 5 | initialState = { 6 | currentShowId: '74', 7 | show: null, 8 | episodes: [], 9 | actors: [], 10 | }; 11 | 12 | [ShowsAction.REQUEST_SHOW_FINISHED](state, action) { 13 | return { 14 | ...state, 15 | show: action.payload, 16 | }; 17 | } 18 | 19 | [ShowsAction.REQUEST_EPISODES_FINISHED](state, action) { 20 | return { 21 | ...state, 22 | episodes: action.payload, 23 | }; 24 | } 25 | 26 | [ShowsAction.REQUEST_CAST_FINISHED](state, action) { 27 | return { 28 | ...state, 29 | actors: action.payload, 30 | }; 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/views/components/toasts/Toasts.jsx: -------------------------------------------------------------------------------- 1 | import styles from './Toasts.module.scss'; 2 | 3 | import React from 'react'; 4 | import { connect } from 'react-redux'; 5 | import ToastCard from '../toast-card/ToastCard'; 6 | 7 | const mapStateToProps = (state, ownProps) => ({ 8 | toasts: state.toasts.items, 9 | }); 10 | 11 | class Toasts extends React.Component { 12 | render() { 13 | const { toasts } = this.props; 14 | 15 | if (toasts.length === 0) { 16 | return null; 17 | } 18 | 19 | return ( 20 |
21 | {toasts.map((model) => ( 22 | 23 | ))} 24 |
25 | ); 26 | } 27 | } 28 | 29 | export { Toasts as Unconnected }; 30 | export default connect(mapStateToProps)(Toasts); 31 | -------------------------------------------------------------------------------- /src/utilities/EffectUtility.js: -------------------------------------------------------------------------------- 1 | import HttpErrorResponseModel from '../models/HttpErrorResponseModel'; 2 | import HttpUtility from './HttpUtility'; 3 | 4 | export default class EffectUtility { 5 | static async getToModel(Model, endpoint, params) { 6 | const response = await HttpUtility.get(endpoint, params); 7 | 8 | return EffectUtility._restModelCreator(Model, response); 9 | } 10 | 11 | static async postToModel(Model, endpoint, data) { 12 | const response = await HttpUtility.post(endpoint, data); 13 | 14 | return EffectUtility._restModelCreator(Model, response); 15 | } 16 | 17 | static _restModelCreator(Model, response) { 18 | if (response instanceof HttpErrorResponseModel) { 19 | return response; 20 | } 21 | 22 | return !Array.isArray(response.data) ? new Model(response.data) : response.data.map((json) => new Model(json)); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /tools/templates/react/redux-store/models/__model__(kebabCase)/__model__ResponseModel.ts: -------------------------------------------------------------------------------- 1 | import {BaseModel} from 'sjs-base-model'; 2 | 3 | /* 4 | // Returned Api Data Sample 5 | { 6 | "data": null, 7 | "success": true, 8 | "errors": [] 9 | } 10 | */ 11 | export default class __model__ResponseModel extends BaseModel { 12 | 13 | public readonly data: unknown = null; 14 | public readonly success: boolean = true; 15 | public readonly errors: string[] = []; 16 | 17 | /* 18 | * Client-Side properties (Not from API) 19 | */ 20 | // public noneApiProperties: unknown = null; 21 | 22 | constructor(data: Partial<__model__ResponseModel>) { 23 | super(); 24 | 25 | this.update(data); 26 | } 27 | 28 | public update(data: Partial<__model__ResponseModel>): void { 29 | super.update(data); 30 | } 31 | 32 | } 33 | -------------------------------------------------------------------------------- /src/stores/rootStore.js: -------------------------------------------------------------------------------- 1 | import { applyMiddleware, createStore } from 'redux'; 2 | import thunk from 'redux-thunk'; 3 | import { composeWithDevTools } from 'redux-devtools-extension/logOnlyInProduction'; 4 | import { routerMiddleware } from 'connected-react-router'; 5 | import reduxFreeze from 'redux-freeze'; 6 | import environment from 'environment'; 7 | import rootReducer from './rootReducer'; 8 | import errorToastMiddleware from '../middlewares/errorToastMiddleware'; 9 | 10 | export default (initialState, history) => { 11 | const middleware = [environment.isDevelopment ? reduxFreeze : null, thunk, routerMiddleware(history), errorToastMiddleware()].filter(Boolean); 12 | 13 | const store = createStore(rootReducer(history), initialState, composeWithDevTools(applyMiddleware(...middleware))); 14 | 15 | // store.subscribe(() => console.log(store.getState())); 16 | 17 | return store; 18 | }; 19 | -------------------------------------------------------------------------------- /tools/templates/react/connected-component/__name__.tsx: -------------------------------------------------------------------------------- 1 | import styles from './__name__.module.scss'; 2 | 3 | import * as React from 'react'; 4 | import {connect} from 'react-redux'; 5 | 6 | interface IProps {} 7 | interface IState {} 8 | interface IRouteParams {} 9 | interface IStateToProps {} 10 | 11 | const mapStateToProps = (state: IStore, ownProps: IProps): IStateToProps => ({}); 12 | 13 | class __name__ extends React.Component, IState> { 14 | 15 | // public static defaultProps: Partial = {}; 16 | 17 | // public state: IState = {}; 18 | 19 | public render(): JSX.Element { 20 | return ( 21 |
22 | __name__(sentenceCase) 23 |
24 | ); 25 | } 26 | 27 | } 28 | 29 | export { __name__ as Unconnected }; 30 | export default connect(mapStateToProps)(__name__); 31 | -------------------------------------------------------------------------------- /src/stores/error/ErrorAction.spec.js: -------------------------------------------------------------------------------- 1 | import ErrorAction from './ErrorAction'; 2 | import uuid from 'uuid/v4'; 3 | import ActionUtility from '../../utilities/ActionUtility'; 4 | 5 | describe('ErrorAction', () => { 6 | describe('removeById', () => { 7 | it('should call action with payload', () => { 8 | const expectedId = uuid(); 9 | 10 | const actualResult = ErrorAction.removeById(expectedId); 11 | const expectedResult = ActionUtility.createAction(ErrorAction.REMOVE, expectedId); 12 | 13 | expect(actualResult).toEqual(expectedResult); 14 | }); 15 | }); 16 | 17 | describe('clearAll', () => { 18 | it('should call action', () => { 19 | const actualResult = ErrorAction.clearAll(); 20 | const expectedResult = ActionUtility.createAction(ErrorAction.CLEAR_ALL); 21 | 22 | expect(actualResult).toEqual(expectedResult); 23 | }); 24 | }); 25 | }); 26 | -------------------------------------------------------------------------------- /src/selectors/episodes/EpisodesSelector.js: -------------------------------------------------------------------------------- 1 | import { createSelector } from 'reselect'; 2 | import groupBy from 'lodash.groupby'; 3 | import dayjs from 'dayjs'; 4 | 5 | export class EpisodesSelector { 6 | static selectEpisodes(episodes) { 7 | const seasons = groupBy(episodes, 'season'); 8 | 9 | return Object.entries(seasons).map(([season, models]) => { 10 | return { 11 | title: `Season ${season}`, 12 | rows: EpisodesSelector._createTableRows(models), 13 | }; 14 | }); 15 | } 16 | 17 | static _createTableRows(models) { 18 | return models.map((model) => ({ 19 | episode: model.number, 20 | name: model.name, 21 | date: dayjs(model.airdate).format('MMM D, YYYY'), 22 | image: model.image.medium, 23 | })); 24 | } 25 | } 26 | 27 | export const selectEpisodes = createSelector((state) => state.shows.episodes, EpisodesSelector.selectEpisodes); 28 | -------------------------------------------------------------------------------- /craco.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | 3 | module.exports = function({ env, paths }) { 4 | return { 5 | babel: { 6 | presets: [], 7 | plugins: ['@babel/plugin-proposal-optional-chaining', '@babel/plugin-proposal-nullish-coalescing-operator'], 8 | // loaderOptions: { /* Any babel-loader configuration options: https://github.com/babel/babel-loader. */ }, 9 | // loaderOptions: (babelLoaderOptions, { env, paths }) => { return babelLoaderOptions; } 10 | }, 11 | webpack: { 12 | alias: { 13 | environment: path.join(__dirname, 'src', 'environments', process.env.CLIENT_ENV), 14 | }, 15 | }, 16 | jest: { 17 | configure: { 18 | modulePathIgnorePatterns: ['/src/environments'], 19 | moduleNameMapper: { 20 | environment: '/src/environments/test', 21 | }, 22 | }, 23 | }, 24 | }; 25 | }; 26 | -------------------------------------------------------------------------------- /src/views/home-page/components/actors/Actors.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { connect } from 'react-redux'; 3 | import { Card } from 'semantic-ui-react'; 4 | import ShowsAction from '../../../../stores/shows/ShowsAction'; 5 | import ActorCard from './components/ActorCard'; 6 | 7 | const mapStateToProps = (state, ownProps) => ({ 8 | actors: state.shows.actors, 9 | }); 10 | 11 | class Actors extends React.Component { 12 | componentDidMount() { 13 | this.props.dispatch(ShowsAction.requestCast()); 14 | } 15 | 16 | render() { 17 | const { actors } = this.props; 18 | 19 | return ( 20 | 21 | {actors.map((model) => ( 22 | 23 | ))} 24 | 25 | ); 26 | } 27 | } 28 | 29 | export { Actors as Unconnected }; 30 | export default connect(mapStateToProps)(Actors); 31 | -------------------------------------------------------------------------------- /src/views/home-page/components/actors/components/ActorCard.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Card, Image } from 'semantic-ui-react'; 3 | 4 | export default class ActorCard extends React.PureComponent { 5 | render() { 6 | const { cardData } = this.props; 7 | const image = cardData?.character?.image?.medium; 8 | const missingImage = 'https://react.semantic-ui.com/images/wireframe/image.png'; 9 | 10 | return ( 11 | 12 | 13 | 14 | {cardData.person.name} 15 | as {cardData.character.name} 16 | 17 | Birth date: {cardData.person.birthday} 18 | 19 | 20 | 21 | ); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/views/components/loading-indicator/LoadingIndicator.jsx: -------------------------------------------------------------------------------- 1 | import styles from './LoadingIndicator.module.scss'; 2 | 3 | import React from 'react'; 4 | import classNames from 'classnames'; 5 | import { Loader } from 'semantic-ui-react'; 6 | 7 | export default class LoadingIndicator extends React.PureComponent { 8 | static defaultProps = { 9 | className: undefined, 10 | isActive: false, 11 | }; 12 | 13 | render() { 14 | const { children, isActive, className } = this.props; 15 | const cssClasses = classNames(className, { 16 | [styles.wrapper]: isActive, 17 | }); 18 | 19 | return ( 20 |
21 | {isActive && ( 22 |
23 | 24 |
25 | )} 26 | {children} 27 |
28 | ); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/index.jsx: -------------------------------------------------------------------------------- 1 | import 'react-app-polyfill/ie11'; 2 | import 'react-app-polyfill/stable'; 3 | 4 | import './index.scss'; 5 | 6 | import React from 'react'; 7 | import ReactDOM from 'react-dom'; 8 | import { Provider } from 'react-redux'; 9 | import { createBrowserHistory } from 'history'; 10 | import rootStore from './stores/rootStore'; 11 | import App from './views/App'; 12 | import environment from 'environment'; 13 | 14 | (async (window) => { 15 | const initialState = {}; 16 | const history = createBrowserHistory({ basename: environment.route.baseRoute }); 17 | const store = rootStore(initialState, history); 18 | 19 | const rootEl = document.getElementById('root'); 20 | const render = (Component, el) => { 21 | ReactDOM.render( 22 | 23 | 24 | , 25 | el 26 | ); 27 | }; 28 | 29 | render(App, rootEl); 30 | })(window); 31 | -------------------------------------------------------------------------------- /src/stores/shows/models/cast/CharacterModel.js: -------------------------------------------------------------------------------- 1 | import { BaseModel } from 'sjs-base-model'; 2 | import ImageModel from '../ImageModel'; 3 | 4 | /* 5 | // Returned Api Data Sample 6 | { 7 | "id": 11320, 8 | "url": "http://www.tvmaze.com/characters/11320/hell-on-wheels-cullen-bohannon", 9 | "name": "Cullen Bohannon", 10 | "image": { 11 | "medium": "http://static.tvmaze.com/uploads/images/medium_portrait/3/9064.jpg", 12 | "original": "http://static.tvmaze.com/uploads/images/original_untouched/3/9064.jpg" 13 | }, 14 | "_links": { 15 | "self": { 16 | "href": "http://api.tvmaze.com/characters/11320" 17 | } 18 | } 19 | } 20 | */ 21 | export default class CharacterModel extends BaseModel { 22 | id = 0; 23 | name = ''; 24 | image = ImageModel; 25 | 26 | /* 27 | * Client-Side properties (Not from API) 28 | */ 29 | // noneApiProperties = null; 30 | 31 | constructor(data) { 32 | super(); 33 | 34 | this.update(data); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/stores/requesting/RequestingReducer.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Note: This reducer breaks convention on how reducers should be setup. 3 | */ 4 | export default class RequestingReducer { 5 | static initialState = {}; 6 | 7 | static reducer(state = RequestingReducer.initialState, action) { 8 | // We only take actions that include 'REQUEST_' in the type. 9 | const isRequestType = action.type.includes('REQUEST_'); 10 | 11 | if (isRequestType === false) { 12 | return state; 13 | } 14 | 15 | // Remove the string '_FINISHED' from the action type so we can use the first part as the key on the state. 16 | const requestName = action.type.replace('_FINISHED', ''); 17 | // If the action type includes '_FINISHED'. The boolean value will be false. Otherwise we 18 | // assume it is a starting request and will be set to true. 19 | const isFinishedRequestType = action.type.includes('_FINISHED'); 20 | 21 | return { 22 | ...state, 23 | [requestName]: isFinishedRequestType === false, 24 | }; 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/views/episodes-page/components/episodes-table/EpisodesTable.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Header, Table } from 'semantic-ui-react'; 3 | import EpisodesTableRow from '../episodes-table-row/EpisodesTableRow'; 4 | 5 | export default class EpisodesTable extends React.PureComponent { 6 | render() { 7 | const { tableData } = this.props; 8 | 9 | return ( 10 |
11 |
{tableData.title}
12 | 13 | 14 | 15 | Scene 16 | Episode 17 | Date 18 | Name 19 | 20 | 21 | 22 | {tableData.rows.map((model) => ( 23 | 24 | ))} 25 | 26 |
27 |
28 | ); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/stores/shows/models/cast/PersonModel.js: -------------------------------------------------------------------------------- 1 | import { BaseModel } from 'sjs-base-model'; 2 | import ImageModel from '../ImageModel'; 3 | 4 | /* 5 | // Returned Api Data Sample 6 | { 7 | "id": 10709, 8 | "url": "http://www.tvmaze.com/people/10709/anson-mount", 9 | "name": "Anson Mount", 10 | "country": { 11 | "name": "United States", 12 | "code": "US", 13 | "timezone": "America/New_York" 14 | }, 15 | "birthday": "1973-02-25", 16 | "deathday": null, 17 | "gender": "Male", 18 | "image": { 19 | "medium": "http://static.tvmaze.com/uploads/images/medium_portrait/0/2326.jpg", 20 | "original": "http://static.tvmaze.com/uploads/images/original_untouched/0/2326.jpg" 21 | }, 22 | "_links": { 23 | "self": { 24 | "href": "http://api.tvmaze.com/people/10709" 25 | } 26 | } 27 | } 28 | */ 29 | export default class PersonModel extends BaseModel { 30 | id = 0; 31 | name = ''; 32 | birthday = ''; 33 | image = ImageModel; 34 | 35 | /* 36 | * Client-Side properties (Not from API) 37 | */ 38 | // noneApiProperties = null; 39 | 40 | constructor(data) { 41 | super(); 42 | 43 | this.update(data); 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/views/episodes-page/EpisodesPage.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { connect } from 'react-redux'; 3 | import ShowsAction from '../../stores/shows/ShowsAction'; 4 | import { selectEpisodes } from '../../selectors/episodes/EpisodesSelector'; 5 | import LoadingIndicator from '../components/loading-indicator/LoadingIndicator'; 6 | import { selectRequesting } from '../../selectors/requesting/RequestingSelector'; 7 | import EpisodesTable from './components/episodes-table/EpisodesTable'; 8 | 9 | const mapStateToProps = (state, ownProps) => ({ 10 | episodeTables: selectEpisodes(state), 11 | isRequesting: selectRequesting(state, [ShowsAction.REQUEST_EPISODES]), 12 | }); 13 | 14 | class EpisodesPage extends React.Component { 15 | componentDidMount() { 16 | this.props.dispatch(ShowsAction.requestEpisodes()); 17 | } 18 | 19 | render() { 20 | const { isRequesting, episodeTables } = this.props; 21 | 22 | return ( 23 | <> 24 | 25 | {episodeTables.map((model) => ( 26 | 27 | ))} 28 | 29 | ); 30 | } 31 | } 32 | 33 | export { EpisodesPage as Unconnected }; 34 | export default connect(mapStateToProps)(EpisodesPage); 35 | -------------------------------------------------------------------------------- /src/views/home-page/HomePage.jsx: -------------------------------------------------------------------------------- 1 | import styles from './HomePage.module.scss'; 2 | 3 | import React from 'react'; 4 | import { connect } from 'react-redux'; 5 | import ShowsAction from '../../stores/shows/ShowsAction'; 6 | import Actors from './components/actors/Actors'; 7 | import MainOverview from './components/main-overview/MainOverview'; 8 | import { Divider, Icon, Header } from 'semantic-ui-react'; 9 | import LoadingIndicator from '../components/loading-indicator/LoadingIndicator'; 10 | import { selectRequesting } from '../../selectors/requesting/RequestingSelector'; 11 | 12 | const mapStateToProps = (state, ownProps) => ({ 13 | isRequesting: selectRequesting(state, [ShowsAction.REQUEST_SHOW, ShowsAction.REQUEST_CAST]), 14 | }); 15 | 16 | class HomePage extends React.Component { 17 | render() { 18 | const { isRequesting } = this.props; 19 | 20 | return ( 21 |
22 | 23 | 24 | 25 |
26 | Cast 27 |
28 |
29 | 30 |
31 |
32 | ); 33 | } 34 | } 35 | 36 | export { HomePage as Unconnected }; 37 | export default connect(mapStateToProps)(HomePage); 38 | -------------------------------------------------------------------------------- /src/views/home-page/components/main-overview/MainOverview.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { connect } from 'react-redux'; 3 | import { Item } from 'semantic-ui-react'; 4 | import ShowsAction from '../../../../stores/shows/ShowsAction'; 5 | 6 | const mapStateToProps = (state, ownProps) => ({ 7 | show: state.shows.show, 8 | }); 9 | 10 | class MainOverview extends React.Component { 11 | componentDidMount() { 12 | this.props.dispatch(ShowsAction.requestShow()); 13 | } 14 | 15 | render() { 16 | const { show } = this.props; 17 | 18 | if (!show) { 19 | return null; 20 | } 21 | 22 | const image = show?.image?.medium ?? ''; 23 | const network = show?.network?.name ?? ''; 24 | 25 | return ( 26 | 27 | 28 | 29 | 30 | {show.name} 31 | {network} 32 | 33 |
34 | 35 | {show.genres.join(' | ')} 36 | 37 | 38 | 39 | ); 40 | } 41 | } 42 | 43 | export { MainOverview as Unconnected }; 44 | export default connect(mapStateToProps)(MainOverview); 45 | -------------------------------------------------------------------------------- /src/views/App.jsx: -------------------------------------------------------------------------------- 1 | import React, { Suspense, lazy } from 'react'; 2 | import { ConnectedRouter } from 'connected-react-router'; 3 | import { Route, Switch } from 'react-router-dom'; 4 | import RouteEnum from '../constants/RouteEnum'; 5 | import MainNav from './components/main-nav/MainNav'; 6 | import LoadingIndicator from './components/loading-indicator/LoadingIndicator'; 7 | import Toasts from './components/toasts/Toasts'; 8 | 9 | const HomePage = lazy(() => import('./home-page/HomePage')); 10 | const EpisodesPage = lazy(() => import('./episodes-page/EpisodesPage')); 11 | const AboutPage = lazy(() => import('./about-page/AboutPage')); 12 | const NotFoundPage = lazy(() => import('./not-found-page/NotFoundPage')); 13 | 14 | export default class App extends React.Component { 15 | render() { 16 | return ( 17 | 18 | }> 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | ); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/views/components/toast-card/ToastCard.jsx: -------------------------------------------------------------------------------- 1 | // import styles from './ToastCard.module.scss'; 2 | 3 | import * as React from 'react'; 4 | import { connect } from 'react-redux'; 5 | import { Button, Card } from 'semantic-ui-react'; 6 | import ToastStatusEnum from '../../../constants/ToastStatusEnum'; 7 | import ToastsAction from '../../../stores/toasts/ToastsAction'; 8 | 9 | const mapStateToProps = (state, ownProps) => ({}); 10 | 11 | class ToastCard extends React.Component { 12 | buttonColorMap = { 13 | [ToastStatusEnum.Error]: 'red', 14 | [ToastStatusEnum.Warning]: 'orange', 15 | [ToastStatusEnum.Success]: 'green', 16 | }; 17 | 18 | render() { 19 | const { item } = this.props; 20 | const buttonColor = this.buttonColorMap[item.type]; 21 | 22 | return ( 23 | 24 | 25 | 26 | 27 | 28 | 29 | 32 | 33 | 34 | ); 35 | } 36 | 37 | _onClickRemoveNotification = (event, data) => { 38 | this.props.dispatch(ToastsAction.removeById(this.props.item.id)); 39 | }; 40 | } 41 | 42 | export { ToastCard as Unconnected }; 43 | export default connect(mapStateToProps)(ToastCard); 44 | -------------------------------------------------------------------------------- /tools/templates/react/redux-store/__store__Action.ts: -------------------------------------------------------------------------------- 1 | import __model__ResponseModel from './models/__model__(kebabCase)/__model__ResponseModel'; 2 | import __store__Effect from './__store__Effect'; 3 | 4 | type ActionUnion = void | HttpErrorResponseModel | __model__ResponseModel; 5 | 6 | export default class __store__Action { 7 | public static readonly REQUEST___model__(constantCase): string = '__store__Action.REQUEST___model__(constantCase)'; 8 | public static readonly REQUEST___model__(constantCase)_FINISHED: string = '__store__Action.REQUEST___model__(constantCase)_FINISHED'; 9 | 10 | public static request__model__(): any { 11 | return async (dispatch: ReduxDispatch, getState: () => IStore) => { 12 | await ActionUtility.createThunkEffect<__model__ResponseModel>(dispatch, __store__Action.REQUEST___model__(constantCase), __store__Effect.request__model__); 13 | }; 14 | } 15 | 16 | public static request__model__Alt(): any { 17 | return async (dispatch: ReduxDispatch, getState: () => IStore) => { 18 | dispatch({type: __store__Action.REQUEST___model__(constantCase)}); 19 | 20 | const model: __model__ResponseModel | HttpErrorResponseModel = await __store__Effect.request__model__(); 21 | 22 | dispatch({ 23 | type: __store__Action.REQUEST___model__(constantCase)_FINISHED, 24 | payload: model, 25 | error: model instanceof HttpErrorResponseModel, 26 | }); 27 | }; 28 | } 29 | 30 | } 31 | -------------------------------------------------------------------------------- /src/stores/requesting/RequestingReducer.spec.js: -------------------------------------------------------------------------------- 1 | import RequestingReducer from './RequestingReducer'; 2 | 3 | describe('RequestingReducer', () => { 4 | const requestActionType = 'SomeAction.REQUEST_SOMETHING'; 5 | const requestActionTypeFinished = 'SomeAction.REQUEST_SOMETHING_FINISHED'; 6 | 7 | it('returns default state with invalid action type', () => { 8 | const action = { type: '' }; 9 | 10 | expect(RequestingReducer.reducer(undefined, action)).toEqual(RequestingReducer.initialState); 11 | }); 12 | 13 | describe('handle REQUEST_* action types', () => { 14 | it('should add the request action type as a key on the state and assign the value as true', () => { 15 | const action = { type: requestActionType }; 16 | 17 | const actualResult = RequestingReducer.reducer(RequestingReducer.initialState, action); 18 | const expectedResult = { 19 | [requestActionType]: true, 20 | }; 21 | 22 | expect(actualResult).toEqual(expectedResult); 23 | }); 24 | }); 25 | 26 | describe('handle REQUEST_*_FINISHED action types', () => { 27 | it('should update the request action type key on the state and assign the value to false', () => { 28 | const action = { type: requestActionTypeFinished }; 29 | 30 | const actualResult = RequestingReducer.reducer(RequestingReducer.initialState, action); 31 | const expectedResult = { 32 | [requestActionType]: false, 33 | }; 34 | 35 | expect(actualResult).toEqual(expectedResult); 36 | }); 37 | }); 38 | }); 39 | -------------------------------------------------------------------------------- /src/stores/shows/models/episodes/EpisodeModel.js: -------------------------------------------------------------------------------- 1 | import { BaseModel } from 'sjs-base-model'; 2 | import ImageModel from '../ImageModel'; 3 | 4 | /* 5 | // Returned Api Data Sample 6 | { 7 | "id": 4155, 8 | "url": "http://www.tvmaze.com/episodes/4155/hell-on-wheels-1x01-pilot", 9 | "name": "Pilot", 10 | "season": 1, 11 | "number": 1, 12 | "airdate": "2011-11-06", 13 | "airtime": "22:00", 14 | "airstamp": "2011-11-07T03:00:00+00:00", 15 | "runtime": 60, 16 | "image": { 17 | "medium": "http://static.tvmaze.com/uploads/images/medium_landscape/9/22633.jpg", 18 | "original": "http://static.tvmaze.com/uploads/images/original_untouched/9/22633.jpg" 19 | }, 20 | "summary": "

A Western about a former Confederate soldier (Anson Mount) and his quest for revenge on the Union troops who killed his wife. In the premiere episode, he heads west to take a job helping to construct the first transcontinental railroad.

", 21 | "_links": { 22 | "self": { 23 | "href": "http://api.tvmaze.com/episodes/4155" 24 | } 25 | } 26 | } 27 | */ 28 | export default class EpisodeModel extends BaseModel { 29 | id = 0; 30 | season = 0; 31 | number = 0; 32 | name = ''; 33 | airdate = ''; 34 | image = ImageModel; 35 | summary = ''; 36 | 37 | /* 38 | * Client-Side properties (Not from API) 39 | */ 40 | // noneApiProperties = null; 41 | 42 | constructor(data) { 43 | super(); 44 | 45 | this.update(data); 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 12 | 13 | 22 | React App 23 | 24 | 25 | 26 |
27 | 37 | 38 | 39 | -------------------------------------------------------------------------------- /src/views/about-page/AboutPage.jsx: -------------------------------------------------------------------------------- 1 | import styles from './AboutPage.module.scss'; 2 | 3 | import React from 'react'; 4 | import { connect } from 'react-redux'; 5 | import { selectErrorText } from '../../selectors/error/ErrorSelector'; 6 | import ShowsAction from '../../stores/shows/ShowsAction'; 7 | import { selectRequesting } from '../../selectors/requesting/RequestingSelector'; 8 | import LoadingIndicator from '../components/loading-indicator/LoadingIndicator'; 9 | import { Header, Message, Container } from 'semantic-ui-react'; 10 | 11 | const mapStateToProps = (state, ownProps) => ({ 12 | isRequesting: selectRequesting(state, [ShowsAction.REQUEST_ERROR]), 13 | requestErrorText: selectErrorText(state, [ShowsAction.REQUEST_ERROR_FINISHED]), 14 | }); 15 | 16 | class AboutPage extends React.Component { 17 | componentDidMount() { 18 | this.props.dispatch(ShowsAction.requestError()); 19 | } 20 | 21 | render() { 22 | const { isRequesting, requestErrorText } = this.props; 23 | 24 | return ( 25 |
26 |
About
27 | 28 | 29 |

30 | This page is only to show how to handle API errors on the page. You will also notice a popup indicator with the actual error text. Below 31 | we create a custom error message. 32 |

33 |
34 | {requestErrorText && } 35 |
36 |
37 | ); 38 | } 39 | } 40 | 41 | export { AboutPage as Unconnected }; 42 | export default connect(mapStateToProps)(AboutPage); 43 | -------------------------------------------------------------------------------- /src/stores/shows/ShowsAction.js: -------------------------------------------------------------------------------- 1 | import ShowsEffect from './ShowsEffect'; 2 | import ActionUtility from '../../utilities/ActionUtility'; 3 | 4 | export default class ShowsAction { 5 | static REQUEST_SHOW = 'ShowsAction.REQUEST_SHOW'; 6 | static REQUEST_SHOW_FINISHED = 'ShowsAction.REQUEST_SHOW_FINISHED'; 7 | 8 | static REQUEST_EPISODES = 'ShowsAction.REQUEST_EPISODES'; 9 | static REQUEST_EPISODES_FINISHED = 'ShowsAction.REQUEST_EPISODES_FINISHED'; 10 | 11 | static REQUEST_CAST = 'ShowsAction.REQUEST_CAST'; 12 | static REQUEST_CAST_FINISHED = 'ShowsAction.REQUEST_CAST_FINISHED'; 13 | 14 | static REQUEST_ERROR = 'ShowsAction.REQUEST_ERROR'; 15 | static REQUEST_ERROR_FINISHED = 'ShowsAction.REQUEST_ERROR_FINISHED'; 16 | 17 | static requestShow() { 18 | return async (dispatch, getState) => { 19 | const showId = getState().shows.currentShowId; 20 | 21 | await ActionUtility.createThunkEffect(dispatch, ShowsAction.REQUEST_SHOW, ShowsEffect.requestShow, showId); 22 | }; 23 | } 24 | 25 | static requestEpisodes() { 26 | return async (dispatch, getState) => { 27 | const showId = getState().shows.currentShowId; 28 | 29 | await ActionUtility.createThunkEffect(dispatch, ShowsAction.REQUEST_EPISODES, ShowsEffect.requestEpisodes, showId); 30 | }; 31 | } 32 | 33 | static requestCast() { 34 | return async (dispatch, getState) => { 35 | const showId = getState().shows.currentShowId; 36 | 37 | await ActionUtility.createThunkEffect(dispatch, ShowsAction.REQUEST_CAST, ShowsEffect.requestCast, showId); 38 | }; 39 | } 40 | 41 | static requestError() { 42 | return async (dispatch, getState) => { 43 | await ActionUtility.createThunkEffect(dispatch, ShowsAction.REQUEST_ERROR, ShowsEffect.requestError); 44 | }; 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/stores/shows/ShowsEffect.js: -------------------------------------------------------------------------------- 1 | import environment from 'environment'; 2 | import HttpErrorResponseModel from '../../models/HttpErrorResponseModel'; 3 | import HttpUtility from '../../utilities/HttpUtility'; 4 | import ShowModel from './models/shows/ShowModel'; 5 | import EpisodeModel from './models/episodes/EpisodeModel'; 6 | import CastModel from './models/cast/CastModel'; 7 | import EffectUtility from '../../utilities/EffectUtility'; 8 | 9 | export default class ShowsEffect { 10 | static async requestShow(showId) { 11 | const endpoint = environment.api.shows.replace(':showId', showId); 12 | 13 | return EffectUtility.getToModel(ShowModel, endpoint); 14 | } 15 | 16 | static async requestEpisodes(showId) { 17 | const endpoint = environment.api.episodes.replace(':showId', showId); 18 | 19 | return EffectUtility.getToModel(EpisodeModel, endpoint); 20 | } 21 | 22 | static async requestCast(showId) { 23 | const endpoint = environment.api.cast.replace(':showId', showId); 24 | 25 | // Below is just to show you what the above "requestEpisodes" method is doing with "HttpUtility.getToModel". 26 | // In your application you can change this to match the "requestEpisodes" method. 27 | const response = await HttpUtility.get(endpoint); 28 | 29 | if (response instanceof HttpErrorResponseModel) { 30 | return response; 31 | } 32 | 33 | return response.data.map((json) => new CastModel(json)); 34 | } 35 | 36 | /** 37 | * This is only to trigger an error api response so we can use it for an example in the AboutPage 38 | */ 39 | static async requestError() { 40 | const endpoint = environment.api.errorExample; 41 | const response = await HttpUtility.get(endpoint); 42 | 43 | if (response instanceof HttpErrorResponseModel) { 44 | return response; 45 | } 46 | 47 | return response.data; 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | // https://www.robertcooper.me/using-eslint-and-prettier-in-a-typescript-project 2 | // https://github.com/typescript-eslint/typescript-eslint/tree/master/packages/eslint-plugin/docs/rules 3 | 4 | module.exports = { 5 | parser: '@typescript-eslint/parser', // Specifies the ESLint parser 6 | plugins: [ 7 | 'react-hooks', // Uses eslint-plugin-react-hooks 8 | ], 9 | extends: [ 10 | 'plugin:react/recommended', // Uses the recommended rules from @eslint-plugin-react 11 | 'plugin:@typescript-eslint/recommended', // Uses the recommended rules from @typescript-eslint/eslint-plugin 12 | 'prettier/@typescript-eslint', // Uses eslint-config-prettier to disable ESLint rules from @typescript-eslint/eslint-plugin that would conflict with prettier 13 | // Make sure this is always the last configuration in the extends array. 14 | 'plugin:prettier/recommended', // Enables eslint-plugin-prettier and eslint-config-prettier. This will display prettier errors as ESLint errors. 15 | ], 16 | parserOptions: { 17 | ecmaVersion: 2018, // Allows for the parsing of modern ECMAScript features 18 | sourceType: 'module', // Allows for the use of imports 19 | ecmaFeatures: { 20 | jsx: true, // Allows for the parsing of JSX 21 | }, 22 | }, 23 | rules: { 24 | // Place to specify ESLint rules. Can be used to overwrite rules specified from the extended configs 25 | // 0 = off, 1 = warn, 2 = error 26 | '@typescript-eslint/interface-name-prefix': 0, 27 | '@typescript-eslint/no-empty-interface': 0, 28 | '@typescript-eslint/no-unused-vars': 0, 29 | '@typescript-eslint/no-explicit-any': 0, 30 | '@typescript-eslint/no-inferrable-types': 0, 31 | '@typescript-eslint/no-non-null-assertion': 0, 32 | '@typescript-eslint/no-use-before-define': 0, 33 | 'react-hooks/rules-of-hooks': 2, 34 | 'react-hooks/exhaustive-deps': 1, 35 | '@typescript-eslint/explicit-function-return-type': 0, 36 | }, 37 | settings: { 38 | react: { 39 | version: 'detect', // Tells eslint-plugin-react to automatically detect the version of React to use 40 | }, 41 | }, 42 | }; 43 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## react-redux-architecture 2 | 3 | View the running [website](https://codebelt.github.io/react-redux-architecture/) 4 | 5 | Check out the source code in different formats: 6 | 7 | - [React/Redux (TypeScript — Classes)](https://github.com/codeBelt/react-redux-architecture/tree/TypeScript) 8 | - [React/Redux (JavaScript — Classes)](https://github.com/codeBelt/react-redux-architecture/tree/JavaScript) 9 | - [React Hooks/Redux (TypeScript — Functions)](https://github.com/codeBelt/react-redux-architecture/tree/ts/function) 10 | - [React Hooks/Redux (JavaScript — Functions)](https://github.com/codeBelt/react-redux-architecture/tree/js/function) 11 | - [React Hooks/Redux (TypeScript — Arrows)](https://github.com/codeBelt/react-redux-architecture/tree/ts/arrows) 12 | 13 | Read the article explaining this code: 14 | [My Awesome React Redux Structure](https://medium.com/better-programming/my-awesome-react-redux-structure-6044e5007e22) 15 | 16 | ![alt text](./appScreenshot.png 'App Screenshot') 17 | 18 | ## Create React App 19 | 20 | This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app). 21 | 22 | ### Install Packages 23 | 24 | #### `yarn` or `npm install` 25 | 26 | ## Available Scripts 27 | 28 | In the project directory, you can run: 29 | 30 | #### `yarn start` or `npm start` 31 | 32 | Runs the app in the development mode.
33 | Open [http://localhost:3000](http://localhost:3000) to view it in the browser. 34 | 35 | The page will reload if you make edits.
36 | You will also see any lint errors in the console. 37 | 38 | #### `yarn generate` or `npm generate` 39 | 40 | Scaffolding tool, see [generate-template-files](https://github.com/codeBelt/generate-template-files#readme). Check the `tools/templates` directory for existing templates. 41 | 42 | #### `yarn build` or `npm build` 43 | 44 | Builds the app for production to the `build` folder.
45 | It correctly bundles React in production mode and optimizes the build for the best performance. 46 | 47 | The build is minified and the filenames include the hashes.
48 | Your app is ready to be deployed! 49 | 50 | See the section about [deployment](https://facebook.github.io/create-react-app/docs/deployment) for more information. 51 | -------------------------------------------------------------------------------- /src/stores/shows/ShowsReducer.spec.js: -------------------------------------------------------------------------------- 1 | import ActionUtility from '../../utilities/ActionUtility'; 2 | import ShowsReducer from './ShowsReducer'; 3 | import ShowsAction from './ShowsAction'; 4 | import ShowModel from './models/shows/ShowModel'; 5 | import EpisodeModel from './models/episodes/EpisodeModel'; 6 | import CastModel from './models/cast/CastModel'; 7 | 8 | describe('ShowsReducer', () => { 9 | const showsReducer = new ShowsReducer(); 10 | 11 | it('returns default state with invalid action type', () => { 12 | const action = ActionUtility.createAction(''); 13 | 14 | expect(showsReducer.reducer(undefined, action)).toEqual(showsReducer.initialState); 15 | }); 16 | 17 | describe(ShowsAction.REQUEST_SHOW_FINISHED, () => { 18 | it('should update show state', () => { 19 | const payload = new ShowModel({}); 20 | const action = ActionUtility.createAction(ShowsAction.REQUEST_SHOW_FINISHED, payload); 21 | 22 | const actualResult = showsReducer.reducer(showsReducer.initialState, action); 23 | const expectedResult = { 24 | ...showsReducer.initialState, 25 | show: payload, 26 | }; 27 | 28 | expect(actualResult).toEqual(expectedResult); 29 | }); 30 | }); 31 | 32 | describe(ShowsAction.REQUEST_EPISODES_FINISHED, () => { 33 | it('should update episodes state', () => { 34 | const payload = [new EpisodeModel({})]; 35 | const action = ActionUtility.createAction(ShowsAction.REQUEST_EPISODES_FINISHED, payload); 36 | 37 | const actualResult = showsReducer.reducer(showsReducer.initialState, action); 38 | const expectedResult = { 39 | ...showsReducer.initialState, 40 | episodes: payload, 41 | }; 42 | 43 | expect(actualResult).toEqual(expectedResult); 44 | }); 45 | }); 46 | 47 | describe(ShowsAction.REQUEST_CAST_FINISHED, () => { 48 | it('should update cast state', () => { 49 | const payload = [new CastModel({})]; 50 | const action = ActionUtility.createAction(ShowsAction.REQUEST_CAST_FINISHED, payload); 51 | 52 | const actualResult = showsReducer.reducer(showsReducer.initialState, action); 53 | const expectedResult = { 54 | ...showsReducer.initialState, 55 | actors: payload, 56 | }; 57 | 58 | expect(actualResult).toEqual(expectedResult); 59 | }); 60 | }); 61 | }); 62 | -------------------------------------------------------------------------------- /src/stores/error/ErrorReducer.js: -------------------------------------------------------------------------------- 1 | import ErrorAction from './ErrorAction'; 2 | 3 | /* 4 | * Note: This reducer breaks convention on how reducers should be setup. 5 | */ 6 | export default class ErrorReducer { 7 | static initialState = {}; 8 | 9 | static reducer(state = ErrorReducer.initialState, action) { 10 | const { type, error, payload } = action; 11 | 12 | /* 13 | * Removes an HttpErrorResponseModel by it's id that is in the action payload. 14 | */ 15 | if (type === ErrorAction.REMOVE) { 16 | // Create a new state without the error that has the same id as the payload. 17 | return Object.entries(state).reduce((newState, [key, value]) => { 18 | if (value.id !== payload) { 19 | newState[key] = value; 20 | } 21 | 22 | return newState; 23 | }, {}); 24 | } 25 | 26 | /* 27 | * Removes all errors by returning the initial state which is an empty object. 28 | */ 29 | if (type === ErrorAction.CLEAR_ALL) { 30 | return ErrorReducer.initialState; 31 | } 32 | 33 | /* 34 | * True if the action type has the key word '_FINISHED' then the action is finished. 35 | */ 36 | const isFinishedRequestType = type.includes('_FINISHED'); 37 | /* 38 | * True if the action type has the key word 'REQUEST_' and not '_FINISHED'. 39 | */ 40 | const isStartRequestType = type.includes('REQUEST_') && !isFinishedRequestType; 41 | 42 | /* 43 | * If an action is started we want to remove any old errors because there is a new action has been re-dispatched. 44 | */ 45 | if (isStartRequestType) { 46 | // Using ES7 Object Rest Spread operator to omit properties from an object. 47 | const { [`${type}_FINISHED`]: value, ...stateWithoutFinishedType } = state; 48 | 49 | return stateWithoutFinishedType; 50 | } 51 | 52 | /* 53 | * True if the action is finished and the error property is true. 54 | */ 55 | const isError = isFinishedRequestType && Boolean(error); 56 | 57 | /* 58 | * For any start and finished actions that don't have errors we return the current state. 59 | */ 60 | if (isError === false) { 61 | return state; 62 | } 63 | 64 | /* 65 | * At this point the "type" will be a finished action type (e.g. "SomeAction.REQUEST_*_FINISHED"). 66 | * The payload will be a HttpErrorResponseModel. 67 | */ 68 | return { 69 | ...state, 70 | [type]: payload, 71 | }; 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /src/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /src/stores/shows/ShowsEffect.spec.js: -------------------------------------------------------------------------------- 1 | import ShowsEffect from './ShowsEffect'; 2 | import nock from 'nock'; 3 | import axios from 'axios'; 4 | import environment from 'environment'; 5 | import ShowModel from './models/shows/ShowModel'; 6 | import StringUtil from '../../utilities/StringUtil'; 7 | import EpisodeModel from './models/episodes/EpisodeModel'; 8 | import CastModel from './models/cast/CastModel'; 9 | 10 | axios.defaults.adapter = require('axios/lib/adapters/http'); 11 | 12 | describe('ShowsEffect', () => { 13 | describe('requestShow', () => { 14 | it('has a successful response', async () => { 15 | const showId = '74'; 16 | const endpoint = environment.api.shows.replace(':showId', showId); 17 | const [baseUrl, sourceUrl] = StringUtil.splitBySeparator(endpoint, '.com'); 18 | 19 | const scope = nock(baseUrl) 20 | .get(sourceUrl) 21 | .reply(200, { name: 'Robert' }); 22 | 23 | const actualResult = await ShowsEffect.requestShow(showId); 24 | 25 | expect(actualResult).toBeInstanceOf(ShowModel); 26 | expect(actualResult.name).toEqual('Robert'); 27 | 28 | // Assert that the expected request was made. 29 | scope.done(); 30 | }); 31 | }); 32 | 33 | describe('requestEpisodes', () => { 34 | it('has a successful response', async () => { 35 | const showId = '74'; 36 | const endpoint = environment.api.episodes.replace(':showId', showId); 37 | const [baseUrl, sourceUrl] = StringUtil.splitBySeparator(endpoint, '.com'); 38 | 39 | const scope = nock(baseUrl) 40 | .get(sourceUrl) 41 | .reply(200, [{ summary: 'Robert is cool' }]); 42 | 43 | const actualResult = await ShowsEffect.requestEpisodes(showId); 44 | 45 | expect(actualResult[0]).toBeInstanceOf(EpisodeModel); 46 | expect(actualResult[0].summary).toEqual('Robert is cool'); 47 | 48 | // Assert that the expected request was made. 49 | scope.done(); 50 | }); 51 | }); 52 | 53 | describe('requestCast', () => { 54 | it('has a successful response', async () => { 55 | const showId = '74'; 56 | const endpoint = environment.api.cast.replace(':showId', showId); 57 | const [baseUrl, sourceUrl] = StringUtil.splitBySeparator(endpoint, '.com'); 58 | 59 | const scope = nock(baseUrl) 60 | .get(sourceUrl) 61 | .reply(200, [{ self: true }]); 62 | 63 | const actualResult = await ShowsEffect.requestCast(showId); 64 | 65 | expect(actualResult[0]).toBeInstanceOf(CastModel); 66 | expect(actualResult[0].self).toBe(true); 67 | 68 | // Assert that the expected request was made. 69 | scope.done(); 70 | }); 71 | }); 72 | }); 73 | -------------------------------------------------------------------------------- /src/selectors/error/ErrorSelector.js: -------------------------------------------------------------------------------- 1 | import { createSelector } from 'reselect'; 2 | 3 | /** 4 | * @typedef {Object.} IErrorState 5 | */ 6 | 7 | /** 8 | * ErrorSelector 9 | * 10 | * @static 11 | */ 12 | export class ErrorSelector { 13 | /** 14 | * Returns a new object with the keys being the finished action type 15 | * (e.g. "SomeAction.REQUEST_*_FINISHED") and the value being a 16 | * HttpErrorResponseModel. 17 | * 18 | * @param {IErrorState} errorState 19 | * @param {string[]} actionTypes 20 | * @returns {IErrorState} 21 | * @static 22 | */ 23 | static selectRawErrors(errorState, actionTypes) { 24 | return actionTypes.reduce((partialState, actionType) => { 25 | const httpErrorResponseModel = errorState[actionType]; 26 | 27 | if (httpErrorResponseModel) { 28 | partialState[actionType] = httpErrorResponseModel; 29 | } 30 | 31 | return partialState; 32 | }, {}); 33 | } 34 | 35 | /** 36 | * Finds any errors matching the array of actionTypes and combines all error 37 | * messages in to a single string. 38 | * 39 | * @param {IErrorState} errorState 40 | * @param {string[]} actionTypes 41 | * @returns {string} 42 | * @static 43 | */ 44 | static selectErrorText(errorState, actionTypes) { 45 | const errorList = actionTypes.reduce((errorMessages, actionType) => { 46 | const httpErrorResponseModel = errorState[actionType]; 47 | 48 | if (httpErrorResponseModel) { 49 | const { message, errors } = httpErrorResponseModel; 50 | const arrayOfErrors = errors.length ? errors : [message]; 51 | 52 | return errorMessages.concat(arrayOfErrors); 53 | } 54 | 55 | return errorMessages; 56 | }, []); 57 | 58 | return errorList.join(', '); 59 | } 60 | 61 | /** 62 | * Returns true or false if there are errors found matching the array of actionTypes. 63 | * 64 | * @param {IErrorState} errorState 65 | * @param {string[]} actionTypes 66 | * @returns {boolean} 67 | * @static 68 | */ 69 | static hasErrors(errorState, actionTypes) { 70 | return actionTypes.map((actionType) => errorState[actionType]).filter(Boolean).length > 0; 71 | } 72 | } 73 | 74 | export const selectRawErrors = createSelector( 75 | (state) => state.error, 76 | (state, actionTypes) => actionTypes, 77 | ErrorSelector.selectRawErrors 78 | ); 79 | 80 | export const selectErrorText = createSelector( 81 | (state) => state.error, 82 | (state, actionTypes) => actionTypes, 83 | ErrorSelector.selectErrorText 84 | ); 85 | 86 | export const hasErrors = createSelector( 87 | (state) => state.error, 88 | (state, actionTypes) => actionTypes, 89 | ErrorSelector.hasErrors 90 | ); 91 | -------------------------------------------------------------------------------- /src/stores/shows/ShowsAction.spec.js: -------------------------------------------------------------------------------- 1 | import ShowsAction from './ShowsAction'; 2 | import ActionUtility from '../../utilities/ActionUtility'; 3 | import { mockStoreFixture } from '../../__fixtures__/reduxFixtures'; 4 | import ShowModel from './models/shows/ShowModel'; 5 | import ShowsEffect from './ShowsEffect'; 6 | import EpisodeModel from './models/episodes/EpisodeModel'; 7 | import CastModel from './models/cast/CastModel'; 8 | 9 | describe('ShowsAction', () => { 10 | let store; 11 | 12 | beforeEach(() => { 13 | store = mockStoreFixture({ 14 | shows: { 15 | currentShowId: '74', 16 | }, 17 | }); 18 | }); 19 | 20 | afterEach(() => { 21 | jest.clearAllMocks(); 22 | jest.restoreAllMocks(); 23 | }); 24 | 25 | describe('requestShow', () => { 26 | it('has a successful response', async () => { 27 | const expectedResponse = new ShowModel({}); 28 | 29 | jest.spyOn(ShowsEffect, 'requestShow').mockImplementation(async () => expectedResponse); 30 | 31 | await store.dispatch(ShowsAction.requestShow()); 32 | 33 | const actualResult = store.getActions(); 34 | const expectedResult = [ 35 | ActionUtility.createAction(ShowsAction.REQUEST_SHOW), 36 | ActionUtility.createAction(ShowsAction.REQUEST_SHOW_FINISHED, expectedResponse), 37 | ]; 38 | 39 | expect(actualResult).toEqual(expectedResult); 40 | }); 41 | }); 42 | 43 | describe('requestEpisodes', () => { 44 | it('has a successful response', async () => { 45 | const expectedResponse = [new EpisodeModel({})]; 46 | 47 | jest.spyOn(ShowsEffect, 'requestEpisodes').mockImplementation(async () => expectedResponse); 48 | 49 | await store.dispatch(ShowsAction.requestEpisodes()); 50 | 51 | const actualResult = store.getActions(); 52 | const expectedResult = [ 53 | ActionUtility.createAction(ShowsAction.REQUEST_EPISODES), 54 | ActionUtility.createAction(ShowsAction.REQUEST_EPISODES_FINISHED, expectedResponse), 55 | ]; 56 | 57 | expect(actualResult).toEqual(expectedResult); 58 | }); 59 | }); 60 | 61 | describe('requestCast', () => { 62 | it('has a successful response', async () => { 63 | const expectedResponse = [new CastModel({})]; 64 | 65 | jest.spyOn(ShowsEffect, 'requestCast').mockImplementation(async () => expectedResponse); 66 | 67 | await store.dispatch(ShowsAction.requestCast()); 68 | 69 | const actualResult = store.getActions(); 70 | const expectedResult = [ 71 | ActionUtility.createAction(ShowsAction.REQUEST_CAST), 72 | ActionUtility.createAction(ShowsAction.REQUEST_CAST_FINISHED, expectedResponse), 73 | ]; 74 | 75 | expect(actualResult).toEqual(expectedResult); 76 | }); 77 | }); 78 | }); 79 | -------------------------------------------------------------------------------- /src/stores/shows/models/shows/ShowModel.js: -------------------------------------------------------------------------------- 1 | import { BaseModel } from 'sjs-base-model'; 2 | import NetworkModel from './NetworkModel'; 3 | import ImageModel from '../ImageModel'; 4 | 5 | /* 6 | // Returned Api Data Sample 7 | { 8 | "id": 74, 9 | "url": "http://www.tvmaze.com/shows/74/hell-on-wheels", 10 | "name": "Hell on Wheels", 11 | "type": "Scripted", 12 | "language": "English", 13 | "genres": [ 14 | "Drama", 15 | "Action", 16 | "Western" 17 | ], 18 | "status": "Ended", 19 | "runtime": 60, 20 | "premiered": "2011-11-06", 21 | "officialSite": "http://www.amctv.com/shows/hell-on-wheels", 22 | "schedule": { 23 | "time": "21:00", 24 | "days": [ 25 | "Saturday" 26 | ] 27 | }, 28 | "rating": { 29 | "average": 8.5 30 | }, 31 | "weight": 82, 32 | "network": { 33 | "id": 20, 34 | "name": "AMC", 35 | "country": { 36 | "name": "United States", 37 | "code": "US", 38 | "timezone": "America/New_York" 39 | } 40 | }, 41 | "webChannel": null, 42 | "externals": { 43 | "tvrage": 27195, 44 | "thetvdb": 212961, 45 | "imdb": "tt1699748" 46 | }, 47 | "image": { 48 | "medium": "http://static.tvmaze.com/uploads/images/medium_portrait/0/526.jpg", 49 | "original": "http://static.tvmaze.com/uploads/images/original_untouched/0/526.jpg" 50 | }, 51 | "summary": "

Hell on Wheels is an American Western television series about the construction of the First Transcontinental Railroad across the United States. The series follows the Union Pacific Railroad and its surveyors, laborers, prostitutes, mercenaries, and others who lived, worked and died in the mobile encampment called \"Hell on Wheels\" that followed the railhead west across the Great Plains. In particular, the story focuses on Cullen Bohannon, a former Confederate soldier who, while working as foreman and chief engineer on the railroad, initially attempts to track down the Union soldiers who murdered his wife and young son during the American Civil War.

", 52 | "updated": 1560886410, 53 | "_links": { 54 | "self": { 55 | "href": "http://api.tvmaze.com/shows/74" 56 | }, 57 | "previousepisode": { 58 | "href": "http://api.tvmaze.com/episodes/862325" 59 | } 60 | } 61 | } 62 | */ 63 | export default class ShowModel extends BaseModel { 64 | id = 0; 65 | name = ''; 66 | summary = ''; 67 | genres = []; 68 | network = NetworkModel; 69 | image = ImageModel; 70 | 71 | /* 72 | * Client-Side properties (Not from API) 73 | */ 74 | // noneApiProperties = null; 75 | 76 | constructor(data) { 77 | super(); 78 | 79 | this.update(data); 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /tools/generate.js: -------------------------------------------------------------------------------- 1 | const {generateTemplateFiles} = require('generate-template-files'); 2 | 3 | generateTemplateFiles([ 4 | // React 5 | { 6 | option: 'React Redux Store', 7 | defaultCase: '(pascalCase)', 8 | entry: { 9 | folderPath: './tools/templates/react/redux-store/', 10 | }, 11 | stringReplacers: ['__store__', '__model__'], 12 | output: { 13 | path: './src/stores/__store__(kebabCase)', 14 | pathAndFileNameDefaultCase: '(pascalCase)', 15 | }, 16 | }, 17 | { 18 | option: 'React Component', 19 | defaultCase: '(pascalCase)', 20 | entry: { 21 | folderPath: './tools/templates/react/component/', 22 | }, 23 | stringReplacers: ['__name__'], 24 | output: { 25 | path: './src/views/components/__name__(kebabCase)', 26 | pathAndFileNameDefaultCase: '(pascalCase)', 27 | }, 28 | }, 29 | { 30 | option: 'React Connected Component', 31 | defaultCase: '(pascalCase)', 32 | entry: { 33 | folderPath: './tools/templates/react/connected-component/', 34 | }, 35 | stringReplacers: ['__name__'], 36 | output: { 37 | path: './src/views/__name__(kebabCase)', 38 | pathAndFileNameDefaultCase: '(pascalCase)', 39 | }, 40 | }, 41 | { 42 | option: 'Selector', 43 | defaultCase: '(pascalCase)', 44 | entry: { 45 | folderPath: './tools/templates/react/selectors/', 46 | }, 47 | stringReplacers: ['__name__'], 48 | output: { 49 | path: './src/selectors/__name__(kebabCase)', 50 | pathAndFileNameDefaultCase: '(pascalCase)', 51 | }, 52 | }, 53 | { 54 | option: 'Model', 55 | defaultCase: '(pascalCase)', 56 | entry: { 57 | folderPath: './tools/templates/react/__model__Model.ts', 58 | }, 59 | stringReplacers: ['__model__'], 60 | output: { 61 | path: './src/models/__model__Model.ts', 62 | pathAndFileNameDefaultCase: '(pascalCase)', 63 | }, 64 | }, 65 | { 66 | option: 'Interface', 67 | defaultCase: '(pascalCase)', 68 | entry: { 69 | folderPath: './tools/templates/react/I__interface__.ts', 70 | }, 71 | stringReplacers: ['__interface__'], 72 | output: { 73 | path: './src/models/I__interface__.ts', 74 | pathAndFileNameDefaultCase: '(pascalCase)', 75 | }, 76 | }, 77 | { 78 | option: 'Enum', 79 | defaultCase: '(pascalCase)', 80 | entry: { 81 | folderPath: './tools/templates/react/__enum__Enum.ts', 82 | }, 83 | stringReplacers: ['__enum__'], 84 | output: { 85 | path: './src/constants/__enum__Enum.ts', 86 | pathAndFileNameDefaultCase: '(pascalCase)', 87 | }, 88 | }, 89 | ]); 90 | -------------------------------------------------------------------------------- /src/stores/error/ErrorReducer.spec.js: -------------------------------------------------------------------------------- 1 | import ErrorReducer from './ErrorReducer'; 2 | import ErrorAction from './ErrorAction'; 3 | import HttpErrorResponseModel from '../../models/HttpErrorResponseModel'; 4 | import ActionUtility from '../../utilities/ActionUtility'; 5 | 6 | describe('ErrorReducer', () => { 7 | const requestActionType = 'SomeAction.REQUEST_SOMETHING'; 8 | const requestActionTypeFinished = 'SomeAction.REQUEST_SOMETHING_FINISHED'; 9 | const httpErrorResponseModel = new HttpErrorResponseModel(); 10 | 11 | it('returns default state with invalid action type', () => { 12 | const action = ActionUtility.createAction(''); 13 | 14 | expect(ErrorReducer.reducer(undefined, action)).toEqual(ErrorReducer.initialState); 15 | }); 16 | 17 | describe('handle REQUEST_*_FINISHED action types', () => { 18 | it('should add error to state with *_FINISHED action type as the key', () => { 19 | const action = ActionUtility.createAction(requestActionTypeFinished, httpErrorResponseModel, true); 20 | 21 | const actualResult = ErrorReducer.reducer(ErrorReducer.initialState, action); 22 | const expectedResult = { 23 | [requestActionTypeFinished]: httpErrorResponseModel, 24 | }; 25 | 26 | expect(actualResult).toEqual(expectedResult); 27 | }); 28 | 29 | it('removes the the old error from state when a new action is dispatched for isStartRequestTypes', () => { 30 | const errorThatRemainsOnState = new HttpErrorResponseModel(); 31 | const initialState = { 32 | [requestActionTypeFinished]: httpErrorResponseModel, 33 | idOfKeyThatShouldNotBeRemoved: errorThatRemainsOnState, 34 | }; 35 | const action = ActionUtility.createAction(requestActionType, httpErrorResponseModel, true); 36 | 37 | const actualResult = ErrorReducer.reducer(initialState, action); 38 | const expectedResult = { 39 | idOfKeyThatShouldNotBeRemoved: errorThatRemainsOnState, 40 | }; 41 | 42 | expect(actualResult).toEqual(expectedResult); 43 | }); 44 | 45 | it('should not add error to state without *_FINISHED action type', () => { 46 | const action = ActionUtility.createAction(requestActionType, httpErrorResponseModel, true); 47 | 48 | const actualResult = ErrorReducer.reducer(ErrorReducer.initialState, action); 49 | const expectedResult = {}; 50 | 51 | expect(actualResult).toEqual(expectedResult); 52 | }); 53 | }); 54 | 55 | describe('removing an error action', () => { 56 | it('should remove error by id (which is the key on the state)', () => { 57 | const errorThatRemainsOnState = new HttpErrorResponseModel(); 58 | const initialState = { 59 | [requestActionTypeFinished]: httpErrorResponseModel, 60 | idOfKeyThatShouldNotBeRemoved: errorThatRemainsOnState, 61 | }; 62 | const action = ActionUtility.createAction(ErrorAction.REMOVE, httpErrorResponseModel.id); 63 | 64 | const actualResult = ErrorReducer.reducer(initialState, action); 65 | const expectedResult = { 66 | idOfKeyThatShouldNotBeRemoved: errorThatRemainsOnState, 67 | }; 68 | 69 | expect(actualResult).toEqual(expectedResult); 70 | }); 71 | }); 72 | 73 | describe('clearing all error actions', () => { 74 | it('should remove all errors, making error state an empty object', () => { 75 | const initialState = { 76 | [requestActionTypeFinished]: httpErrorResponseModel, 77 | }; 78 | const action = ActionUtility.createAction(ErrorAction.CLEAR_ALL); 79 | 80 | const actualResult = ErrorReducer.reducer(initialState, action); 81 | const expectedResult = {}; 82 | 83 | expect(actualResult).toEqual(expectedResult); 84 | }); 85 | }); 86 | }); 87 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-typescript", 3 | "version": "0.1.0", 4 | "private": true, 5 | "homepage": "https://codebelt.github.io/react-redux-architecture", 6 | "husky": { 7 | "hooks": { 8 | "pre-commit": "pretty-quick --staged", 9 | "post-commit": "git update-index -g" 10 | } 11 | }, 12 | "scripts": { 13 | "---------- HELPERS -------------------------------------------------------------------------------------": "", 14 | "generate": "node ./tools/generate.js", 15 | "---------- DEVELOPMENT ---------------------------------------------------------------------------------": "", 16 | "start": "cross-env CLIENT_ENV=development craco start", 17 | "prod": "cross-env CLIENT_ENV=production craco start", 18 | "---------- PRODUCTION ----------------------------------------------------------------------------------": "", 19 | "build": "cross-env CLIENT_ENV=production craco build", 20 | "predeploy": "npm run build", 21 | "deploy": "gh-pages -d build", 22 | "---------- TESTING -------------------------------------------------------------------------------------": "", 23 | "test": "cross-env CLIENT_ENV=test craco test", 24 | "ts": "tsc --noEmit", 25 | "ts:watch": "npm run ts -- --watch", 26 | "lint": "eslint 'src/**/*.{js,ts,tsx}' --fix", 27 | "--------------------------------------------------------------------------------------------------------": "" 28 | }, 29 | "eslintConfig": { 30 | "extends": "react-app" 31 | }, 32 | "browserslist": { 33 | "production": [ 34 | ">0.2%", 35 | "not dead", 36 | "not op_mini all" 37 | ], 38 | "development": [ 39 | "last 1 chrome version", 40 | "last 1 firefox version", 41 | "last 1 safari version" 42 | ] 43 | }, 44 | "dependencies": { 45 | "axios": "0.19.2", 46 | "classnames": "2.2.6", 47 | "connected-react-router": "6.7.0", 48 | "dayjs": "1.8.21", 49 | "history": "4.10.1", 50 | "lodash.groupby": "4.6.0", 51 | "react": "16.13.0", 52 | "react-app-polyfill": "1.0.6", 53 | "react-dom": "16.13.0", 54 | "react-redux": "7.2.0", 55 | "react-router-dom": "5.1.2", 56 | "redux": "4.0.5", 57 | "redux-devtools-extension": "2.13.8", 58 | "redux-freeze": "0.1.7", 59 | "redux-thunk": "2.3.0", 60 | "reselect": "4.0.0", 61 | "semantic-ui-css": "2.4.1", 62 | "semantic-ui-react": "0.88.2", 63 | "sjs-base-model": "1.9.0", 64 | "uuid": "7.0.1" 65 | }, 66 | "devDependencies": { 67 | "@babel/plugin-proposal-nullish-coalescing-operator": "7.8.3", 68 | "@babel/plugin-proposal-optional-chaining": "7.8.3", 69 | "@craco/craco": "5.6.3", 70 | "@types/classnames": "2.2.9", 71 | "@types/history": "4.7.5", 72 | "@types/jest": "25.1.3", 73 | "@types/lodash.groupby": "4.6.6", 74 | "@types/node": "13.7.7", 75 | "@types/react": "16.9.23", 76 | "@types/react-dom": "16.9.5", 77 | "@types/react-redux": "7.1.7", 78 | "@types/react-router-dom": "5.1.3", 79 | "@types/redux-mock-store": "1.0.2", 80 | "@types/uuid": "7.0.0", 81 | "@typescript-eslint/eslint-plugin": "2.21.0", 82 | "@typescript-eslint/parser": "2.21.0", 83 | "cross-env": "7.0.0", 84 | "eslint": "6.8.0", 85 | "eslint-config-prettier": "6.10.0", 86 | "eslint-plugin-prettier": "3.1.2", 87 | "eslint-plugin-react": "7.18.3", 88 | "eslint-plugin-react-hooks": "2.5.0", 89 | "generate-template-files": "2.2.1", 90 | "gh-pages": "2.2.0", 91 | "husky": "4.2.3", 92 | "nock": "12.0.3", 93 | "node-sass": "4.13.1", 94 | "prettier": "1.19.1", 95 | "pretty-quick": "2.0.1", 96 | "react-scripts": "3.4.0", 97 | "redux-mock-store": "1.5.4", 98 | "typescript": "3.8.3" 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /src/utilities/HttpUtility.js: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | import HttpErrorResponseModel from '../models/HttpErrorResponseModel'; 3 | 4 | const RequestMethod = { 5 | Get: 'GET', 6 | Post: 'POST', 7 | Put: 'PUT', 8 | Delete: 'DELETE', 9 | Options: 'OPTIONS', 10 | Head: 'HEAD', 11 | Patch: 'PATCH', 12 | }; 13 | 14 | export default class HttpUtility { 15 | static async get(endpoint, params, requestConfig) { 16 | const paramsConfig = params ? { params } : undefined; 17 | 18 | return HttpUtility._request( 19 | { 20 | url: endpoint, 21 | method: RequestMethod.Get, 22 | }, 23 | { 24 | ...paramsConfig, 25 | ...requestConfig, 26 | } 27 | ); 28 | } 29 | 30 | static async post(endpoint, data) { 31 | const config = data ? { data } : undefined; 32 | 33 | return HttpUtility._request( 34 | { 35 | url: endpoint, 36 | method: RequestMethod.Post, 37 | }, 38 | config 39 | ); 40 | } 41 | 42 | static async put(endpoint, data) { 43 | const config = data ? { data } : undefined; 44 | 45 | return HttpUtility._request( 46 | { 47 | url: endpoint, 48 | method: RequestMethod.Put, 49 | }, 50 | config 51 | ); 52 | } 53 | 54 | static async delete(endpoint) { 55 | return HttpUtility._request({ 56 | url: endpoint, 57 | method: RequestMethod.Delete, 58 | }); 59 | } 60 | 61 | static async _request(restRequest, config) { 62 | if (!Boolean(restRequest.url)) { 63 | console.error(`Received ${restRequest.url} which is invalid for a endpoint url`); 64 | } 65 | 66 | try { 67 | const axiosRequestConfig = { 68 | ...config, 69 | method: restRequest.method, 70 | url: restRequest.url, 71 | headers: { 72 | 'Content-Type': 'application/x-www-form-urlencoded', 73 | ...config?.headers, 74 | }, 75 | }; 76 | 77 | const [axiosResponse] = await Promise.all([axios(axiosRequestConfig), HttpUtility._delay()]); 78 | 79 | const { status, data, request } = axiosResponse; 80 | 81 | if (data.success === false) { 82 | return HttpUtility._fillInErrorWithDefaults( 83 | { 84 | status, 85 | message: data.errors.join(' - '), 86 | errors: data.errors, 87 | url: request ? request.responseURL : restRequest.url, 88 | raw: axiosResponse, 89 | }, 90 | restRequest 91 | ); 92 | } 93 | 94 | return { 95 | ...axiosResponse, 96 | }; 97 | } catch (error) { 98 | if (error.response) { 99 | // The request was made and the server responded with a status code that falls out of the range of 2xx 100 | const { status, statusText, data } = error.response; 101 | const errors = data.hasOwnProperty('errors') ? [statusText, ...data.errors] : [statusText]; 102 | 103 | return HttpUtility._fillInErrorWithDefaults( 104 | { 105 | status, 106 | message: errors.filter(Boolean).join(' - '), 107 | errors, 108 | url: error.request.responseURL, 109 | raw: error.response, 110 | }, 111 | restRequest 112 | ); 113 | } else if (error.request) { 114 | // The request was made but no response was received `error.request` is an instance of XMLHttpRequest in the browser and an instance of http.ClientRequest in node.js 115 | const { status, statusText, responseURL } = error.request; 116 | 117 | return HttpUtility._fillInErrorWithDefaults( 118 | { 119 | status, 120 | message: statusText, 121 | errors: [statusText], 122 | url: responseURL, 123 | raw: error.request, 124 | }, 125 | restRequest 126 | ); 127 | } 128 | 129 | // Something happened in setting up the request that triggered an Error 130 | return HttpUtility._fillInErrorWithDefaults( 131 | { 132 | status: 0, 133 | message: error.message, 134 | errors: [error.message], 135 | url: restRequest.url, 136 | raw: error, 137 | }, 138 | restRequest 139 | ); 140 | } 141 | } 142 | 143 | static _fillInErrorWithDefaults(error, request) { 144 | const model = new HttpErrorResponseModel(); 145 | 146 | model.status = error.status || 0; 147 | model.message = error.message || 'Error requesting data'; 148 | model.errors = error.errors.length ? error.errors : ['Error requesting data']; 149 | model.url = error.url || request.url; 150 | model.raw = error.raw; 151 | 152 | // Remove anything with undefined or empty strings. 153 | model.errors = model.errors.filter(Boolean); 154 | 155 | return model; 156 | } 157 | 158 | /** 159 | * We want to show the loading indicator to the user but sometimes the api 160 | * request finished too quickly. This makes sure there the loading indicator is 161 | * visual for at least a given time. 162 | * 163 | * @param duration 164 | * @returns {Promise} 165 | * @private 166 | */ 167 | static _delay(duration = 250) { 168 | return new Promise((resolve) => setTimeout(resolve, duration)); 169 | } 170 | } 171 | --------------------------------------------------------------------------------