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 |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 |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