├── .gitignore ├── .vscode └── launch.json ├── README.md ├── _docs ├── boilerplateOverivew.gif ├── enterModuleName.png ├── featureModule.gif ├── featureModuleExample.png ├── featureModuleRightClick.png ├── rooReducerName.png ├── rootReducerAddReducer.png └── selectFeatureModule.png ├── blueprint-templates └── Feature Module │ └── __camelCase_name__ │ ├── __camelCase_name__.asyncActions.js │ ├── __camelCase_name__.selectors.js │ ├── __camelCase_name__.slice.js │ ├── __pascalCase_name__.js │ └── index.js ├── docker-compose-dev.yml ├── package-lock.json └── webapp ├── .eslintrc.js ├── .prettierrc.json ├── Dockerfile-dev ├── config-overrides.js ├── package-lock.json ├── package.json ├── public ├── favicon.ico ├── index.html ├── logo192.png ├── logo512.png ├── manifest.json └── robots.txt └── src ├── App.js ├── Routes.js ├── __mocks__ └── react-redux.js ├── app.css ├── config.js ├── createStore.js ├── features ├── Authenticated.js ├── Authorized.js ├── Home.js ├── SignIn.js ├── demo │ ├── Demo.js │ ├── demo.asyncActions.js │ ├── demo.selectors.js │ ├── demo.slice.js │ └── index.js ├── settings │ ├── Settings.js │ ├── index.js │ ├── settings.selectors.js │ └── settings.slice.js ├── userContext │ ├── WithRestrictedAccess.js │ ├── index.js │ ├── userContext.asynActions.js │ ├── userContext.selectors.js │ └── userContext.slice.js └── users │ ├── Users.js │ ├── index.js │ ├── users.asyncActions.js │ ├── users.selectors.js │ └── users.slice.js ├── index.css ├── index.js ├── infrastructure ├── buildCacheKey.js ├── createAsyncAction.js ├── dispatchAsync.js ├── doAsync │ ├── doAsync.actionTypes.js │ ├── doAsync.js │ ├── doAsyncLogic.js │ ├── index.js │ └── tests │ │ ├── doAsync.test.js │ │ ├── doAsyncLogic.cleanUpPendingRequests.test.js │ │ └── doAsyncLogic.requestIsAlreadyPending.test.js ├── http │ ├── http.constants.js │ ├── http.js │ ├── index.js │ └── tests │ │ └── http.test.js ├── httpCache │ ├── httpCache.selectors.js │ ├── httpCache.slice.js │ ├── index.js │ └── tests │ │ └── httpCache.test.js ├── notificationPopup │ ├── NotificationPopup.js │ ├── __mocks__ │ │ └── notificationPopup.actions.js │ ├── index.js │ ├── notificationPopup.selectors.js │ └── notificationPopup.slice.js ├── pendingRequest │ ├── index.js │ ├── pendingRequest.reducer.js │ ├── pendingRequest.selectors.js │ ├── pendingRequest.slice.js │ └── tests │ │ └── pendingReqeust.test.js ├── reduxHelpers.js ├── test │ └── mockFetch.js └── useAsync.js ├── rootReducer.js ├── serviceWorker.js ├── setupTests.js └── widgets ├── NavBar ├── NavBar.js └── index.js ├── Page ├── Page.js └── page.css ├── busyIndicator ├── BusyIndicator.js ├── busyIndicator.css ├── busyIndicator.selectors.js ├── busyIndicator.slice.js └── index.js └── modal ├── Modal.js ├── index.js ├── modal.selectors.js └── modal.slice.js /.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 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "name": "Debug CRA Tests", 9 | "type": "node", 10 | "request": "launch", 11 | "runtimeExecutable": "${workspaceRoot}/webapp/node_modules/.bin/react-scripts", 12 | "args": ["test", "--runInBand", "--no-cache", "--watchAll=false"], 13 | "cwd": "${workspaceRoot}/webapp", 14 | "protocol": "inspector", 15 | "console": "integratedTerminal", 16 | "internalConsoleOptions": "neverOpen", 17 | "env": { "CI": "true" }, 18 | "disableOptimisticBPs": true 19 | } 20 | ] 21 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Overview 2 | This is a React boilerplate focused on enabling high developer velocity while implementing idiomatic Redux Hooks and React Hooks. 3 | 4 | ## Overview Video 5 | Here's a video that walks through some of the major features of this boilerplate. 6 | 7 | [![Overview](_docs/boilerplateOverivew.gif)](https://www.youtube.com/watch?v=4l9KUffb9cc) 8 | 9 | > **_Note_** 10 | > 11 | > If you are wanting to just get an idea of the features the boilerplate offers you should also checkout in the [Feature Module Generation in VS Code](#feature-module-generation-in-vs-code) section of this readme 12 | 13 | # Table of Contents 14 | 15 | 16 | 17 | - [Overview](#overview) 18 | - [Overview Video](#overview-video) 19 | - [Table of Contents](#table-of-contents) 20 | - [Installation and Running](#installation-and-running) 21 | - [Available Scripts](#available-scripts) 22 | - [`npm start`](#npm-start) 23 | - [`npm test`](#npm-test) 24 | - [`npm run build`](#npm-run-build) 25 | - [`npm run eject`](#npm-run-eject) 26 | - [Docker Support](#docker-support) 27 | - [Goals](#goals) 28 | - [Good Starting Point](#good-starting-point) 29 | - [High Developer Ergonomics](#high-developer-ergonomics) 30 | - [Create Software with High Asset Value](#create-software-with-high-asset-value) 31 | - [Maximize the Value of the Tools we are Using](#maximize-the-value-of-the-tools-we-are-using) 32 | - [Scale Well in Complex Apps](#scale-well-in-complex-apps) 33 | - [Features](#features) 34 | - [Feature Module Generation in VS Code](#feature-module-generation-in-vs-code) 35 | - [Authentication Flow with Redux](#authentication-flow-with-redux) 36 | - [Permission Based Authorization with Redux](#permission-based-authorization-with-redux) 37 | - [Redux Caching](#redux-caching) 38 | - [Background Loading](#background-loading) 39 | - [Busy Indicator with Redux](#busy-indicator-with-redux) 40 | - [Automatic Linting and Code Beutification](#automatic-linting-and-code-beutification) 41 | - [Circular Dependency Detection](#circular-dependency-detection) 42 | - [Folder Structure](#folder-structure) 43 | - [Patterns](#patterns) 44 | - [Feature Module Pattern](#feature-module-pattern) 45 | - [Module Structure](#module-structure) 46 | - [index.js](#indexjs) 47 | - [name](#name) 48 | - [reducer](#reducer) 49 | - [actions](#actions) 50 | - [asyncActions](#asyncactions) 51 | - [selectors](#selectors) 52 | - [Infrastructure Components](#infrastructure-components) 53 | - [doAsync](#doasync) 54 | - [Options](#options) 55 | - [busyIndicator](#busyindicator) 56 | - [withRestrictedAccess](#withrestrictedaccess) 57 | - [Authenticated Component](#authenticated-component) 58 | - [Authorized Comopnent](#authorized-comopnent) 59 | - [popupNotification](#popupnotification) 60 | - [Best Practices](#best-practices) 61 | - [Only access redux state in selectors](#only-access-redux-state-in-selectors) 62 | - [Always collocate selectors with reducers](#always-collocate-selectors-with-reducers) 63 | - [Configuration](#configuration) 64 | - [API Proxy](#api-proxy) 65 | - [Learn More](#learn-more) 66 | - [Code Splitting](#code-splitting) 67 | - [Analyzing the Bundle Size](#analyzing-the-bundle-size) 68 | - [Making a Progressive Web App](#making-a-progressive-web-app) 69 | - [Advanced Configuration](#advanced-configuration) 70 | - [Deployment](#deployment) 71 | - [`npm run build` fails to minify](#npm-run-build-fails-to-minify) 72 | 73 | 74 | 75 | # Installation and Running 76 | 77 | This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app). 78 | 79 | ## Available Scripts 80 | 81 | Note that the app supports docker for projects that want to use micorservices. Because of this the Web Application is in the `webApp` folder. 82 | 83 | --- 84 | **IMPORTANT** 85 | 86 | All of the commands below require you to be in the `webApp` directory 87 | 88 | --- 89 | 90 | ### `npm start` 91 | 92 | - Runs the app in the development mode. 93 | - Open [http://localhost:3000](http://localhost:3000) to view it in the browser. 94 | - The page will reload if you make edits.
95 | - Prettier will run and auto-format your code whenever a file is saved. 96 | - You will also see any lint errors in the console. 97 | - *Currently lint errors get written out twice. We will try and fix this soon.* 98 | 99 | ### `npm test` 100 | 101 | - Launches the test runner in the interactive watch mode. 102 | - See the section about [running tests](https://facebook.github.io/create-react-app/docs/running-tests) for more information. 103 | 104 | > **_NOTE_** 105 | > 106 | > You must place `node-module` mocks in the `webapp/src/__mocks__` director because `create-react-app` reset Jest's `roots` config for performance reasons. I lost half a day on figuring this out so figured I'd share. The PR is below. 107 | > 108 | > https://github.com/facebook/create-react-app/pull/7480/files 109 | 110 | 111 | ### `npm run build` 112 | 113 | - Builds the app for production to the `build` folder. 114 | - It correctly bundles React in production mode and optimizes the build for the best performance. 115 | - The build is minified and the filenames include the hashes. 116 | - Your app is ready to be deployed! 117 | - See the section about [deployment](https://facebook.github.io/create-react-app/docs/deployment) for more information. 118 | 119 | ### `npm run eject` 120 | 121 | > **_NOTE_** 122 | > 123 | > This is a one-way operation. Once you `eject`, you can’t go back!** 124 | 125 | If you aren’t satisfied with the build tool and configuration choices, you can `eject` at any time. This command will remove the single build dependency from your project. 126 | 127 | Instead, it will copy all the configuration files and the transitive dependencies (webpack, Babel, ESLint, etc) right into your project so you have full control over them. All of the commands except `eject` will still work, but they will point to the copied scripts so you can tweak them. At this point you’re on your own. 128 | 129 | You don’t have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you shouldn’t feel obligated to use this feature. However we understand that this tool wouldn’t be useful if you couldn’t customize it when you are ready for it. 130 | 131 | ### Docker Support 132 | To run the Web Application in a docker container from the root directory execute the command below. 133 | 134 | ``` 135 | docker-compose up 136 | ``` 137 | 138 | The code running in the container uses a mapped volume and will have all the same features as when you run `npm start` from `webApp` directory (file watching for prettier, rebuild, etc...). The main difference is that you won't get as pretty formatted output (no colors, etc...) and you won't be able to click on the source code paths to jump to the file. 139 | 140 | # Goals 141 | These are the goals of this boilerplate 142 | 143 | ## Good Starting Point 144 | We don't want to waste time or our clients budgets rewriting the same code over and over for things every web app needs like 145 | 146 | - authentication 147 | - authorization 148 | - popups (errors, notifications) 149 | - busy indicators 150 | - caching 151 | - forms and validations 152 | - ect... 153 | 154 | ## High Developer Ergonomics 155 | We want to make sure that 156 | - developers are able to do the 80% they need to do most often easily without a lot of boilerplate 157 | - new developers on the project can get up and running quickly and modify the code confidently 158 | 159 | ## Create Software with High Asset Value 160 | It's much easier to create software that is a liability, that offers little value to the sponsors without the team that wrote it. We want the systems built with this boilerplate to be easy to transfer from one team to another. We often help clients to build apps that their own teams will take over one day and want that transfer to be as easy as possible. 161 | 162 | ## Maximize the Value of the Tools we are Using 163 | We don't want to include libraries because they are popular, we want to include them because they add value that we want to take advantage of. So for example if we are using Redux then we want to take advantage of the valuable features it provide (dev tooling, serializable state, etc...). 164 | 165 | ## Scale Well in Complex Apps 166 | We want to make sure that if the projects that use this boilerplate become successful and complex overtime that they won't outgrow the patterns, infrastructure and best practices. 167 | 168 | # Features 169 | Below are some of the features that we've added to this boilerplate 170 | 171 | ## Feature Module Generation in VS Code 172 | Our architecture uses a pattern we call [Feature Module](#feature-module-pattern) and we have added a blueprint template that will allow generating a working `Feature Module` from the context menu. [The video below](https://www.youtube.com/watch?v=Aoz6VPHQr-4&t=6s) walks through quickly creating a feature using this approach. 173 | 174 | [![Generating Feature Modules](_docs/featureModule.gif)](https://www.youtube.com/watch?v=Aoz6VPHQr-4&t=6s) 175 | 176 | To take advanatage of this feature you need to use Visual Studio Code with the [Blueprint Templates](https://marketplace.visualstudio.com/items?itemName=teamchilla.blueprint) plugin. Once you have Blueprint installed then you can simply: 177 | 178 | 1. Right click on a folder 179 | 180 | ![](_docs/featureModuleRightClick.png) 181 | 182 | 1. Select the `Feature Module` template 183 | 184 | ![](_docs/selectFeatureModule.png) 185 | 186 | 1. Enter the name of your new feature 187 | 188 | ![](_docs/enterModuleName.png) 189 | 190 | Now you will have a new feature module created like the one showed below. 191 | 192 | ![](_docs/featureModuleExample.png) 193 | 194 | 195 | 196 | ## Authentication Flow with Redux 197 | We provide a login flow that you can plug in Auth0 or whatever IDP you like to use. Our authentication flow will allow you to call your IDP and handles redirecting from protected routes to Sign In page for you as well as redirecting back to the calling page after successful sign in. The user profile returned from your IDP will be put into redux and available on the `state.userContext` slice. 198 | 199 | 200 | 201 | > **_NOTE_** 202 | > 203 | > You can watch a video demonstrating this feature in the [Overview Video](#overview-video) section. 204 | 205 | ## Permission Based Authorization with Redux 206 | We have added `withRestrictedAccess(component, permissions)` HOC which takes `permissions` array that will be cross referenced with `state.userContext.permissions` automatically and prevent access for users without the configured permissions. 207 | 208 | > **_NOTE_** 209 | > 210 | > You can watch a video demonstrating this feature in the [Overview Video](#overview-video) section. 211 | 212 | ## Redux Caching 213 | Via the `doAsync` module that can be easily used with `createAsyncThunk` from `redux-toolkit` we support redux caching. Passing `useCaching: true` to `doAsync({url, useCaching: true})` will not go to the server if the data has already been fetched and is in redux. 214 | 215 | > **_NOTE_** 216 | > 217 | > You can watch a video demonstrating this feature in the [Overview Video](#overview-video) section. 218 | 219 | ## Background Loading 220 | Via the `doAsync` module that can be easily used with `createAsyncThunk` from `redux-toolkit` we support background loading of data via API calls. Passing `noBusySpinner: true` to `doAsync({url, noBusySpinner: true})` will start a call to the API but not turn on the busy indicator. Note that if a call comes through for the same url before the first background call returns then the busy spinner will be turned on and the API will not be called and the current request will not be sent to the API. 221 | 222 | > **_NOTE_** 223 | > 224 | > You can watch a video demonstrating this feature in the [Overview Video](#overview-video) section. 225 | 226 | ## Busy Indicator with Redux 227 | The `busyIndicator` module allows for redux based busy indicator management. Our `doAsync` module will automoatically turn on and off the busy indicator for you as you call the API. You can also manually turn on and off the busy indicator and there is support for named busy indicators allowing for creating regional busy indicators. 228 | 229 | > **_NOTE_** 230 | > 231 | > You can watch a video demonstrating this feature in the [Overview Video](#overview-video) section. 232 | 233 | ## Automatic Linting and Code Beutification 234 | Every time a file is saved when the app is running in dev via `npm start` that file will be beautified via prettier and the prettier rules have been configured to match the eslint rules. 235 | 236 | ## Circular Dependency Detection 237 | If you introduce a circular dependency in your `import` statements the build will fail and you will be forced to fix it by refactoring your code. If circular references aren't fixed you will eventually get a very hard to fix `object null` null type of exception. This usually happens after there are a lot of circular references in the code making cleaning all up difficult and expensive. 238 | 239 | # Folder Structure 240 | We are using the folder structure described below. 241 | 242 | - `webapp`: contains the web app source 243 | - `features`: one folder for each app feature (userProfile, orders, accounts, etc...) 244 | - `infrastructure`: contains code that is a cross cutting concern but not a component (httpCache, doAsync, etc...) 245 | - `widgets`: contains reusable UI widgets (NavBar, Modal, NotificationPopup, etc...) 246 | 247 | 248 | # Patterns 249 | Below are patterns we use and best practices we recommend. 250 | 251 | ## Feature Module Pattern 252 | The feature module pattern is the pattern we recommend for organizing a feature in the application where a feature is a set of functionality that uses one or more Redux reducer slices. Basically, this means any feature of your app that uses redux. This could be a widget like a busy indicator or a domain specific feature like a document list. You can easily create feature modules using the technique described in the [Feature Module Generation in VS Code](#feature-module-generation-in-vs-code) secont above. 253 | 254 | > **_NOTE_** 255 | > 256 | > You can watch a video on generating a Feature Module in the [Feature Module Generation in VS Code](#feature-module-generation-in-vs-code) section. 257 | 258 | ### Module Structure 259 | A feature module is designed to be contained using the module approach found in node JS. We can thinkg of each features as being a self contained node module. It's desirable that if you write a component that could be used in another project that you could easily move the files over to the new project and using this organization will give you that benifit. Each module is made up of a folder with the following files. 260 | 261 | ``` 262 | - demo 263 | -- index.js 264 | -- demo.slice.js 265 | -- demo.asyncActions.js 266 | -- demo.selectors.js 267 | -- Demo.js 268 | ``` 269 | 270 | > **_NOTE_** 271 | > 272 | > Not all files are required in all cases 273 | 274 | 275 | #### index.js 276 | This file defines the public interface of the module and allows us to be explicity about how it should be used. 277 | 278 | > **_NOTE_** 279 | > 280 | > In Javascript it's possible for anyone to `import` any file so the goal here is to express intent not to prevent misuse. 281 | 282 | 283 | Every `export` in this file is something that you can easily import else where in the system. The structure of this file is 284 | 285 | ```javascript 286 | import Demo from './Demo' 287 | import * as selectors from './demo.selectors' 288 | import * as asyncActions from './demo.asyncActions' 289 | import slice from './demo.slice' 290 | 291 | export const { 292 | name, 293 | actions: { updateFilter }, 294 | reducer, 295 | } = slice 296 | 297 | export const { fetchAllDemo } = asyncActions 298 | 299 | // we prefix all selectors with the the "select" prefix 300 | export const { selectAllDemo, selectDemoFilter } = selectors 301 | 302 | // we export the component most likely to be desired by default 303 | export default Demo 304 | ``` 305 | 306 | > **_NOTE_** 307 | > 308 | > We are using `redux-toolkit` and they recommend following an approach called `dux` where you put all your actions and selectors and other data flow code in a a single file. We are not following that here. Instead we have put `./module-name.selectors.js` and `./module-name.asyncActions.js` in seperate files and this is because we find these get large over time in complex systems. `redux-toolkit` is being responsive to it's users complaints about having to modify too many files when they make changes but we feel that the benifits in a large system out weigh this concern. 309 | 310 | **Exports** 311 | 312 | What we are exporting is: 313 | 314 | ##### name 315 | 316 | This is available to consumers and should be used as the name of the slice on the root reducer as shown below: 317 | 318 | ![](_docs/rooReducerName.png) 319 | 320 | --- 321 | > **_NOTE_** 322 | > 323 | > The name is part of the slice created by `redux-toolkit`'s `createSlice()` builder function. 324 | 325 | ##### reducer 326 | This is available to consumers and should be used as the reducer for this module and mounted to the root reducer: 327 | 328 | ![](_docs/rootReducerAddReducer.png) 329 | 330 | > **_NOTE:_** The name is part of the slice created by `redux-toolkit`'s `createSlice()` builder function. 331 | 332 | ##### actions 333 | Here we destructure each action that we wanted exported from the colloction of actions that will be created for us by `redux-toolkit`'s `createSlice()` builder function. 334 | 335 | ```javascript 336 | import Demo from './Demo' 337 | import * as selectors from './demo.selectors' 338 | import * as asyncActions from './demo.asyncActions' 339 | import slice from './demo.slice' 340 | 341 | export const { 342 | name, 343 | actions: { updateFilter }, // <=== export each action here 344 | reducer, 345 | } = slice 346 | ``` 347 | 348 | Each of these actions will be created for us when we build our slice as shown below. 349 | 350 | ```javascript 351 | import { createSlice } from '@reduxjs/toolkit' 352 | import * as asyncActions from './demo.asyncActions' 353 | 354 | const initialState = { 355 | allDemo: [], 356 | filter: '', 357 | } 358 | 359 | const slice = createSlice({ 360 | name: 'demo', 361 | initialState, 362 | reducers: { 363 | // synchronous actions 364 | updateFilter(state, action) { // <=== action to be exported 365 | state.filter = action.payload 366 | }, 367 | }, 368 | extraReducers: { 369 | // asynchronous actions 370 | [asyncActions.fetchAllDemo.fulfilled]: (state, action) => { 371 | state.allDemo = action.payload 372 | }, 373 | }, 374 | }) 375 | 376 | export default slice 377 | 378 | export const { name, actions, reducer } = slice 379 | ``` 380 | 381 | In the above example when we declare `reducders` as an object with a `updateFilter()` method, under the hood `redux-toolkit` will create an action for us and make it available on the `slice.actions` collection. 382 | 383 | As part of the feature module pattern when we add a new action/reducer function to our slice's reducer section we need to also export it from our `index.js` file if it's meant to be available to other modules in the system. 384 | 385 | > **_NOTE_** 386 | > 387 | >It is possible that you would want to have actions that are only used internal to your module. 388 | 389 | 390 | ##### asyncActions 391 | We destructure each of our asyncActions that we want available externally. 392 | 393 | ```javascript 394 | // removed for clarity 395 | 396 | import * as asyncActions from './demo.asyncActions' 397 | 398 | // removed for clarity 399 | 400 | export const { fetchAllDemo } = asyncActions 401 | 402 | // removed for clarity 403 | ``` 404 | 405 | 406 | ##### selectors 407 | We destructure each of our asyncActions that we want available externally. 408 | 409 | ```javascript 410 | // removed for clarity 411 | 412 | import * as selectors from './demo.selectors' 413 | 414 | // removed for clarity 415 | 416 | // we prefix all selectors with the the "select" prefix 417 | export const { selectAllDemo, selectDemoFilter } = selectors 418 | 419 | // removed for clarity 420 | ``` 421 | 422 | Each generated selector file will have a `selectSlice` function defined like shown below. 423 | 424 | ```javascript 425 | import slice from './demo.slice' 426 | 427 | export const selectSlice = (state) => state[slice.name] 428 | 429 | export const selectAllDemo = (state) => selectSlice(state).allDemo 430 | ``` 431 | 432 | This function should be used to access the root of the slice as it will use `slice.name` and allow for easier refactoring of the slice name. 433 | 434 | > **_NOTE_** 435 | > 436 | >See the [Only access redux state in selectors](#only-access-redux-state-in-selectors) and [Always collocate selectors with reducers](#always-collocate-selectors-with-reducers) best practices. 437 | 438 | # Infrastructure Components 439 | Here we document the APIs of the infrastructure components. 440 | 441 | ## doAsync 442 | This is our most feature packed module. It is a [thunk](https://github.com/reduxjs/redux-thunk) builder that allows easily wiring up asynchronous calls to the API with full redux support. It can be used inside [Redux Toolkit](https://redux-toolkit.js.org/)'s [createAsyncThunk](https://redux-toolkit.js.org/api/createAsyncThunk) builder function or stand alone. It's common usage is shown below. 443 | 444 | ```javascript 445 | export const fetchAllDemo = createAsyncThunk( 446 | 'demo/getAll', 447 | async ({ useCaching, noBusySpinner } = {}, thunkArgs) => 448 | await doAsync({ 449 | url: 'demo', 450 | useCaching, 451 | noBusySpinner, 452 | successMessage: 'Demo loaded', 453 | errorMessage: 'Unable to load demo. Please try again later.', 454 | stubSuccess: ['Dummy item 1', 'Dummy item 2'], 455 | ...thunkArgs, 456 | }) 457 | ) 458 | ``` 459 | 460 | ### Options 461 | doAsync only takes on argument which is a config object with the properties shown below. 462 | 463 | | Properties | Description | Default | 464 | | ----------------- |-----------------------| ---------| 465 | | url | Specifies the API endpoint to call where a url is build with 'transport://host/api-prefix/endpoint'. doAsync will build the url with the correct `transport`, `host`, and `api-prefix` for the current environment. | no default, must be specified | 466 | | httpMethod | Specifies the http verb to use. | `'get'` | 467 | | errorMessage | An error message to show to the user using the [popupNotification](#popupNotification) module after a reqeust returns an error code. | `'Unable to process request. Please try again later.'` | 468 | | successMessage | A sucess message to show to the user using the [popupNotification](#popupNotification) module after a request returns successfully. | no default, optional | 469 | | httpConfig | `doAsync` uses [fetch](https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API) internally. `doAsync` will provide reasonable defaults for the [request](https://developer.mozilla.org/en-US/docs/Web/API/Request) argument to `fetch(url, request)` which will include configurations for authentication, accept, etc... However, you can specify any values for the `request` argument to fetch here and they will override the defaults provided by `doAsync` | no default, option | 470 | | onError | Callback that can be used to have code called when an error occurs. | no default, optional | 471 | | noBusySpinner | If true then no [busyIndicator](#busyIndicator) won't be incremented for this request. Note that if an additional request comes through for the same `url` and `httpMethod` with `noBusySpinner` set to `false` before the previous request with `noBusySpinner` set to `true` completes then the [busyIndicator](#busyIndicator) will be incremented and the current request will not be sent to the API. | `false` | 472 | | busyIndicatorName | Name of the [busyIndicator](#busyIndicator) to increment | no default, optional | 473 | | useCaching | If `true` then subsequent requests to the same `url` and `httpMethod` (and `body` for `POST`, `PUT`, `UPDATE`) will not be sent to the server. This will allow components to use the data in Redux as a cache for better responsivness for the users. | `false`| 474 | | stubSuccess | Specifies a dummy body to return to the caller. This is intended to be used to get UIs built before the APIs are ready. If you specify an object or array here it will be returned to the caller after a delay which will allow simulated busy indicator. | no default, optional | 475 | | stubError | Same as `stubSuccess` except will return an error code and reject the promise | no default, optional | 476 | 477 | ## busyIndicator 478 | The busy indicator module allows for easy busy indicator functionality. To show a busy indicator simply wrap your components in the `BusyIndicator` component as shown below. 479 | 480 | ```javascript 481 |
482 |

Demo

483 | dispatch(updateFilter(e.target.value))} 487 | placeholder='Filter by...' 488 | /> 489 | 490 | 496 | 497 |
498 | ``` 499 | 500 | The busy indicator integrates with `doAsync` and will show by default when any request is [pending](https://redux-toolkit.js.org/api/createAsyncThunk#type) that was called with `noBusySpinner` set to `false` will decrement the global busy indicator. If you want to have more than one busy indicator then pass `doAsync` a `busyIndicatorName` and then put that name on your busy indicator instance as shown below. 501 | 502 | ```Javascript 503 | // foo.asyncActions.js 504 | doAsync({ url, busyIndicatorName: 'foo'}) 505 | 506 | // bar.asyncActions.js 507 | doAsync({ url, busyIndicatorName: 'bar'}) 508 | 509 | // SomeComponent.js 510 | 511 | // will only show buys for "foo" 512 | // foo related components 513 | 514 | 515 | // other components 516 | 517 | // will only show buys for "bar" 518 | // bar related components 519 | 520 | ``` 521 | 522 | ## withRestrictedAccess 523 | An [HOC](https://reactjs.org/docs/higher-order-components.html) that allows creating components that support authentication and authorization. 524 | 525 | ### Authenticated Component 526 | To require a user be authenticated simply wrap your component in `WithRestrictedAccess` as shown below. 527 | 528 | ```javascript 529 | import React from 'react' 530 | import { WithRestrictedAccess } from './userContext' 531 | 532 | const Authenticated = () =>

Authenticated

533 | 534 | export default WithRestrictedAccess(Authenticated) 535 | ``` 536 | 537 | ### Authorized Comopnent 538 | To create a component that requires one or more premission pass an array of strings to the `WithRestrictedAccess` as shown below. 539 | 540 | ```javascript 541 | import React from 'react' 542 | import { WithRestrictedAccess } from './userContext' 543 | 544 | const Authorized = () =>

Authorized Page

545 | 546 | export default WithRestrictedAccess(Authorized, ['can-do-foo', 'can-do-bar`]) 547 | ``` 548 | 549 | ## popupNotification 550 | To show popup notifications use the actions in the `popupNotification` module shown below. 551 | 552 | | Action | Description | 553 | |--------|-------------| 554 | | notifyError | Will show an error popup | 555 | | notifySuccess | Will show an information stype popup | 556 | | resetError | Will reset the popup so that it won't be shown again | 557 | | closePopup | Will close the popup | 558 | 559 | # Best Practices 560 | Below are best practices we recommend following. 561 | 562 | ## Only access redux state in selectors 563 | We recommend **only accessing state from selectors** and not directly in components. 564 | 565 | **Good** 566 | 567 | ```javascript 568 | // Foo.js 569 | 570 | // Inside component 571 | const foo = useSelector(selectFoo) // loosely coupled 572 | ``` 573 | 574 | **Bad** 575 | 576 | ```javascript 577 | // Foo.js 578 | 579 | // Inside component 580 | const foo = useSelector(state => state.foo) // couples component to state shape 581 | ``` 582 | 583 | This approach greatly improves your ability to refacotor you state atom shape and improve it over time by incorporating patterns like [normalization](http://redux.js.org/docs/recipes/reducers/NormalizingStateShape.html). 584 | 585 | ## Always collocate selectors with reducers 586 | The creator of Redux, Dan Abromov's, recomends [collocating selectors with reducers](https://egghead.io/lessons/javascript-redux-colocating-selectors-with-reducers) and we agree. While Dan shows doing this by keeping selectors in the reducer file you can also collocate by keeping the selectors in the same module and then bundling everything into the same module in your `index.js` file as shown in our [Module Structure](#module-structure) section above. 587 | 588 | # Configuration 589 | Below are configurations supported in this boilerplate. 590 | 591 | ## API Proxy 592 | coming soon... 593 | 594 | # Learn More 595 | 596 | You can learn more in the [Create React App documentation](https://facebook.github.io/create-react-app/docs/getting-started). 597 | 598 | To learn React, check out the [React documentation](https://reactjs.org/). 599 | 600 | ## Code Splitting 601 | 602 | This section has moved here: https://facebook.github.io/create-react-app/docs/code-splitting 603 | 604 | ## Analyzing the Bundle Size 605 | 606 | This section has moved here: https://facebook.github.io/create-react-app/docs/analyzing-the-bundle-size 607 | 608 | ## Making a Progressive Web App 609 | 610 | This section has moved here: https://facebook.github.io/create-react-app/docs/making-a-progressive-web-app 611 | 612 | ## Advanced Configuration 613 | 614 | This section has moved here: https://facebook.github.io/create-react-app/docs/advanced-configuration 615 | 616 | ## Deployment 617 | 618 | This section has moved here: https://facebook.github.io/create-react-app/docs/deployment 619 | 620 | ## `npm run build` fails to minify 621 | 622 | This section has moved here: https://facebook.github.io/create-react-app/docs/troubleshooting#npm-run-build-fails-to-minify 623 | -------------------------------------------------------------------------------- /_docs/boilerplateOverivew.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vicesoftware/react-redux-hooks-boilerplate/7f414cb6172b8d57721e3e2e300504d423455980/_docs/boilerplateOverivew.gif -------------------------------------------------------------------------------- /_docs/enterModuleName.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vicesoftware/react-redux-hooks-boilerplate/7f414cb6172b8d57721e3e2e300504d423455980/_docs/enterModuleName.png -------------------------------------------------------------------------------- /_docs/featureModule.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vicesoftware/react-redux-hooks-boilerplate/7f414cb6172b8d57721e3e2e300504d423455980/_docs/featureModule.gif -------------------------------------------------------------------------------- /_docs/featureModuleExample.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vicesoftware/react-redux-hooks-boilerplate/7f414cb6172b8d57721e3e2e300504d423455980/_docs/featureModuleExample.png -------------------------------------------------------------------------------- /_docs/featureModuleRightClick.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vicesoftware/react-redux-hooks-boilerplate/7f414cb6172b8d57721e3e2e300504d423455980/_docs/featureModuleRightClick.png -------------------------------------------------------------------------------- /_docs/rooReducerName.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vicesoftware/react-redux-hooks-boilerplate/7f414cb6172b8d57721e3e2e300504d423455980/_docs/rooReducerName.png -------------------------------------------------------------------------------- /_docs/rootReducerAddReducer.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vicesoftware/react-redux-hooks-boilerplate/7f414cb6172b8d57721e3e2e300504d423455980/_docs/rootReducerAddReducer.png -------------------------------------------------------------------------------- /_docs/selectFeatureModule.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vicesoftware/react-redux-hooks-boilerplate/7f414cb6172b8d57721e3e2e300504d423455980/_docs/selectFeatureModule.png -------------------------------------------------------------------------------- /blueprint-templates/Feature Module/__camelCase_name__/__camelCase_name__.asyncActions.js: -------------------------------------------------------------------------------- 1 | import { createAsyncThunk } from '@reduxjs/toolkit' 2 | import doAsync from '../../infrastructure/doAsync' 3 | 4 | export const fetchAll{{pascalCase name}} = createAsyncThunk( 5 | '{{camelCase name}}/getAll', 6 | async ({ useCaching, noBusySpinner } = {}, thunkArgs) => 7 | await doAsync({ 8 | url: '{{kebabCase name}}', 9 | useCaching, 10 | noBusySpinner, 11 | successMessage: '{{pascalCase name}} loaded', 12 | errorMessage: 'Unable to load {{camelCase name}}. Please try again later.', 13 | stubSuccess: ['Dummy item 1', 'Dummy item 2'], 14 | ...thunkArgs, 15 | }) 16 | ) -------------------------------------------------------------------------------- /blueprint-templates/Feature Module/__camelCase_name__/__camelCase_name__.selectors.js: -------------------------------------------------------------------------------- 1 | import slice from './{{camelCase name}}.slice' 2 | 3 | export const selectSlice = (state) => state[slice.name] 4 | 5 | export const selectAll{{pascalCase name}} = (state) => selectSlice(state).all{{pascalCase name}} 6 | 7 | export const select{{pascalCase name}}Filter = (state) => selectSlice(state).filter -------------------------------------------------------------------------------- /blueprint-templates/Feature Module/__camelCase_name__/__camelCase_name__.slice.js: -------------------------------------------------------------------------------- 1 | import { createSlice } from '@reduxjs/toolkit' 2 | import * as asyncActions from './{{camelCase name}}.asyncActions' 3 | 4 | const initialState = { 5 | all{{pascalCase name}}: [], 6 | filter: '', 7 | } 8 | 9 | const slice = createSlice({ 10 | name: '{{camelCase name}}', 11 | initialState, 12 | reducers: { // synchronous actions 13 | updateFilter(state, action) { 14 | state.filter = action.payload 15 | }, 16 | }, 17 | extraReducers: { // asynchronous actions 18 | [asyncActions.fetchAll{{pascalCase name}}.fulfilled]: (state, action) => { 19 | state.all{{pascalCase name}} = action.payload 20 | }, 21 | }, 22 | }) 23 | 24 | export default slice 25 | 26 | export const { name, actions, reducer } = slice 27 | -------------------------------------------------------------------------------- /blueprint-templates/Feature Module/__camelCase_name__/__pascalCase_name__.js: -------------------------------------------------------------------------------- 1 | import React, { useEffect } from 'react' 2 | import { useSelector, useDispatch } from 'react-redux' 3 | import { 4 | selectAll{{pascalCase name}}, 5 | select{{pascalCase name}}Filter, 6 | } from './{{camelCase name}}.selectors' 7 | import { actions } from './{{camelCase name}}.slice' 8 | import { fetchAll{{pascalCase name}} } from './{{camelCase name}}.asyncActions' 9 | import BusyIndicator from '../../widgets/busyIndicator' 10 | 11 | const { updateFilter } = actions 12 | 13 | export default function {{pascalCase name}}() { 14 | const {{camelCase name}} = useSelector(selectAll{{pascalCase name}}) 15 | const filter = useSelector(select{{pascalCase name}}Filter) 16 | 17 | const dispatch = useDispatch() 18 | 19 | useEffect(() => { 20 | dispatch(fetchAll{{pascalCase name}}()) 21 | }, [dispatch]) 22 | 23 | return ( 24 |
25 |

{{pascalCase name}}

26 | dispatch(updateFilter(e.target.value))} 30 | placeholder='Filter by...' 31 | /> 32 | 33 |
    34 | { {{camelCase name}} && 35 | {{camelCase name}} 36 | .filter((item) => (filter ? item.includes(filter) : true)) 37 | .map((item) =>
  • {item}
  • )} 38 |
39 |
40 |
41 | ) 42 | } -------------------------------------------------------------------------------- /blueprint-templates/Feature Module/__camelCase_name__/index.js: -------------------------------------------------------------------------------- 1 | import {{pascalCase name}} from './{{pascalCase name}}' 2 | import * as selectors from './{{camelCase name}}.selectors' 3 | import * as asyncActions from './{{camelCase name}}.asyncActions' 4 | import slice from './{{camelCase name}}.slice' 5 | 6 | export const { 7 | name, 8 | actions: { updateFilter }, 9 | reducer, 10 | } = slice 11 | 12 | export const { fetchAll{{pascalCase name}} } = asyncActions 13 | 14 | // we prefix all selectors with the the "select" prefix 15 | export const { selectAll{{pascalCase name}}, select{{pascalCase name}}Filter } = selectors 16 | 17 | // we export the component most likely to be desired by default 18 | export default {{pascalCase name}} 19 | -------------------------------------------------------------------------------- /docker-compose-dev.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | services: 3 | ui: 4 | build: 5 | context: . 6 | dockerfile: webapp/Dockerfile-dev 7 | volumes: 8 | - './webapp:/app' 9 | - '/app/node_modules' 10 | ports: 11 | - '3000:3000' 12 | environment: 13 | - NODE_ENV=development 14 | -------------------------------------------------------------------------------- /package-lock.json: -------------------------------------------------------------------------------- 1 | { 2 | "lockfileVersion": 1 3 | } 4 | -------------------------------------------------------------------------------- /webapp/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | env: { 3 | browser: true, 4 | es6: true, 5 | "jest/globals": true 6 | }, 7 | extends: [ 8 | 'plugin:react/recommended', 9 | 'standard', 10 | "prettier" 11 | ], 12 | globals: { 13 | Atomics: 'readonly', 14 | SharedArrayBuffer: 'readonly' 15 | }, 16 | parserOptions: { 17 | ecmaFeatures: { 18 | jsx: true 19 | }, 20 | ecmaVersion: 2018, 21 | sourceType: 'module' 22 | }, 23 | plugins: [ 24 | 'react', 25 | 'jest', 26 | 'react-hooks', 27 | 'prettier' 28 | ], 29 | settings: { 30 | react: { 31 | version: "detect" 32 | } 33 | }, 34 | rules: { 35 | "prettier/prettier": [ 36 | "error", 37 | { 38 | "printWidth": 80, 39 | "trailingComma": "es5", 40 | "semi": false, 41 | "jsxSingleQuote": true, 42 | "singleQuote": true, 43 | "useTabs": true 44 | } 45 | ], 46 | "react/prop-types": 0, 47 | "react-hooks/exhaustive-deps": "warn" 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /webapp/.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 80, 3 | "trailingComma": "es5", 4 | "semi": false, 5 | "jsxSingleQuote": true, 6 | "singleQuote": true, 7 | "useTabs": true 8 | } -------------------------------------------------------------------------------- /webapp/Dockerfile-dev: -------------------------------------------------------------------------------- 1 | # base image 2 | FROM node:12.2.0-alpine 3 | 4 | # set working directory 5 | WORKDIR /app 6 | 7 | # add `/app/node_modules/.bin` to $PATH 8 | ENV PATH /app/node_modules/.bin:$PATH 9 | 10 | # install and cache app dependencies 11 | COPY /webapp/package.json /app 12 | RUN npm install 13 | 14 | # start app 15 | CMD ["npm", "start"] -------------------------------------------------------------------------------- /webapp/config-overrides.js: -------------------------------------------------------------------------------- 1 | const CircularDependencyPlugin = require('circular-dependency-plugin') 2 | 3 | module.exports = function override(config, env) { 4 | config.plugins.push( 5 | new CircularDependencyPlugin({ 6 | // exclude detection of files based on a RegExp 7 | exclude: /a\.js|node_modules/, 8 | // include specific files based on a RegExp 9 | include: /src/, 10 | // add errors to webpack instead of warnings 11 | failOnError: true, 12 | // allow import cycles that include an asyncronous import, 13 | // e.g. via import(/* webpackMode: "weak" */ './file.js') 14 | allowAsyncCycles: false, 15 | // set the current working directory for displaying module paths 16 | cwd: process.cwd(), 17 | }) 18 | ) 19 | //do stuff with the webpack config... 20 | return config; 21 | } -------------------------------------------------------------------------------- /webapp/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "vice-react-hooks-boilerplate", 3 | "version": "0.1.0", 4 | "private": true, 5 | "dependencies": { 6 | "@reduxjs/toolkit": "^1.3.4", 7 | "@testing-library/jest-dom": "^4.2.4", 8 | "@testing-library/react": "^9.5.0", 9 | "@testing-library/user-event": "^7.2.1", 10 | "bootstrap": "^4.4.1", 11 | "classnames": "^2.2.6", 12 | "formik": "^2.1.4", 13 | "localStorage": "^1.0.4", 14 | "lodash": "^4.17.15", 15 | "react": "^16.13.1", 16 | "react-bootstrap": "^1.0.0", 17 | "react-dom": "^16.13.1", 18 | "react-redux": "^7.2.0", 19 | "react-router-bootstrap": "^0.25.0", 20 | "react-router-dom": "^5.1.2", 21 | "react-scripts": "^3.4.1", 22 | "redux": "^4.0.5", 23 | "uuid": "^7.0.3" 24 | }, 25 | "scripts": { 26 | "start": "npm run pretty && npm run eslint && npm run start-watch", 27 | "start-watch": "concurrently --kill-others \"npm run prettier-watch\" \"npm run eslint-watch\" \"react-app-rewired start --no-cache\"", 28 | "build": "react-app-rewired build", 29 | "test": "react-app-rewired test", 30 | "eject": "react-scripts eject", 31 | "pretty": "npx prettier --write \"src/**/*.js\" \"src/**/*.css\"", 32 | "prettier-watch": "onchange \"src/**/*.js\" \"src/**/*.css\" -- npx prettier --write {{changed}}", 33 | "eslint": "npx eslint src", 34 | "eslint-watch": "onchange \"src/**/*.js\" -- npx eslint src" 35 | }, 36 | "eslintConfig": { 37 | "extends": "react-app" 38 | }, 39 | "browserslist": { 40 | "production": [ 41 | ">0.2%", 42 | "not dead", 43 | "not op_mini all" 44 | ], 45 | "development": [ 46 | "last 1 chrome version", 47 | "last 1 firefox version", 48 | "last 1 safari version" 49 | ] 50 | }, 51 | "devDependencies": { 52 | "circular-dependency-plugin": "^5.2.0", 53 | "concurrently": "^5.1.0", 54 | "customize-cra": "^0.9.1", 55 | "eslint": "^6.8.0", 56 | "eslint-config-prettier": "^6.10.1", 57 | "eslint-config-standard": "^14.1.1", 58 | "eslint-plugin-import": "^2.20.1", 59 | "eslint-plugin-jest": "^23.8.2", 60 | "eslint-plugin-node": "^11.0.0", 61 | "eslint-plugin-prettier": "^3.1.2", 62 | "eslint-plugin-promise": "^4.2.1", 63 | "eslint-plugin-react": "^7.19.0", 64 | "eslint-plugin-standard": "^4.0.1", 65 | "onchange": "^6.1.0", 66 | "prettier": "^2.0.2", 67 | "react-app-rewired": "^2.1.5" 68 | }, 69 | "babel": { 70 | "presets": [ 71 | "react-app" 72 | ] 73 | }, 74 | "//": "proxy COMMENT: Note that slowwly allows for adding a delay to an api. We are calling github with a 2.5 second delay", 75 | "proxy": "http://slowwly.robertomurray.co.uk/delay/2500/url/https://api.github.com/" 76 | } 77 | -------------------------------------------------------------------------------- /webapp/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vicesoftware/react-redux-hooks-boilerplate/7f414cb6172b8d57721e3e2e300504d423455980/webapp/public/favicon.ico -------------------------------------------------------------------------------- /webapp/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 12 | 13 | 17 | 18 | 27 | React App 28 | 29 | 30 | 31 |
32 | 42 | 43 | 44 | -------------------------------------------------------------------------------- /webapp/public/logo192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vicesoftware/react-redux-hooks-boilerplate/7f414cb6172b8d57721e3e2e300504d423455980/webapp/public/logo192.png -------------------------------------------------------------------------------- /webapp/public/logo512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vicesoftware/react-redux-hooks-boilerplate/7f414cb6172b8d57721e3e2e300504d423455980/webapp/public/logo512.png -------------------------------------------------------------------------------- /webapp/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 | "src": "logo192.png", 12 | "type": "image/png", 13 | "sizes": "192x192" 14 | }, 15 | { 16 | "src": "logo512.png", 17 | "type": "image/png", 18 | "sizes": "512x512" 19 | } 20 | ], 21 | "start_url": ".", 22 | "display": "standalone", 23 | "theme_color": "#000000", 24 | "background_color": "#ffffff" 25 | } 26 | -------------------------------------------------------------------------------- /webapp/public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | Disallow: 4 | -------------------------------------------------------------------------------- /webapp/src/App.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { BrowserRouter as Router } from 'react-router-dom' 3 | import Container from 'react-bootstrap/Container' 4 | import NavBar from './widgets/NavBar' 5 | import Routes from './Routes' 6 | import './app.css' 7 | import NotificationPopup from './infrastructure/notificationPopup' 8 | 9 | function App() { 10 | return ( 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | ) 19 | } 20 | 21 | export default App 22 | -------------------------------------------------------------------------------- /webapp/src/Routes.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { Switch, Route } from 'react-router-dom' 3 | import Page from './widgets/Page/Page' 4 | import Home from './features/Home' 5 | import Authenticated from './features/Authenticated' 6 | import Authorized from './features/Authorized' 7 | import Users from './features/users' 8 | import Settings from './features/settings' 9 | import SignIn from './features/SignIn' 10 | 11 | export default function Routes() { 12 | return ( 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | ) 34 | } 35 | 36 | function PageRoute({ children, ...rest }) { 37 | return ( 38 | 39 | {children} 40 | 41 | ) 42 | } 43 | -------------------------------------------------------------------------------- /webapp/src/__mocks__/react-redux.js: -------------------------------------------------------------------------------- 1 | const reactReduxMock = jest.genMockFromModule('react-redux') 2 | 3 | const dispatchMock = jest.fn() 4 | reactReduxMock.useDispatch.mockReturnValue(dispatchMock) 5 | 6 | const storeMock = { getState: jest.fn() } 7 | reactReduxMock.useStore.mockReturnValue(storeMock) 8 | 9 | module.exports = reactReduxMock 10 | -------------------------------------------------------------------------------- /webapp/src/app.css: -------------------------------------------------------------------------------- 1 | .page { 2 | margin-top: 20px; 3 | } 4 | -------------------------------------------------------------------------------- /webapp/src/config.js: -------------------------------------------------------------------------------- 1 | export const ACTION_TYPE_PREFIX = 'vbp' 2 | -------------------------------------------------------------------------------- /webapp/src/createStore.js: -------------------------------------------------------------------------------- 1 | import rootReducer from './rootReducer' 2 | import { configureStore } from '@reduxjs/toolkit' 3 | 4 | const store = configureStore({ 5 | reducer: rootReducer, 6 | }) 7 | 8 | export default () => store 9 | -------------------------------------------------------------------------------- /webapp/src/features/Authenticated.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { WithRestrictedAccess } from './userContext' 3 | 4 | const Authenticated = () =>

Authenticated

5 | 6 | export default WithRestrictedAccess(Authenticated) 7 | -------------------------------------------------------------------------------- /webapp/src/features/Authorized.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { WithRestrictedAccess } from './userContext' 3 | 4 | const Authorized = () =>

Authorized Page

5 | 6 | export default WithRestrictedAccess(Authorized, ['can-do-anything']) 7 | -------------------------------------------------------------------------------- /webapp/src/features/Home.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | export default function Home() { 3 | return ( 4 | <> 5 |

Vice Software Boilerplate

6 |

7 | Created by{' '} 8 | 9 | Vice Software, LLC 10 | {' '} 11 | to enable high velocity developement and easy maintenace. See details{' '} 12 | 16 | here 17 | 18 | . 19 |

20 | 21 | ) 22 | } 23 | -------------------------------------------------------------------------------- /webapp/src/features/SignIn.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { Redirect, useLocation } from 'react-router-dom' 3 | import get from 'lodash/get' 4 | import Form from 'react-bootstrap/Form' 5 | import Button from 'react-bootstrap/Button' 6 | import { Formik } from 'formik' 7 | import { useSelector, useDispatch } from 'react-redux' 8 | import BusyIndicator from '../widgets/busyIndicator' 9 | import { selectIsAuthenticated, signIn } from './userContext' 10 | 11 | const SignIn = () => { 12 | const location = useLocation() 13 | const isAuthenticated = useSelector(selectIsAuthenticated) 14 | const dispatch = useDispatch() 15 | 16 | const to = get(location, 'state.from') || 'home' 17 | 18 | return ( 19 | <> 20 | {isAuthenticated ? ( 21 | 22 | ) : ( 23 | 24 | { 27 | // When button submits form and form is in the process of submitting, submit button is disabled 28 | setSubmitting(true) 29 | 30 | // Simulate submitting to database, shows us values submitted, resets form 31 | dispatch(signIn(values)).then(() => { 32 | resetForm() 33 | setSubmitting(false) 34 | }) 35 | }} 36 | > 37 | {({ 38 | values, 39 | // errors, 40 | // touched, 41 | handleChange, 42 | handleBlur, 43 | handleSubmit, 44 | isSubmitting, 45 | }) => ( 46 |
47 | 48 | Email address 49 | 57 | {/* 58 | We'll never share your email with anyone else. 59 | */} 60 | 61 | 62 | 63 | Password 64 | 72 | 73 | 76 |
77 | )} 78 |
79 |
80 | )} 81 | 82 | ) 83 | } 84 | 85 | export default SignIn 86 | -------------------------------------------------------------------------------- /webapp/src/features/demo/Demo.js: -------------------------------------------------------------------------------- 1 | import React, { useEffect } from 'react' 2 | import { useSelector, useDispatch } from 'react-redux' 3 | import { selectAllDemo, selectDemoFilter } from './demo.selectors' 4 | import { actions } from './demo.slice' 5 | import { fetchAllDemo } from './demo.asyncActions' 6 | import BusyIndicator from '../../widgets/busyIndicator' 7 | 8 | const { updateFilter } = actions 9 | 10 | export default function Demo() { 11 | const demo = useSelector(selectAllDemo) 12 | const filter = useSelector(selectDemoFilter) 13 | 14 | const dispatch = useDispatch() 15 | 16 | useEffect(() => { 17 | dispatch(fetchAllDemo()) 18 | }, [dispatch]) 19 | 20 | return ( 21 |
22 |

Demo

23 | dispatch(updateFilter(e.target.value))} 27 | placeholder='Filter by...' 28 | /> 29 | 30 |
    31 | {demo && 32 | demo 33 | .filter((item) => (filter ? item.includes(filter) : true)) 34 | .map((item) =>
  • {item}
  • )} 35 |
36 |
37 |
38 | ) 39 | } 40 | -------------------------------------------------------------------------------- /webapp/src/features/demo/demo.asyncActions.js: -------------------------------------------------------------------------------- 1 | import { createAsyncThunk } from '@reduxjs/toolkit' 2 | import doAsync from '../../infrastructure/doAsync' 3 | 4 | export const fetchAllDemo = createAsyncThunk( 5 | 'demo/getAll', 6 | async ({ useCaching, noBusySpinner } = {}, thunkArgs) => 7 | await doAsync({ 8 | url: 'demo', 9 | useCaching, 10 | noBusySpinner, 11 | successMessage: 'Demo loaded', 12 | errorMessage: 'Unable to load demo. Please try again later.', 13 | stubSuccess: ['Dummy item 1', 'Dummy item 2'], 14 | ...thunkArgs, 15 | }) 16 | ) 17 | -------------------------------------------------------------------------------- /webapp/src/features/demo/demo.selectors.js: -------------------------------------------------------------------------------- 1 | import slice from './demo.slice' 2 | 3 | export const selectSlice = (state) => state[slice.name] 4 | 5 | export const selectAllDemo = (state) => selectSlice(state).allDemo 6 | 7 | export const selectDemoFilter = (state) => selectSlice(state).filter 8 | -------------------------------------------------------------------------------- /webapp/src/features/demo/demo.slice.js: -------------------------------------------------------------------------------- 1 | import { createSlice } from '@reduxjs/toolkit' 2 | import * as asyncActions from './demo.asyncActions' 3 | 4 | const initialState = { 5 | allDemo: [], 6 | filter: '', 7 | } 8 | 9 | const slice = createSlice({ 10 | name: 'demo', 11 | initialState, 12 | reducers: { 13 | // synchronous actions 14 | updateFilter(state, action) { 15 | state.filter = action.payload 16 | }, 17 | }, 18 | extraReducers: { 19 | // asynchronous actions 20 | [asyncActions.fetchAllDemo.fulfilled]: (state, action) => { 21 | state.allDemo = action.payload 22 | }, 23 | }, 24 | }) 25 | 26 | export default slice 27 | 28 | export const { name, actions, reducer } = slice 29 | -------------------------------------------------------------------------------- /webapp/src/features/demo/index.js: -------------------------------------------------------------------------------- 1 | import Demo from './Demo' 2 | import * as selectors from './demo.selectors' 3 | import * as asyncActions from './demo.asyncActions' 4 | import slice from './demo.slice' 5 | 6 | export const { 7 | name, 8 | actions: { updateFilter }, 9 | reducer, 10 | } = slice 11 | 12 | export const { fetchAllDemo } = asyncActions 13 | 14 | // we prefix all selectors with the the "select" prefix 15 | export const { selectAllDemo, selectDemoFilter } = selectors 16 | 17 | // we export the component most likely to be desired by default 18 | export default Demo 19 | -------------------------------------------------------------------------------- /webapp/src/features/settings/Settings.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { useSelector, useDispatch } from 'react-redux' 3 | import Form from 'react-bootstrap/Form' 4 | import { actions } from './settings.slice' 5 | import { selectAllSettings } from './settings.selectors' 6 | 7 | const { setUseCaching, setNoBusySpinner } = actions 8 | 9 | export default function Settings() { 10 | const settings = useSelector(selectAllSettings) 11 | 12 | const dispatch = useDispatch() 13 | 14 | return ( 15 |
16 |

Settings

17 | 18 | dispatch(setUseCaching(!settings.useCaching))} 20 | checked={settings.useCaching} 21 | type='checkbox' 22 | label='useCaching' 23 | /> 24 | 25 | 26 | dispatch(setNoBusySpinner(!settings.noBusySpinner))} 28 | checked={settings.noBusySpinner} 29 | type='checkbox' 30 | label='noBusySpinner' 31 | /> 32 | 33 |
34 | ) 35 | } 36 | -------------------------------------------------------------------------------- /webapp/src/features/settings/index.js: -------------------------------------------------------------------------------- 1 | import Settings from './Settings' 2 | import * as selectors from './settings.selectors' 3 | import slice from './settings.slice' 4 | 5 | export const { 6 | name, 7 | actions: { setUseCaching, setNoBusySpinner }, 8 | reducer, 9 | } = slice 10 | 11 | export const { selectAllSettings } = selectors 12 | 13 | export default Settings 14 | -------------------------------------------------------------------------------- /webapp/src/features/settings/settings.selectors.js: -------------------------------------------------------------------------------- 1 | import slice from './settings.slice' 2 | 3 | export const selectSlice = (state) => state[slice.name] 4 | 5 | export const selectAllSettings = (state) => selectSlice(state) 6 | -------------------------------------------------------------------------------- /webapp/src/features/settings/settings.slice.js: -------------------------------------------------------------------------------- 1 | import { createSlice } from '@reduxjs/toolkit' 2 | 3 | const initialState = { 4 | useCaching: false, 5 | noBusySpinner: false, 6 | } 7 | 8 | const slice = createSlice({ 9 | name: 'settings', 10 | initialState, 11 | reducers: { 12 | setUseCaching(state, action) { 13 | state.useCaching = action.payload 14 | }, 15 | setNoBusySpinner(state, action) { 16 | state.noBusySpinner = action.payload 17 | }, 18 | }, 19 | }) 20 | 21 | export default slice 22 | 23 | export const { name, actions, reducer } = slice 24 | -------------------------------------------------------------------------------- /webapp/src/features/userContext/WithRestrictedAccess.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { useSelector } from 'react-redux' 3 | import { Redirect, useLocation } from 'react-router-dom' 4 | import { 5 | selectIsAuthenticated, 6 | selectCurrentUserHasPermissions, 7 | } from './userContext.selectors' 8 | 9 | const WithRestrictedAccess = (WrappedComponent, requiredPermissions = []) => { 10 | const ProtectedRoute = () => { 11 | const isAuthenticated = !!useSelector(selectIsAuthenticated) 12 | const hasPermissions = useSelector( 13 | selectCurrentUserHasPermissions(requiredPermissions) 14 | ) 15 | const location = useLocation() 16 | 17 | return ( 18 |
19 | {isAuthenticated && hasPermissions ? ( 20 | 21 | ) : // Authenticated so show content 22 | !isAuthenticated ? ( 23 | 30 | ) : ( 31 |
You {"don't"} have the required permissions for this page.
32 | )} 33 |
34 | ) 35 | } 36 | 37 | return ProtectedRoute 38 | } 39 | 40 | export default WithRestrictedAccess 41 | -------------------------------------------------------------------------------- /webapp/src/features/userContext/index.js: -------------------------------------------------------------------------------- 1 | import * as selectors from './userContext.selectors' 2 | import * as asyncActions from './userContext.asynActions' 3 | import slice from './userContext.slice' 4 | import WithRestrictedAccess from './WithRestrictedAccess' 5 | 6 | export const { 7 | name, 8 | actions: { logout }, 9 | reducer, 10 | } = slice 11 | 12 | export const { signIn } = asyncActions 13 | 14 | export const { 15 | selectIsAuthenticated, 16 | selectCurrentUserHasPermissions, 17 | selectUserContext, 18 | } = selectors 19 | 20 | export { WithRestrictedAccess } 21 | -------------------------------------------------------------------------------- /webapp/src/features/userContext/userContext.asynActions.js: -------------------------------------------------------------------------------- 1 | import { createAsyncThunk } from '@reduxjs/toolkit' 2 | import doAsync from '../../infrastructure/doAsync' 3 | 4 | export const NOT_FOUND = 404 5 | export const BAD_REQUEST = 400 6 | 7 | export const signIn = createAsyncThunk( 8 | 'userContext/signIn', 9 | async ({ email, password, useCaching, noBusySpinner } = {}, thunkArgs) => { 10 | const { stubSuccess, stubError } = fakeAuthentication(email, password) 11 | return await doAsync({ 12 | url: 'sign-in', 13 | httpConfig: { 14 | body: JSON.stringify({ userName: email, password }), 15 | }, 16 | useCaching, 17 | noBusySpinner, 18 | successMessage: 'Sign In successful', 19 | errorMessage: `Unable to sign in user. Error: ${ 20 | stubError && stubError.statuscode 21 | }`, 22 | stubSuccess, 23 | stubError, 24 | ...thunkArgs, 25 | }) 26 | } 27 | ) 28 | 29 | // Will create a request with either 30 | // (1) stubSuccess property to fake a successful server authentication 31 | // (2) stubError property to fake a server authentication error 32 | function fakeAuthentication(email, password) { 33 | const response = {} 34 | 35 | let stubError 36 | let permissions = [] 37 | let displayName 38 | 39 | if (email === 'ryan@vicesoftware.com') { 40 | displayName = 'Ryan Vice' 41 | permissions = ['can-do-anything'] 42 | } else if (email === 'heather@vicesoftware.com') { 43 | displayName = 'Heather Vice' 44 | } else { 45 | stubError = { 46 | statusCode: NOT_FOUND, 47 | } 48 | } 49 | 50 | if (password !== 'password') { 51 | stubError = { 52 | statusCode: BAD_REQUEST, 53 | } 54 | } 55 | 56 | if (stubError) { 57 | response.stubError = stubError 58 | } else { 59 | response.stubSuccess = { 60 | userName: email, 61 | displayName, 62 | permissions, 63 | } 64 | } 65 | 66 | return response 67 | } 68 | -------------------------------------------------------------------------------- /webapp/src/features/userContext/userContext.selectors.js: -------------------------------------------------------------------------------- 1 | import slice from './userContext.slice' 2 | import isEmpty from 'lodash/isEmpty' 3 | 4 | export const selectSlice = (state) => state[slice.name] 5 | 6 | export const selectUserContext = (state) => selectSlice(state) 7 | 8 | export const selectIsAuthenticated = (state) => 9 | !isEmpty(selectUserContext(state)) 10 | 11 | export const selectCurrentUserHasPermissions = (permissions) => (state) => 12 | userHasPermissions(permissions, state) 13 | 14 | function userHasPermissions(permissions, state) { 15 | if (!permissions || !permissions.length) { 16 | return true 17 | } 18 | 19 | const userContext = selectUserContext(state) 20 | 21 | if ( 22 | !userContext || 23 | !userContext.permissions || 24 | !userContext.permissions.length 25 | ) { 26 | return false 27 | } 28 | 29 | return !!userContext.permissions.find((userPermission) => 30 | permissions.find( 31 | (requiredPermission) => requiredPermission === userPermission 32 | ) 33 | ) 34 | } 35 | -------------------------------------------------------------------------------- /webapp/src/features/userContext/userContext.slice.js: -------------------------------------------------------------------------------- 1 | import { createSlice } from '@reduxjs/toolkit' 2 | import * as asyncActions from './userContext.asynActions' 3 | 4 | const initialState = {} 5 | 6 | export default createSlice({ 7 | name: 'userContext', 8 | initialState, 9 | reducers: { 10 | logout(state, action) { 11 | return {} 12 | }, 13 | }, 14 | extraReducers: { 15 | [asyncActions.signIn.fulfilled]: (_, action) => action.payload, 16 | }, 17 | }) 18 | -------------------------------------------------------------------------------- /webapp/src/features/users/Users.js: -------------------------------------------------------------------------------- 1 | import React, { useEffect } from 'react' 2 | import { useSelector, useDispatch } from 'react-redux' 3 | import Row from 'react-bootstrap/Row' 4 | import Col from 'react-bootstrap/Col' 5 | import Button from 'react-bootstrap/Button' 6 | import Card from 'react-bootstrap/Card' 7 | import Container from 'react-bootstrap/Container' 8 | import { fetchAllUsers } from './users.asyncActions' 9 | import { selectAllUsers } from './users.selectors' 10 | import BusyIndicator from '../../widgets/busyIndicator' 11 | import { selectAllSettings, setNoBusySpinner } from '../settings' 12 | 13 | export default function Users() { 14 | const users = useSelector(selectAllUsers) 15 | 16 | const dispatch = useDispatch() 17 | 18 | const settings = useSelector(selectAllSettings) 19 | 20 | useEffect(() => { 21 | dispatch( 22 | fetchAllUsers({ 23 | useCaching: settings.useCaching, 24 | noBusySpinner: settings.noBusySpinner, 25 | }) 26 | ) 27 | }, [dispatch, settings.useCaching, settings.noBusySpinner]) 28 | 29 | return ( 30 |
31 | 32 | 33 |

Users

34 | 35 |
36 | 37 | 38 | 39 |
    40 | {users && 41 | users.map((user) =>
  • {user.login}
  • )} 42 |
43 |
44 | 45 | 46 | 47 | 48 | 49 | Options 50 | 51 | 52 | 58 | 59 | 60 | {`noBusySpinner: ${settings.noBusySpinner}`} 61 | 62 | 63 | {`useCaching: ${settings.useCaching}`} 64 | 65 | 66 | 67 | 68 | 69 | 70 |
71 |
72 | ) 73 | } 74 | 75 | function Help() { 76 | return ( 77 | 78 | 79 | About 80 | 81 | This app demonstrates three features of the boilerplate: 82 | 83 |
90 | useCaching 91 | 92 | doAsync takes a useCaching argument which will avoid trips to the 93 | API for data that is already in Redux. To see this 94 |
    95 |
  1. go to settings page and turn on useCaching
  2. 96 |
  3. 97 | navigate back to the users page and the API will be called again 98 | but the call will be recorded by the httpCache module (you can 99 | see all this happen in Redux DevTools) 100 |
  4. 101 |
  5. 102 | {' '} 103 | navigate away from users and come back and then you won't 104 | see a busy indicator as the users will now be in the redux cache 105 |
  6. 106 |
107 |
108 | 109 | nBusySpinner 110 | 111 | 112 | doAsync takes a noBusySpinner argument which will avoid trips to the 113 | API for data that is already in Redux. To see this 114 |
    115 |
  1. navigate to settings page
  2. 116 |
  3. refresh the browser on the settings page
  4. 117 |
  5. 118 | after the settings page reloads turn check the noBusySpinner 119 | option 120 |
  6. 121 |
  7. 122 | {' '} 123 | navigate back to users page and you won't see a busy 124 | indicator while the data is loading 125 |
  8. 126 |
  9. 127 | repeate these steps and then quickly click the "Reload with 128 | Busy Spinner" which will call doAsync again but with the 129 | noBusySpinner option set to true. This will 130 |
      131 |
    1. 132 | determine there is already a request in progress so it 133 | won't call the API again 134 |
    2. 135 |
    3. 136 | determine that the busyIndicator isn't being showed but 137 | should be so will turn the busy indicator on 138 |
    4. 139 |
    140 |
  10. 141 |
142 |
143 | 144 | NotificationPopups integration with doAsync 145 | 146 |

147 | This page is configured with both a success message and an 148 | errorMessage when it calls doAsync. The successMessages causes a 149 | popup notification to be shown with "Users loaded" in it 150 | after the users load. If you want to see the errorMessage, 151 | disconnect from the internet and refresh the page. You will see that 152 |

153 |
154 |
155 |
156 | ) 157 | } 158 | -------------------------------------------------------------------------------- /webapp/src/features/users/index.js: -------------------------------------------------------------------------------- 1 | import Users from './Users' 2 | import * as selectors from './users.selectors' 3 | import slice from './users.slice' 4 | import * as asyncActions from './users.asyncActions' 5 | 6 | export const { name, reducer } = slice 7 | 8 | export const { fetchAllUsers } = asyncActions 9 | export const { selectAllUsers } = selectors 10 | 11 | export default Users 12 | -------------------------------------------------------------------------------- /webapp/src/features/users/users.asyncActions.js: -------------------------------------------------------------------------------- 1 | import { createAsyncThunk } from '@reduxjs/toolkit' 2 | import doAsync from '../../infrastructure/doAsync' 3 | 4 | export const fetchAllUsers = createAsyncThunk( 5 | 'users/getAll', 6 | async ({ useCaching, noBusySpinner }, thunkArgs) => 7 | await doAsync({ 8 | url: 'users?page=1&per_page=100', 9 | useCaching, 10 | noBusySpinner, 11 | successMessage: 'Users loaded', 12 | errorMessage: 'Unable to load users. Please try again later.', 13 | ...thunkArgs, 14 | }) 15 | ) 16 | -------------------------------------------------------------------------------- /webapp/src/features/users/users.selectors.js: -------------------------------------------------------------------------------- 1 | import slice from './users.slice' 2 | 3 | export const selectSlice = (state) => state[slice.name] 4 | 5 | export const selectAllUsers = (state) => selectSlice(state).allUsers 6 | export const selectUsersById = (usersId) => (state) => 7 | selectAllUsers(state).find((item) => item.id === usersId) 8 | -------------------------------------------------------------------------------- /webapp/src/features/users/users.slice.js: -------------------------------------------------------------------------------- 1 | import { createSlice } from '@reduxjs/toolkit' 2 | import * as asyncActions from './users.asyncActions' 3 | 4 | const initialState = { 5 | allUsers: [], 6 | } 7 | 8 | export default createSlice({ 9 | name: 'users', 10 | initialState, 11 | extraReducers: { 12 | [asyncActions.fetchAllUsers.fulfilled]: (state, action) => { 13 | state.allUsers = action.payload 14 | }, 15 | }, 16 | }) 17 | -------------------------------------------------------------------------------- /webapp/src/index.css: -------------------------------------------------------------------------------- 1 | body { 2 | margin: 0; 3 | font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 4 | 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', 5 | sans-serif; 6 | -webkit-font-smoothing: antialiased; 7 | -moz-osx-font-smoothing: grayscale; 8 | } 9 | 10 | code { 11 | font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', 12 | monospace; 13 | } 14 | -------------------------------------------------------------------------------- /webapp/src/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import ReactDOM from 'react-dom' 3 | import { Provider } from 'react-redux' 4 | import createStore from './createStore' 5 | import './index.css' 6 | import App from './App' 7 | import * as serviceWorker from './serviceWorker' 8 | import 'bootstrap/dist/css/bootstrap.min.css' 9 | 10 | const store = createStore() 11 | 12 | ReactDOM.render( 13 | 14 | 15 | 16 | 17 | , 18 | document.getElementById('root') 19 | ) 20 | 21 | // If you want your app to work offline and load faster, you can change 22 | // unregister() to register() below. Note this comes with some pitfalls. 23 | // Learn more about service workers: https://bit.ly/CRA-PWA 24 | serviceWorker.unregister() 25 | -------------------------------------------------------------------------------- /webapp/src/infrastructure/buildCacheKey.js: -------------------------------------------------------------------------------- 1 | const buildCacheKey = ({ url, httpMethod }) => `${httpMethod}|${url}` 2 | 3 | export default buildCacheKey 4 | -------------------------------------------------------------------------------- /webapp/src/infrastructure/createAsyncAction.js: -------------------------------------------------------------------------------- 1 | import { createAction, nanoid } from '@reduxjs/toolkit' 2 | const commonProperties = ['name', 'message', 'stack', 'code'] 3 | class RejectWithValue { 4 | constructor(value) { 5 | this.value = value 6 | } 7 | } 8 | // Reworked from https://github.com/sindresorhus/serialize-error 9 | export const miniSerializeError = (value) => { 10 | if (typeof value === 'object' && value !== null) { 11 | const simpleError = {} 12 | for (const property of commonProperties) { 13 | if (typeof value[property] === 'string') { 14 | simpleError[property] = value[property] 15 | } 16 | } 17 | return simpleError 18 | } 19 | return { message: String(value) } 20 | } 21 | /** 22 | * 23 | * @param type 24 | * @param payloadCreator 25 | * 26 | * @public 27 | */ 28 | export function createAsyncThunk(type, payloadCreator) { 29 | const fulfilled = createAction( 30 | type + '/fulfilled', 31 | (result, requestId, arg) => { 32 | return { 33 | payload: result, 34 | meta: { arg, requestId }, 35 | } 36 | } 37 | ) 38 | const pending = createAction(type + '/pending', (requestId, arg) => { 39 | return { 40 | payload: undefined, 41 | meta: { arg, requestId }, 42 | } 43 | }) 44 | const rejected = createAction( 45 | type + '/rejected', 46 | (error, requestId, arg, payload) => { 47 | const aborted = !!error && error.name === 'AbortError' 48 | return { 49 | payload, 50 | error: miniSerializeError(error || 'Rejected'), 51 | meta: { 52 | arg, 53 | requestId, 54 | aborted, 55 | }, 56 | } 57 | } 58 | ) 59 | let displayedWarning = false 60 | const AC = 61 | typeof AbortController !== 'undefined' 62 | ? AbortController 63 | : class { 64 | constructor() { 65 | this.signal = { 66 | aborted: false, 67 | addEventListener() {}, 68 | dispatchEvent() { 69 | return false 70 | }, 71 | onabort() {}, 72 | removeEventListener() {}, 73 | } 74 | } 75 | 76 | abort() { 77 | if (process.env.NODE_ENV !== 'production') { 78 | if (!displayedWarning) { 79 | displayedWarning = true 80 | console.info(`This platform does not implement AbortController. 81 | If you want to use the AbortController to react to \`abort\` events, please consider importing a polyfill like 'abortcontroller-polyfill/dist/abortcontroller-polyfill-only'.`) 82 | } 83 | } 84 | } 85 | } 86 | 87 | function actionCreator(arg) { 88 | return (dispatch, getState, extra) => { 89 | console.log('called') 90 | 91 | const requestId = nanoid() 92 | const abortController = new AC() 93 | let abortReason 94 | 95 | const abortedPromise = new Promise((resolve, reject) => 96 | abortController.signal.addEventListener('abort', () => 97 | // eslint-disable-next-line prefer-promise-reject-errors 98 | reject({ name: 'AbortError', message: abortReason || 'Aborted' }) 99 | ) 100 | ) 101 | function abort(reason) { 102 | abortReason = reason 103 | abortController.abort() 104 | } 105 | const promise = (async function () { 106 | let finalAction 107 | try { 108 | dispatch(pending(requestId, arg)) 109 | finalAction = await Promise.race([ 110 | abortedPromise, 111 | Promise.resolve( 112 | payloadCreator(arg, { 113 | dispatch, 114 | getState, 115 | extra, 116 | requestId, 117 | signal: abortController.signal, 118 | rejectWithValue(value) { 119 | return new RejectWithValue(value) 120 | }, 121 | }) 122 | ).then((result) => { 123 | if (result instanceof RejectWithValue) { 124 | return rejected(null, requestId, arg, result.value) 125 | } 126 | return fulfilled(result, requestId, arg) 127 | }), 128 | ]) 129 | } catch (err) { 130 | finalAction = rejected(err, requestId, arg) 131 | } 132 | // We dispatch the result action _after_ the catch, to avoid having any errors 133 | // here get swallowed by the try/catch block, 134 | // per https://twitter.com/dan_abramov/status/770914221638942720 135 | // and https://redux-toolkit.js.org/tutorials/advanced-tutorial#async-error-handling-logic-in-thunks 136 | dispatch(finalAction) 137 | return finalAction 138 | })() 139 | 140 | return Object.assign(promise, { abort }) 141 | } 142 | } 143 | 144 | return Object.assign(actionCreator, { 145 | pending, 146 | rejected, 147 | fulfilled, 148 | }) 149 | } 150 | /** 151 | * @public 152 | */ 153 | export function unwrapResult(returned) { 154 | if ('error' in returned) { 155 | throw returned.error 156 | } 157 | return returned.payload 158 | } 159 | -------------------------------------------------------------------------------- /webapp/src/infrastructure/dispatchAsync.js: -------------------------------------------------------------------------------- 1 | const DUMMY_ASYNC_DELAY = 1500 2 | 3 | export default function dispatchAsync({ 4 | url, 5 | actionType, 6 | dispatch, 7 | dummyResponse, 8 | dummyError, 9 | } = {}) { 10 | if (!url) { 11 | throw new Error('url is required!') 12 | } 13 | 14 | if ( 15 | !actionType || 16 | !(actionType.REQUESTED && actionType.RECEIVED && actionType.ERROR) 17 | ) { 18 | throw new Error( 19 | 'actionType is required and must be an async action. Use the buildAsyncAction() factor method to create async actions.' 20 | ) 21 | } 22 | 23 | if (dummyResponse && dummyError) { 24 | throw new Error( 25 | "It's invalid to specify dummyResponse and dummyError. You must specify one or the other." 26 | ) 27 | } 28 | 29 | dispatch({ type: actionType.REQUESTED }) 30 | 31 | if (dummyResponse) { 32 | setTimeout( 33 | () => dispatch({ type: actionType.RECEIVED, payload: dummyResponse }), 34 | DUMMY_ASYNC_DELAY 35 | ) 36 | return 37 | } else if (dummyError) { 38 | setTimeout( 39 | () => dispatch({ type: actionType.ERROR, dummyError }), 40 | DUMMY_ASYNC_DELAY 41 | ) 42 | } 43 | 44 | fetch(url) 45 | .then((response) => response.json()) 46 | .then((json) => dispatch({ type: actionType.RECEIVED, payload: json })) 47 | .catch((e) => dispatch({ type: actionType.ERROR, payload: e })) 48 | } 49 | -------------------------------------------------------------------------------- /webapp/src/infrastructure/doAsync/doAsync.actionTypes.js: -------------------------------------------------------------------------------- 1 | import { buildActionType } from '../reduxHelpers' 2 | 3 | export const REQUEST_ALREADY_PENDING_ASYNC = buildActionType( 4 | 'doAsync', 5 | 'REQUEST_ALREADY_PENDING_ASYNC' 6 | ) 7 | 8 | export const TURN_OFF_BUSY_INDICATOR_FOR_PENDING_ASYNC = buildActionType( 9 | 'doAsync', 10 | 'TURN_OFF_BUSY_INDICATOR_FOR_PENDING_ASYNC' 11 | ) 12 | 13 | export const REDUX_CACHE_HIT_RECEIVED_ASYNC = buildActionType( 14 | 'doAsync', 15 | 'REDUX_CACHE_HIT_RECEIVED_ASYNC' 16 | ) 17 | -------------------------------------------------------------------------------- /webapp/src/infrastructure/doAsync/doAsync.js: -------------------------------------------------------------------------------- 1 | import http from '../http' 2 | import { addRequestToCache, tryToFindRequestInCache } from '../httpCache' 3 | import { REDUX_CACHE_HIT_RECEIVED_ASYNC } from './doAsync.actionTypes' 4 | import { 5 | buildHeaders, 6 | cleanUpPendingRequests, 7 | handleError, 8 | logError, 9 | requestIsAlreadyPending, 10 | validateInput, 11 | } from './doAsyncLogic' 12 | import { 13 | incrementBusyIndicator, 14 | decrementBusyIndicator, 15 | } from '../../widgets/busyIndicator' 16 | import { notifySuccess } from '../../infrastructure/notificationPopup' 17 | 18 | export const cacheHit = (url, method, noBusySpinner) => ({ 19 | type: REDUX_CACHE_HIT_RECEIVED_ASYNC, 20 | payload: { 21 | url, 22 | method, 23 | noBusySpinner, 24 | }, 25 | }) 26 | 27 | const doAsync = ({ 28 | url, 29 | httpMethod = 'get', 30 | errorMessage = 'Unable to process request. Please try again later.', 31 | httpConfig, 32 | onError, 33 | successMessage, 34 | noBusySpinner, 35 | busyIndicatorName, 36 | useCaching = false, 37 | stubSuccess, 38 | stubError, 39 | dispatch, 40 | getState, 41 | rejectWithValue, 42 | } = {}) => { 43 | if (!getState || typeof getState !== 'function') { 44 | throw new Error( 45 | 'getState is required and must have a getState method defined on it' 46 | ) 47 | } 48 | 49 | if (!dispatch || typeof dispatch !== 'function') { 50 | throw new Error('dispatch is required and must be a function') 51 | } 52 | 53 | try { 54 | validateInput(url, httpMethod) 55 | 56 | if ( 57 | requestIsAlreadyPending({ 58 | noBusySpinner, 59 | url, 60 | httpMethod, 61 | httpConfig, 62 | busyIndicatorName, 63 | dispatch, 64 | getState, 65 | }) 66 | ) { 67 | return rejectWithValue('Request is already pending.') 68 | } 69 | 70 | if (useCaching) { 71 | if ( 72 | tryToFindRequestInCache( 73 | getState(), 74 | url, 75 | httpMethod, 76 | httpConfig && httpConfig.body 77 | ) 78 | ) { 79 | dispatch(cacheHit(url, httpMethod, noBusySpinner)) 80 | cleanUpPendingRequests({ 81 | url, 82 | httpMethod, 83 | busyIndicatorName, 84 | dispatch, 85 | getState, 86 | }) 87 | return rejectWithValue('Request found in cache.') 88 | } 89 | 90 | const requestConfig = {} 91 | 92 | if ( 93 | httpMethod.toLowerCase() === 'post' || 94 | httpMethod.toLowerCase() === 'put' 95 | ) { 96 | requestConfig.body = httpConfig.body 97 | } 98 | 99 | dispatch(addRequestToCache({ url, httpMethod, config: requestConfig })) 100 | } 101 | 102 | if (!noBusySpinner) { 103 | dispatch(incrementBusyIndicator(busyIndicatorName)) 104 | } 105 | 106 | httpConfig = { 107 | ...httpConfig, 108 | ...buildHeaders(url, httpConfig), 109 | } 110 | 111 | return http[httpMethod](url, httpConfig, { stubSuccess, stubError }) 112 | .then((body) => { 113 | if (successMessage) { 114 | dispatch(notifySuccess(successMessage)) 115 | } 116 | 117 | return Promise.resolve(body) 118 | }) 119 | .catch((exception) => { 120 | handleError( 121 | exception, 122 | onError, 123 | dispatch, 124 | httpMethod, 125 | url, 126 | httpConfig, 127 | errorMessage 128 | ) 129 | }) 130 | .then((response) => { 131 | cleanUpPendingRequests({ 132 | url, 133 | httpMethod, 134 | busyIndicatorName, 135 | dispatch, 136 | getState, 137 | }) 138 | if (!noBusySpinner) { 139 | dispatch(decrementBusyIndicator(busyIndicatorName)) 140 | } 141 | return response 142 | }) 143 | } catch (exception) { 144 | logError(dispatch, httpMethod, url, httpConfig, { 145 | exception, 146 | }) 147 | throw exception 148 | } 149 | } 150 | 151 | export default doAsync 152 | -------------------------------------------------------------------------------- /webapp/src/infrastructure/doAsync/doAsyncLogic.js: -------------------------------------------------------------------------------- 1 | import http from '../http' 2 | import { 3 | REQUEST_ALREADY_PENDING_ASYNC, 4 | TURN_OFF_BUSY_INDICATOR_FOR_PENDING_ASYNC, 5 | } from './doAsync.actionTypes' 6 | import { 7 | addPendingRequest, 8 | deletePendingRequest, 9 | setBusySpinner, 10 | selectPendingRequest, 11 | } from '../pendingRequest' 12 | import { notifyError } from '../notificationPopup' 13 | import { 14 | decrementBusyIndicator, 15 | incrementBusyIndicator, 16 | } from '../../widgets/busyIndicator' 17 | 18 | export function cleanUpPendingRequests({ 19 | url, 20 | httpMethod, 21 | busyIndicatorName, 22 | dispatch, 23 | getState, 24 | }) { 25 | if (!getState || typeof getState !== 'function') { 26 | throw new Error('getState is required and must be a function') 27 | } 28 | 29 | if (!selectPendingRequest(getState(), { url, httpMethod })) { 30 | return 31 | } 32 | 33 | if (selectPendingRequest(getState(), { url, httpMethod }).turnSpinnerOff) { 34 | dispatch({ type: TURN_OFF_BUSY_INDICATOR_FOR_PENDING_ASYNC }) 35 | dispatch(decrementBusyIndicator(busyIndicatorName)) 36 | } 37 | 38 | dispatch(deletePendingRequest({ url, httpMethod })) 39 | } 40 | 41 | export function handleError( 42 | exception, 43 | onError, 44 | dispatch, 45 | httpMethod, 46 | url, 47 | httpConfig, 48 | errorMessage 49 | ) { 50 | if (onError) { 51 | onError(exception) 52 | } else { 53 | logError(dispatch, httpMethod, url, httpConfig, { 54 | exception, 55 | errorMessage: `${errorMessage}. 56 | An error occurred when trying to dispatch results of ajax call to Redux.`, 57 | }) 58 | } 59 | } 60 | 61 | export function getError(httpMethod, url, httpConfig, errorMessage) { 62 | return `${errorMessage && errorMessage + '. '} 63 | Unable to complete http request ${httpMethod}:${url} 64 | with httpConfig: ${JSON.stringify(httpConfig)}.` 65 | } 66 | 67 | export function logError( 68 | dispatch, 69 | httpMethod, 70 | url, 71 | httpConfig, 72 | { exception, errorMessage } = {} 73 | ) { 74 | console.log( 75 | `${getError(httpMethod, url, httpConfig, errorMessage)} 76 | Failed with error:`, 77 | exception 78 | ) 79 | 80 | if (errorMessage) { 81 | dispatch(notifyError(errorMessage)) 82 | } 83 | } 84 | 85 | export function requestIsAlreadyPending({ 86 | noBusySpinner, 87 | url, 88 | httpMethod, 89 | httpConfig, 90 | busyIndicatorName, 91 | dispatch, 92 | getState, 93 | } = {}) { 94 | if (!getState || typeof getState !== 'function') { 95 | throw new Error('get state is required and must be a function') 96 | } 97 | 98 | const pendingRequest = selectPendingRequest(getState(), { url, httpMethod }) 99 | 100 | if (pendingRequest) { 101 | const currentRequestRequiresABusySpinner = !noBusySpinner 102 | 103 | if (!pendingRequest.turnSpinnerOff && !noBusySpinner) { 104 | dispatch(incrementBusyIndicator(busyIndicatorName)) 105 | } 106 | 107 | dispatch( 108 | setBusySpinner({ 109 | url, 110 | httpMethod, 111 | turnSpinnerOff: currentRequestRequiresABusySpinner, 112 | }) 113 | ) 114 | 115 | dispatch({ 116 | type: REQUEST_ALREADY_PENDING_ASYNC, 117 | payload: { 118 | url, 119 | httpMethod, 120 | httpConfig, 121 | noBusySpinner, 122 | }, 123 | }) 124 | return true 125 | } 126 | 127 | // At this point we don't have a pending request and 128 | // the current request doesn't want a spinner so we 129 | // need to add it to the list of pending requests so 130 | // future request will know this request is pending 131 | if (noBusySpinner) { 132 | dispatch(addPendingRequest({ url, httpMethod })) 133 | } 134 | 135 | return false 136 | } 137 | 138 | export function buildHeaders(url, httpConfig) { 139 | const defaultHeadersObj = { 140 | headers: { 141 | Accept: 'application/json', 142 | 'Content-Type': 'application/json', 143 | }, 144 | } 145 | 146 | return httpConfig 147 | ? { 148 | headers: { 149 | ...defaultHeadersObj.headers, 150 | ...httpConfig.headers, 151 | }, 152 | } 153 | : defaultHeadersObj 154 | } 155 | 156 | export function validateInput(url, httpMethod) { 157 | if (!url) { 158 | throw new Error('url is required.') 159 | } 160 | 161 | if (!httpMethod || !http[httpMethod]) { 162 | throw new Error( 163 | 'httpMethod is required and must index the http service and resolve to a method.' 164 | ) 165 | } 166 | } 167 | -------------------------------------------------------------------------------- /webapp/src/infrastructure/doAsync/index.js: -------------------------------------------------------------------------------- 1 | import doAsync from './doAsync' 2 | import * as actionTypes from './doAsync.actionTypes' 3 | 4 | export { actionTypes } 5 | 6 | export default doAsync 7 | -------------------------------------------------------------------------------- /webapp/src/infrastructure/doAsync/tests/doAsync.test.js: -------------------------------------------------------------------------------- 1 | import doAsync, { cacheHit } from '../doAsync' 2 | import * as doAsyncLogic from '../doAsyncLogic' 3 | import * as reactReduxMock from 'react-redux' 4 | import http from '../../http' 5 | 6 | import { buildHeaders } from '../doAsyncLogic' 7 | import * as httpCache from '../../httpCache' 8 | 9 | jest.mock('../doAsyncLogic') 10 | jest.mock('react-redux') 11 | jest.mock('../../http') 12 | jest.mock('../../httpCache') 13 | 14 | let dispatch 15 | let getState 16 | 17 | describe('Given we call doAsync ', () => { 18 | beforeEach(() => { 19 | dispatch = reactReduxMock.useDispatch() 20 | getState = reactReduxMock.useStore().getState 21 | }) 22 | 23 | afterEach(() => { 24 | dispatch.mockReset() 25 | getState.mockReset() 26 | doAsyncLogic.validateInput.mockReset() 27 | doAsyncLogic.requestIsAlreadyPending.mockReset() 28 | httpCache.addRequestToCache.mockReset() 29 | httpCache.tryToFindRequestInCache.mockReset() 30 | http.get.mockReset() 31 | http.post.mockReset() 32 | http.put.mockReset() 33 | }) 34 | 35 | describe('When validateInput throws ', () => { 36 | it('Then logError is called and we rethrow exception ', () => { 37 | expect(doAsync).toBeTruthy() 38 | 39 | const expectedErrorMessage = 'expectedErrorMessage' 40 | 41 | doAsyncLogic.validateInput.mockImplementation(() => { 42 | throw new Error(expectedErrorMessage) 43 | }) 44 | 45 | const url = 'url' 46 | const httpMethod = 'get' 47 | const errorMessage = 'errorMessage' 48 | 49 | try { 50 | doAsync({ 51 | url, 52 | httpMethod, 53 | errorMessage, 54 | dispatch, 55 | getState, 56 | }) 57 | 58 | throw new Error('validateInput should have thrown an error') 59 | } catch (e) { 60 | expect(e.message).toEqual(expectedErrorMessage) 61 | expect(doAsyncLogic.validateInput.mock.calls[0]).toEqual([ 62 | url, 63 | httpMethod, 64 | ]) 65 | } 66 | }) 67 | }) 68 | 69 | describe('When a request is pending ', () => { 70 | it('Then actionType.REQUESTED is dispatched and nothing else is dispatched and we return rejectWithValue to caller ', async () => { 71 | expect(doAsync).toBeTruthy() 72 | 73 | const url = 'url' 74 | const httpMethod = 'httpMethod' 75 | const errorMessage = 'errorMessage' 76 | const noBusySpinner = 'noBusySpinner' 77 | const httpConfig = 'httpConfig' 78 | const useCaching = 'useCaching' 79 | 80 | const rejectWithValue = jest.fn() 81 | 82 | doAsyncLogic.requestIsAlreadyPending.mockReturnValue(true) 83 | 84 | await doAsync({ 85 | noBusySpinner, 86 | url, 87 | httpMethod, 88 | httpConfig, 89 | errorMessage, 90 | useCaching, 91 | dispatch, 92 | getState, 93 | rejectWithValue, 94 | }) 95 | 96 | expect(rejectWithValue.mock.calls.length).toBe(1) 97 | expect(rejectWithValue.mock.calls[0][0]).toEqual( 98 | 'Request is already pending.' 99 | ) 100 | 101 | expect(doAsyncLogic.requestIsAlreadyPending.mock.calls.length).toBe(1) 102 | expect(doAsyncLogic.requestIsAlreadyPending.mock.calls[0][0]).toEqual({ 103 | noBusySpinner, 104 | url, 105 | httpMethod, 106 | dispatch, 107 | httpConfig, 108 | getState, 109 | }) 110 | }) 111 | }) 112 | 113 | describe("When arguments are valid and request isn't pending ", () => { 114 | describe('and the http returns rejected promise', () => { 115 | it('Then handleError is called ', (done) => { 116 | const expectedBody = 'expectedBody' 117 | const expectedError = 'expectedError' 118 | 119 | http.get.mockImplementation(() => Promise.reject(expectedError)) 120 | 121 | testDoAsync({ expectedBody, expectProcessResult: false }) 122 | .then(({ url, httpMethod, errorMessage, httpConfig }) => { 123 | expect(doAsyncLogic.handleError.mock.calls.length).toEqual(1) 124 | expect(doAsyncLogic.handleError.mock.calls[0][0]).toEqual( 125 | expectedError 126 | ) 127 | expect(doAsyncLogic.handleError.mock.calls[0][3]).toEqual( 128 | httpMethod 129 | ) 130 | expect(doAsyncLogic.handleError.mock.calls[0][4]).toEqual(url) 131 | expect(doAsyncLogic.handleError.mock.calls[0][5]).toEqual( 132 | httpConfig 133 | ) 134 | expect(doAsyncLogic.handleError.mock.calls[0][6]).toEqual( 135 | errorMessage 136 | ) 137 | 138 | done() 139 | }) 140 | .catch(done.fail) 141 | }) 142 | }) 143 | 144 | describe('and the httpMethod is GET', () => { 145 | it('Then actionType.REQUESTED is dispatched and nothing else is dispatched ', async () => { 146 | const expectedBody = 'expectedBody' 147 | 148 | http.get.mockReturnValue(Promise.resolve(expectedBody)) 149 | 150 | return testDoAsync({ expectedBody }).then(({ url, httpConfig }) => { 151 | expect(http.get.mock.calls.length).toBe(1) 152 | expect(http.get.mock.calls[0][0]).toEqual(url) 153 | expect(http.get.mock.calls[0][1]).toEqual(httpConfig) 154 | }) 155 | }) 156 | }) 157 | 158 | describe('and the httpMethod is POST', () => { 159 | it('Then actionType.REQUESTED is dispatched and nothing else is dispatched ', async () => { 160 | const expectedBody = 'expectedBody' 161 | 162 | http.post.mockReturnValue(Promise.resolve(expectedBody)) 163 | 164 | return testDoAsync({ expectedBody, httpMethod: 'post' }).then( 165 | ({ url, httpConfig }) => { 166 | expect(http.post.mock.calls.length).toBe(1) 167 | expect(http.post.mock.calls[0][0]).toEqual(url) 168 | expect(http.post.mock.calls[0][1]).toEqual(httpConfig) 169 | } 170 | ) 171 | }) 172 | }) 173 | 174 | describe('and the httpMethod is PUT', () => { 175 | it('Then actionType.REQUESTED is dispatched and nothing else is dispatched ', async () => { 176 | const expectedBody = 'expectedBody' 177 | 178 | http.put.mockReturnValue(Promise.resolve(expectedBody)) 179 | 180 | return testDoAsync({ expectedBody, httpMethod: 'put' }).then( 181 | ({ url, httpConfig }) => { 182 | expect(http.put.mock.calls.length).toBe(1) 183 | expect(http.put.mock.calls[0][0]).toEqual(url) 184 | expect(http.put.mock.calls[0][1]).toEqual(httpConfig) 185 | } 186 | ) 187 | }) 188 | }) 189 | 190 | describe('and useCaching is true ', () => { 191 | describe('and request is NOT in cache ', () => { 192 | describe('and httpMethdo is GET ', () => { 193 | it('Then actionType.REQUESTED is dispatched and nothing else is dispatched ', async () => { 194 | const expectedBody = 'expectedBody' 195 | http.get.mockReturnValue(Promise.resolve(expectedBody)) 196 | 197 | return testUseCachingWithRequestNotInCache({ expectedBody }).then( 198 | ({ url, httpConfig }) => { 199 | expect(http.get.mock.calls.length).toBe(1) 200 | expect(http.get.mock.calls[0][0]).toEqual(url) 201 | expect(http.get.mock.calls[0][1]).toEqual(httpConfig) 202 | } 203 | ) 204 | }) 205 | }) 206 | 207 | describe('and httpMethdo is POST ', () => { 208 | it('Then actionType.REQUESTED is dispatched and nothing else is dispatched ', async () => { 209 | const expectedBody = 'expectedBody' 210 | http.post.mockReturnValue(Promise.resolve(expectedBody)) 211 | 212 | return testUseCachingWithRequestNotInCache({ 213 | expectedBody, 214 | httpMethod: 'post', 215 | }).then(({ url, httpConfig }) => { 216 | expect(http.post.mock.calls.length).toBe(1) 217 | expect(http.post.mock.calls[0][0]).toEqual(url) 218 | expect(http.post.mock.calls[0][1]).toEqual(httpConfig) 219 | }) 220 | }) 221 | }) 222 | 223 | describe('and httpMethdo is PUT ', () => { 224 | it('Then actionType.REQUESTED is dispatched and nothing else is dispatched ', async () => { 225 | const expectedBody = 'expectedBody' 226 | http.put.mockReturnValue(Promise.resolve(expectedBody)) 227 | 228 | return testUseCachingWithRequestNotInCache({ 229 | expectedBody, 230 | httpMethod: 'put', 231 | }).then(({ url, httpConfig }) => { 232 | expect(http.put.mock.calls.length).toBe(1) 233 | expect(http.put.mock.calls[0][0]).toEqual(url) 234 | expect(http.put.mock.calls[0][1]).toEqual(httpConfig) 235 | }) 236 | }) 237 | }) 238 | }) 239 | 240 | describe('and request is in cache ', () => { 241 | describe('and httpMethod is GET ', () => { 242 | it( 243 | 'Then cacheHit is dispatched, cleanUpPendingRequest is called ' + 244 | 'and actionType.REQUESTED are dispatched and nothing else is dispatched ', 245 | async () => { 246 | return testUseCachingWithRequestInCache() 247 | } 248 | ) 249 | }) 250 | 251 | describe('and httpMethod is POST ', () => { 252 | it( 253 | 'Then cacheHit is dispatched, cleanUpPendingRequest is called ' + 254 | 'and actionType.REQUESTED are dispatched and nothing else is dispatched ', 255 | async () => { 256 | return testUseCachingWithRequestInCache({ httpMethod: 'post' }) 257 | } 258 | ) 259 | }) 260 | 261 | describe('and httpMethod is PUT ', () => { 262 | it( 263 | 'Then cacheHit is dispatched, cleanUpPendingRequest is called ' + 264 | 'and actionType.REQUESTED are dispatched and nothing else is dispatched ', 265 | async () => { 266 | return testUseCachingWithRequestInCache({ httpMethod: 'put' }) 267 | } 268 | ) 269 | }) 270 | }) 271 | }) 272 | }) 273 | }) 274 | 275 | function testUseCachingWithRequestNotInCache({ 276 | expectedBody, 277 | httpMethod = 'get', 278 | } = {}) { 279 | expect(doAsync).toBeTruthy() 280 | expect(httpCache.addRequestToCache.mock).toBeTruthy() 281 | 282 | const url = 'url' 283 | const errorMessage = 'errorMessage' 284 | const noBusySpinner = 'noBusySpinner' 285 | const httpConfig = 'httpConfig' 286 | const useCaching = true 287 | const expectedAddRequestToCacheResult = 'expectedAddRequestToCacheResult' 288 | 289 | httpCache.tryToFindRequestInCache.mockReturnValue(false) 290 | 291 | httpCache.addRequestToCache.mockReturnValue(expectedAddRequestToCacheResult) 292 | 293 | return doAsync({ 294 | noBusySpinner, 295 | url, 296 | httpMethod, 297 | httpConfig, 298 | errorMessage, 299 | useCaching, 300 | dispatch, 301 | getState, 302 | }).then((r) => { 303 | expect(doAsyncLogic.requestIsAlreadyPending.mock.calls.length).toBe(1) 304 | 305 | expect(doAsyncLogic.requestIsAlreadyPending.mock.calls[0][0]).toEqual({ 306 | noBusySpinner, 307 | url, 308 | httpMethod, 309 | dispatch, 310 | httpConfig, 311 | getState, 312 | }) 313 | 314 | expect(dispatch.mock.calls.length).toBe(1) 315 | 316 | expect(dispatch.mock.calls[0][0]).toEqual('expectedAddRequestToCacheResult') 317 | 318 | const tempHttpConfig = { 319 | ...httpConfig, 320 | ...buildHeaders(url, httpConfig), 321 | } 322 | 323 | return { 324 | url, 325 | httpConfig: tempHttpConfig, 326 | } 327 | }) 328 | } 329 | 330 | async function testUseCachingWithRequestInCache({ httpMethod = 'get' } = {}) { 331 | expect(doAsync).toBeTruthy() 332 | expect(httpCache.addRequestToCache.mock).toBeTruthy() 333 | 334 | const url = 'url' 335 | const errorMessage = 'errorMessage' 336 | const noBusySpinner = 'noBusySpinner' 337 | const httpConfig = 'httpConfig' 338 | const useCaching = true 339 | const expectedBody = 'expectedBody' 340 | const expectedAddRequestToCacheResult = 'expectedAddRequestToCacheResult' 341 | 342 | const rejectWithValue = jest.fn() 343 | 344 | http.get.mockReturnValue(Promise.resolve(expectedBody)) 345 | 346 | httpCache.tryToFindRequestInCache.mockReturnValue(true) 347 | 348 | httpCache.addRequestToCache.mockReturnValue(expectedAddRequestToCacheResult) 349 | 350 | const result = await doAsync({ 351 | noBusySpinner, 352 | url, 353 | httpMethod, 354 | httpConfig, 355 | errorMessage, 356 | useCaching, 357 | dispatch, 358 | getState, 359 | rejectWithValue, 360 | }) 361 | expect(rejectWithValue.mock.calls.length).toBe(1) 362 | expect(rejectWithValue.mock.calls[0][0]).toBe('Request found in cache.') 363 | 364 | expect(doAsyncLogic.requestIsAlreadyPending.mock.calls.length).toBe(1) 365 | 366 | expect(doAsyncLogic.requestIsAlreadyPending.mock.calls[0][0]).toEqual({ 367 | noBusySpinner, 368 | url, 369 | httpMethod, 370 | dispatch, 371 | httpConfig, 372 | getState, 373 | }) 374 | 375 | expect(dispatch.mock.calls.length).toBe(1) 376 | 377 | expect(dispatch.mock.calls[0][0]).toEqual( 378 | cacheHit(url, httpMethod, noBusySpinner) 379 | ) 380 | 381 | return result 382 | } 383 | 384 | function testDoAsync({ expectedBody, httpMethod = 'get' } = {}) { 385 | expect(doAsync).toBeTruthy() 386 | 387 | const url = 'url' 388 | const errorMessage = 'errorMessage' 389 | const noBusySpinner = 'noBusySpinner' 390 | const httpConfig = 'httpConfig' 391 | const useCaching = false 392 | 393 | return doAsync({ 394 | noBusySpinner, 395 | url, 396 | httpMethod, 397 | httpConfig, 398 | errorMessage, 399 | useCaching, 400 | dispatch, 401 | getState, 402 | }).then((r) => { 403 | expect(doAsyncLogic.requestIsAlreadyPending.mock.calls.length).toBe(1) 404 | expect(doAsyncLogic.requestIsAlreadyPending.mock.calls[0][0]).toEqual({ 405 | noBusySpinner, 406 | url, 407 | httpMethod, 408 | dispatch, 409 | httpConfig, 410 | getState, 411 | }) 412 | 413 | const tempHttpConfig = { 414 | ...httpConfig, 415 | ...buildHeaders(url, httpConfig), 416 | } 417 | 418 | return { 419 | url, 420 | httpConfig: tempHttpConfig, 421 | httpMethod, 422 | errorMessage, 423 | } 424 | }) 425 | } 426 | -------------------------------------------------------------------------------- /webapp/src/infrastructure/doAsync/tests/doAsyncLogic.cleanUpPendingRequests.test.js: -------------------------------------------------------------------------------- 1 | import { cleanUpPendingRequests } from '../doAsyncLogic' 2 | import * as reactReduxMock from 'react-redux' 3 | import * as pendingRequests from '../../pendingRequest/pendingRequest.selectors' 4 | import { TURN_OFF_BUSY_INDICATOR_FOR_PENDING_ASYNC } from '../doAsync.actionTypes' 5 | import { deletePendingRequest } from '../../pendingRequest' 6 | import { decrementBusyIndicator } from '../../../widgets/busyIndicator' 7 | 8 | jest.mock('react-redux') 9 | jest.mock('../../pendingRequest/pendingRequest.selectors') 10 | 11 | let dispatch 12 | let getState 13 | 14 | describe('Given we call cleanUpPendingRequests with an actionType and dispatch ', () => { 15 | beforeEach(() => { 16 | dispatch = reactReduxMock.useDispatch() 17 | getState = reactReduxMock.useStore().getState 18 | }) 19 | 20 | afterEach(() => { 21 | dispatch.mockReset() 22 | getState.mockReset() 23 | 24 | pendingRequests.selectPendingRequest.mockReset() 25 | }) 26 | 27 | describe('When there are no pending requests ', () => { 28 | it('Then we return without call dispatch', () => { 29 | expect(pendingRequests.selectPendingRequest.mock).toBeTruthy() 30 | expect(dispatch.mock).toBeTruthy() 31 | expect(cleanUpPendingRequests).toBeTruthy() 32 | 33 | cleanUpPendingRequests({ dispatch, getState }) 34 | 35 | expect(dispatch.mock.calls.length).toBe(0) 36 | expect(pendingRequests.selectPendingRequest.mock.calls.length).toBe(1) 37 | }) 38 | }) 39 | 40 | describe('When there is a pending requests that require turning off busy spinner ', () => { 41 | it('Then pending request is called twice and we dispatch TURN_OFF_BUSY_INDICATOR_FOR_PENDING_ASYNC and deletePendingReqeust', () => { 42 | callPendingRequestAndThen( 43 | { turnSpinnerOff: true }, 44 | (dispatchMock, { url, httpMethod }) => { 45 | expect(dispatchMock.mock.calls.length).toBe(3) 46 | expect(dispatchMock.mock.calls[0][0]).toEqual({ 47 | type: TURN_OFF_BUSY_INDICATOR_FOR_PENDING_ASYNC, 48 | }) 49 | expect(dispatchMock.mock.calls[1][0]).toEqual( 50 | decrementBusyIndicator() 51 | ) 52 | 53 | expect(dispatchMock.mock.calls[2][0]).toEqual( 54 | deletePendingRequest({ url, httpMethod }) 55 | ) 56 | } 57 | ) 58 | }) 59 | }) 60 | 61 | describe('When there is a pending requests but NOT that require turning off busy spinner ', () => { 62 | it('Then pending request is called twice and we dispatch ONLY deletePendingReqeust', () => { 63 | callPendingRequestAndThen( 64 | { turnSpinnerOff: false }, 65 | (dispatchMock, { url, httpMethod }) => { 66 | expect(dispatchMock.mock.calls.length).toBe(1) 67 | expect(dispatchMock.mock.calls[0][0]).toEqual( 68 | deletePendingRequest({ url, httpMethod }) 69 | ) 70 | } 71 | ) 72 | }) 73 | }) 74 | }) 75 | 76 | function callPendingRequestAndThen({ turnSpinnerOff }, andThen) { 77 | expect(pendingRequests.selectPendingRequest.mock).toBeTruthy() 78 | expect(dispatch.mock).toBeTruthy() 79 | expect(cleanUpPendingRequests).toBeTruthy() 80 | 81 | pendingRequests.selectPendingRequest.mockReturnValue({ turnSpinnerOff }) 82 | 83 | const expectedState = 'expectedState' 84 | 85 | getState.mockReturnValue(expectedState) 86 | 87 | const url = 'expectedUrl' 88 | const httpMethod = 'expectedMethod' 89 | 90 | cleanUpPendingRequests({ url, httpMethod, dispatch, getState }) 91 | 92 | expect(pendingRequests.selectPendingRequest.mock.calls.length).toBe(2) 93 | expect(pendingRequests.selectPendingRequest.mock.calls[0][0]).toBe( 94 | expectedState 95 | ) 96 | expect(pendingRequests.selectPendingRequest.mock.calls[0][1]).toEqual({ 97 | url, 98 | httpMethod, 99 | }) 100 | expect(pendingRequests.selectPendingRequest.mock.calls[1][0]).toBe( 101 | expectedState 102 | ) 103 | expect(pendingRequests.selectPendingRequest.mock.calls[1][1]).toEqual({ 104 | url, 105 | httpMethod, 106 | }) 107 | 108 | andThen(dispatch, { url, httpMethod }) 109 | } 110 | -------------------------------------------------------------------------------- /webapp/src/infrastructure/doAsync/tests/doAsyncLogic.requestIsAlreadyPending.test.js: -------------------------------------------------------------------------------- 1 | import { requestIsAlreadyPending } from '../doAsyncLogic' 2 | import * as pendingRequestSelectors from '../../pendingRequest/pendingRequest.selectors' 3 | import * as reactReduxMock from 'react-redux' 4 | import { REQUEST_ALREADY_PENDING_ASYNC } from '../doAsync.actionTypes' 5 | import { incrementBusyIndicator } from '../../../widgets/busyIndicator' 6 | import { addPendingRequest, setBusySpinner } from '../../pendingRequest' 7 | 8 | jest.mock('react-redux') 9 | jest.mock('../../pendingRequest/pendingRequest.selectors') 10 | 11 | let dispatch 12 | let getState 13 | 14 | describe('Given we call requestIsAlreadyPending ', () => { 15 | beforeEach(() => { 16 | dispatch = reactReduxMock.useDispatch() 17 | getState = reactReduxMock.useStore().getState 18 | }) 19 | 20 | afterEach(() => { 21 | dispatch.mockReset() 22 | getState.mockReset() 23 | }) 24 | 25 | describe('When no request are pending ', () => { 26 | it("And there is noBusySpinner false Then we return false and don't call dispatch ", async () => { 27 | expect(requestIsAlreadyPending).toBeTruthy() 28 | expect(dispatch.mock).toBeTruthy() 29 | expect(pendingRequestSelectors.selectPendingRequest.mock).toBeTruthy() 30 | 31 | pendingRequestSelectors.selectPendingRequest.mockReturnValue(false) 32 | 33 | expect( 34 | requestIsAlreadyPending({ 35 | actionType: { REQUESTED: '' }, 36 | dispatch, 37 | getState, 38 | }) 39 | ).toEqual(false) 40 | 41 | expect(dispatch.mock.calls.length).toBe(0) 42 | }) 43 | 44 | it("And there is noBusySpinner true Then we return false and don't call dispatch ", async () => { 45 | expect(requestIsAlreadyPending).toBeTruthy() 46 | expect(dispatch.mock).toBeTruthy() 47 | expect(pendingRequestSelectors.selectPendingRequest.mock).toBeTruthy() 48 | 49 | const url = 'EXPECTED_URL' 50 | const httpMethod = 'EXPECTED_HTTP_METHOD' 51 | 52 | pendingRequestSelectors.selectPendingRequest.mockReturnValue(false) 53 | 54 | expect( 55 | requestIsAlreadyPending({ 56 | url, 57 | httpMethod, 58 | noBusySpinner: true, 59 | dispatch, 60 | getState, 61 | }) 62 | ).toEqual(false) 63 | 64 | expect(dispatch.mock.calls.length).toBe(1) 65 | expect(dispatch.mock.calls[0][0]).toEqual( 66 | addPendingRequest({ url, httpMethod }) 67 | ) 68 | }) 69 | }) 70 | 71 | describe('When a request is pending but noBusySpinner is passed in ', () => { 72 | it('Then we dispatch incrementBusyIndicator, dispatch setBusySpinner with busy spinner true and we dispatch REQUEST_ALREADY_PENDING_ASYNC and we return true ', async () => { 73 | testIt( 74 | { noBusySpinner: false }, 75 | ({ noBusySpinner, url, httpMethod, httpConfig }) => { 76 | expect(dispatch.mock.calls.length).toBe(3) 77 | expect(dispatch.mock.calls[0][0]).toEqual(incrementBusyIndicator()) 78 | expect(dispatch.mock.calls[1][0]).toEqual( 79 | setBusySpinner({ url, httpMethod, turnSpinnerOff: !noBusySpinner }) 80 | ) 81 | expect(dispatch.mock.calls[2][0]).toEqual({ 82 | type: REQUEST_ALREADY_PENDING_ASYNC, 83 | payload: { 84 | url, 85 | httpMethod, 86 | httpConfig, 87 | noBusySpinner, 88 | }, 89 | }) 90 | } 91 | ) 92 | }) 93 | }) 94 | 95 | describe('When a request is pending and noBusySpinner is passed in ', () => { 96 | it('Then we dispatch setBusySpinner with busy spinner true and we dispatch REQUEST_ALREADY_PENDING_ASYNC and we return true ', async () => { 97 | testIt( 98 | { noBusySpinner: true }, 99 | ({ actionType, noBusySpinner, url, httpMethod, httpConfig }) => { 100 | expect(dispatch.mock.calls.length).toBe(2) 101 | expect(dispatch.mock.calls[0][0]).toEqual( 102 | setBusySpinner({ url, httpMethod, turnSpinnerOff: !noBusySpinner }) 103 | ) 104 | expect(dispatch.mock.calls[1][0]).toEqual({ 105 | type: REQUEST_ALREADY_PENDING_ASYNC, 106 | payload: { 107 | url, 108 | httpMethod, 109 | httpConfig, 110 | noBusySpinner, 111 | }, 112 | }) 113 | } 114 | ) 115 | }) 116 | }) 117 | }) 118 | 119 | function testIt({ noBusySpinner }, andThen) { 120 | expect(requestIsAlreadyPending).toBeTruthy() 121 | expect(dispatch.mock).toBeTruthy() 122 | expect(pendingRequestSelectors.selectPendingRequest.mock).toBeTruthy() 123 | 124 | const actionType = { 125 | REQUESTED: 'TYPE_REQUESTED', 126 | } 127 | const url = 'expectedUrl' 128 | const httpMethod = 'httpMethod' 129 | const httpConfig = 'httpConfig' 130 | 131 | pendingRequestSelectors.selectPendingRequest.mockReturnValue(true) 132 | 133 | expect( 134 | requestIsAlreadyPending({ 135 | noBusySpinner, 136 | actionType, 137 | dispatch, 138 | url, 139 | httpMethod, 140 | httpConfig, 141 | getState, 142 | }) 143 | ).toEqual(true) 144 | 145 | andThen({ actionType, noBusySpinner, url, httpMethod, httpConfig }) 146 | } 147 | -------------------------------------------------------------------------------- /webapp/src/infrastructure/http/http.constants.js: -------------------------------------------------------------------------------- 1 | export const NOT_FOUND = 404 2 | export const BAD_REQUEST = 400 3 | 4 | // To have an prefix added to your API calls uncomment the line below 5 | // and set the prefix according you your needs. The setting below 6 | // will result in /api/your-endpoint-name being used to call the api 7 | // not we should move this to a config file and doucment it in the readme 8 | // export const API_URL_PREFIX = 'api' 9 | export const API_URL_PREFIX = undefined 10 | -------------------------------------------------------------------------------- /webapp/src/infrastructure/http/http.js: -------------------------------------------------------------------------------- 1 | import localStorage from 'localStorage' 2 | import { API_URL_PREFIX } from './http.constants' 3 | // import { getJwtToken } from "../../modules/userContext/userContext.selectors"; 4 | // import { getState } from "../store"; 5 | // import * as actionTypes from "../userContext/userContext.actionTypes"; 6 | 7 | export default { 8 | get, 9 | post, 10 | put, 11 | patch, 12 | delete: callDelete, 13 | } 14 | 15 | const ASYNC_DELAY = 2000 16 | 17 | function get(url, config, { stubSuccess, stubError } = {}) { 18 | return doFetch(url, config, { stubSuccess, stubError }) 19 | } 20 | 21 | function post(url, config, { stubSuccess, stubError } = {}) { 22 | config = { 23 | ...config, 24 | method: 'POST', 25 | } 26 | 27 | return doFetch(url, config, { stubSuccess, stubError }) 28 | } 29 | 30 | function put(url, config, { stubSuccess, stubError } = {}) { 31 | config = { 32 | ...config, 33 | method: 'PUT', 34 | } 35 | 36 | return doFetch(url, config, { stubSuccess, stubError }) 37 | } 38 | 39 | function patch(url, config, { stubSuccess, stubError } = {}) { 40 | config = { 41 | ...config, 42 | method: 'PATCH', 43 | } 44 | 45 | return doFetch(url, config, { stubSuccess, stubError }) 46 | } 47 | 48 | function callDelete(url, config, { stubSuccess, stubError } = {}) { 49 | config = { 50 | ...config, 51 | method: 'DELETE', 52 | } 53 | 54 | return doFetch(url, config, { stubSuccess, stubError }) 55 | } 56 | 57 | // If stubSuccess or stubError is defined then we will fake a successful call to the 58 | // server and return stubSuccess as the response. This allows for easily 59 | // faking calls during development when APIs aren't ready. A warning 60 | // will be written out for each stubbed response to help prevent forgetting 61 | // about the stubs. 62 | function doFetch(url, config, { stubSuccess, stubError } = {}) { 63 | if (!url) { 64 | throw new Error('You must specify a url') 65 | } 66 | 67 | if (process.env.NODE_ENV !== 'test') { 68 | if (stubSuccess) { 69 | return new Promise((resolve) => 70 | setTimeout(() => { 71 | console.warn(`Stubbed service call made to url: ${url}`) 72 | resolve(stubSuccess) 73 | }, ASYNC_DELAY) 74 | ) 75 | } 76 | 77 | if (stubError) { 78 | return new Promise((resolve, reject) => 79 | setTimeout(() => { 80 | console.warn(`Stubbed service error was returned from url: ${url}`) 81 | reject(stubError) 82 | }, ASYNC_DELAY) 83 | ) 84 | } 85 | } 86 | return fetch(buildUrl(url), addJwtToken(config)).then((response) => { 87 | if (response.headers) { 88 | const authHeader = response.headers.get('Authorization') 89 | 90 | setJwtTokenFromHeaderResponse(authHeader) 91 | // updateSessionToken(parseJwtTokenFromHeader(authHeader)); 92 | } 93 | 94 | if (response.ok) { 95 | if ( 96 | response.headers && 97 | response.headers.map && 98 | response.headers.map['content-type'].includes('stream') 99 | ) { 100 | return response 101 | } 102 | return response.json() 103 | } 104 | 105 | const unauthorized = 401 106 | if (response.status === unauthorized) { 107 | // All else failed so redirect user ot FMS to reauthenticate 108 | localStorage.removeItem('jwtToken') 109 | // response.json().then(() => redirectToSignOut()); 110 | } 111 | 112 | return Promise.reject(response) 113 | }) 114 | } 115 | 116 | function buildUrl(url) { 117 | return API_URL_PREFIX ? `${API_URL_PREFIX}/${url}` : url 118 | } 119 | 120 | function addJwtToken(config) { 121 | // TODO: Implement me 122 | // const jwtToken = getJwtToken(getState()); 123 | // if (!jwtToken || !config) { 124 | // return config; 125 | // } 126 | // 127 | // const authorization = `Bearer ${jwtToken}`; 128 | // return { 129 | // ...config, 130 | // headers: { 131 | // ...config.headers, 132 | // Authorization: authorization 133 | // } 134 | // }; 135 | return config 136 | } 137 | 138 | function setJwtTokenFromHeaderResponse(authorizationHeader) { 139 | const jwtToken = parseJwtTokenFromHeader(authorizationHeader) 140 | if (jwtToken) { 141 | localStorage.setItem('jwtToken', jwtToken) 142 | } else { 143 | localStorage.removeItem('jwtToken') 144 | } 145 | } 146 | 147 | function parseJwtTokenFromHeader(authorizationHeader) { 148 | if (!authorizationHeader) { 149 | return 150 | } 151 | const tokens = authorizationHeader.match(/\S+/g) 152 | 153 | // We are getting the second token because the first token will be Bearer. 154 | // EX: Bearer woeirweoirjw.... 155 | return tokens.length > 1 ? tokens[1] : null 156 | } 157 | 158 | // const updateSessionToken = token => dispatch => { 159 | // TODO: Do we need this? 160 | // dispatch({ 161 | // type: actionTypes.JWT_TOKEN_ASYNC.RECEIVED, 162 | // payload: token 163 | // }); 164 | // }; 165 | -------------------------------------------------------------------------------- /webapp/src/infrastructure/http/index.js: -------------------------------------------------------------------------------- 1 | import http from './http' 2 | import * as constants from './http.constants' 3 | 4 | export default http 5 | 6 | export { constants } 7 | -------------------------------------------------------------------------------- /webapp/src/infrastructure/http/tests/http.test.js: -------------------------------------------------------------------------------- 1 | import * as reactReduxMock from 'react-redux' 2 | import http from '../http' 3 | import mockFetch from '../../../infrastructure/test/mockFetch' 4 | 5 | jest.mock('react-redux') 6 | jest.mock('../http.constants', () => ({ 7 | API_URL: 'http://expectedHostName/api', 8 | })) 9 | 10 | let getState 11 | 12 | beforeEach(() => { 13 | getState = reactReduxMock.useStore().getState 14 | global.fetch = jest.fn() 15 | }) 16 | 17 | afterEach(() => { 18 | getState.mockReset() 19 | }) 20 | 21 | describe('When we call get on a resource with a config', () => { 22 | it('Then we get the expected result from server', (done) => { 23 | try { 24 | const calls = [] 25 | const expectedUrl = 'expectedUrl' 26 | const expectedResult = { result: 'expectedResult' } 27 | 28 | fetch.mockImplementation( 29 | mockFetch( 30 | [ 31 | { 32 | url: expectedUrl, 33 | response: () => expectedResult, 34 | }, 35 | ], 36 | { calls } 37 | ) 38 | ) 39 | 40 | getState.mockReturnValue({ 41 | userContext: {}, 42 | }) 43 | 44 | const expectedConfig = { sampleConfig: 'sample' } 45 | 46 | const p = http.get(expectedUrl, expectedConfig) 47 | 48 | setTimeout(() => { 49 | p.then((r) => { 50 | expect(calls.length).toBe(1) 51 | 52 | expect(calls[0].url).toEqual(`${expectedUrl}`) 53 | 54 | expect(calls[0].config).toEqual(expectedConfig) 55 | 56 | expect(r.result).toBe(expectedResult.result) 57 | }) 58 | .then(() => done()) 59 | .catch(done.fail) 60 | }) 61 | } catch (e) { 62 | done.fail(e) 63 | } 64 | }) 65 | 66 | it('When we get a failure response Then we get rejected promise with error', async () => { 67 | fetch.mockImplementation( 68 | mockFetch([ 69 | { 70 | url: 'url', 71 | errorResponse: true, 72 | response: () => ({ error: 'whoops' }), 73 | }, 74 | ]) 75 | ) 76 | getState.mockReturnValue({ 77 | userContext: {}, 78 | }) 79 | 80 | await http 81 | .get('url', { sampleConfig: 'sample' }) 82 | .then((r) => expect(r.result)) 83 | .catch((e) => e.json((r) => expect(r.error).toBe('whoops'))) 84 | }) 85 | }) 86 | 87 | describe('When we call post on a resource with a config', () => { 88 | it('Then we get the expected result from server and config.method set to POST', (done) => { 89 | callApiAndExpect('post', done) 90 | }) 91 | }) 92 | 93 | describe('When we call put on a resource with a config', () => { 94 | it('Then we get the expected result from server and config.method set to POST', (done) => { 95 | callApiAndExpect('put', done) 96 | }) 97 | }) 98 | 99 | function callApiAndExpect(httpMethod, done) { 100 | try { 101 | const calls = [] 102 | const expectedUrl = 'expectedUrl' 103 | const expectedResult = { result: 'expectedResult' } 104 | 105 | fetch.mockImplementation( 106 | mockFetch( 107 | [ 108 | { 109 | url: expectedUrl, 110 | method: httpMethod.toUpperCase(), 111 | response: () => expectedResult, 112 | }, 113 | ], 114 | { calls } 115 | ) 116 | ) 117 | 118 | getState.mockReturnValue({ 119 | userContext: {}, 120 | }) 121 | 122 | const expectedConfig = { sampleConfig: 'sample' } 123 | 124 | const p = http[httpMethod](expectedUrl, expectedConfig) 125 | 126 | setTimeout(() => { 127 | p.then((r) => { 128 | expect(calls.length).toBe(1) 129 | 130 | expect(calls[0].url).toEqual(`${expectedUrl}`) 131 | 132 | expect(calls[0].config).toEqual({ 133 | ...expectedConfig, 134 | method: httpMethod.toUpperCase(), 135 | }) 136 | 137 | expect(r.result).toBe(expectedResult.result) 138 | }) 139 | .then(() => done()) 140 | .catch(done.fail) 141 | }) 142 | } catch (e) { 143 | done.fail(e) 144 | } 145 | } 146 | -------------------------------------------------------------------------------- /webapp/src/infrastructure/httpCache/httpCache.selectors.js: -------------------------------------------------------------------------------- 1 | import isEqual from 'lodash/isEqual' 2 | import buildCacheKey from '../buildCacheKey' 3 | import slice from './httpCache.slice' 4 | 5 | export const selectSlice = (state) => state[slice.name] 6 | 7 | export const CACHE_TIMEOUT = 900000 8 | 9 | const isExpired = (item) => { 10 | const currentTime = Date.now() 11 | return currentTime - item.createdAt > CACHE_TIMEOUT 12 | } 13 | 14 | const getRequestCache = (state) => selectSlice(state) 15 | 16 | export const tryToFindRequestInCache = (state, url, httpMethod, body) => { 17 | const cacheKey = buildCacheKey({ url, httpMethod }) 18 | const item = getRequestCache(state)[cacheKey] 19 | 20 | if ( 21 | item && 22 | (httpMethod.toLowerCase() === 'post' || 23 | httpMethod.toLowerCase() === 'put') && 24 | body 25 | ) { 26 | if (!isEqual(item.body, body)) { 27 | return false 28 | } 29 | } 30 | 31 | if (!item) { 32 | return item 33 | } 34 | 35 | if (isExpired(item)) { 36 | // TODO: ryan - remove this as it's now mutating state 37 | // getRequestCache[cacheKey] = undefined; 38 | return false 39 | } 40 | 41 | return item 42 | } 43 | -------------------------------------------------------------------------------- /webapp/src/infrastructure/httpCache/httpCache.slice.js: -------------------------------------------------------------------------------- 1 | import { createSlice } from '@reduxjs/toolkit' 2 | import buildCacheKey from '../buildCacheKey' 3 | 4 | const initialState = {} 5 | 6 | const slice = createSlice({ 7 | name: 'httpCache', 8 | initialState, 9 | reducers: { 10 | addRequestToCache(state, action) { 11 | state[buildCacheKey(action.payload)] = { 12 | ...action.payload.config, 13 | createdAt: action.payload.createdAt, 14 | } 15 | }, 16 | deleteRequestFromCache(state, action) { 17 | if ( 18 | action.payload.url && 19 | action.payload.httpMethod && 20 | !action.payload.patterns 21 | ) { 22 | delete state[buildCacheKey(action.payload)] 23 | } else if ( 24 | !(action.payload.url && action.payload.httpMethod) && 25 | action.payload.patterns 26 | ) { 27 | for (const cacheKey in state) { 28 | if ( 29 | Object.prototype.hasOwnProperty.call(state, cacheKey) && 30 | action.payload.patterns.some((p) => p.test(cacheKey)) 31 | ) { 32 | delete state[cacheKey] 33 | } 34 | } 35 | } 36 | }, 37 | }, 38 | }) 39 | 40 | export default slice 41 | 42 | export const { name, actions, reducer } = slice 43 | -------------------------------------------------------------------------------- /webapp/src/infrastructure/httpCache/index.js: -------------------------------------------------------------------------------- 1 | import * as selectors from './httpCache.selectors' 2 | import slice from './httpCache.slice' 3 | 4 | export const { 5 | name, 6 | actions: { addRequestToCache, deleteRequestFromCache }, 7 | reducer, 8 | } = slice 9 | 10 | export const { 11 | isExpired, 12 | getRequestCache, 13 | tryToFindRequestInCache, 14 | CACHE_TIMEOUT, 15 | } = selectors 16 | -------------------------------------------------------------------------------- /webapp/src/infrastructure/httpCache/tests/httpCache.test.js: -------------------------------------------------------------------------------- 1 | import buildCacheKey from '../../buildCacheKey' 2 | import { 3 | reducer, 4 | addRequestToCache, 5 | deleteRequestFromCache, 6 | tryToFindRequestInCache, 7 | name, 8 | CACHE_TIMEOUT, 9 | } from '../index' 10 | 11 | beforeEach(() => { 12 | Date.now = jest.fn() 13 | }) 14 | 15 | afterEach(() => Date.now.mockRestore()) 16 | 17 | describe('Given we have no cached request', () => { 18 | it('When we add a new request Then its added to new state', () => { 19 | const expectedUrl = 'expectedUrl' 20 | const expectedMethod = 'expectedMethod' 21 | const createdAt = 'expectedExpirtationTime' 22 | 23 | Date.now.mockReturnValue(createdAt) 24 | 25 | const config = { url: expectedUrl, httpMethod: expectedMethod, createdAt } 26 | 27 | expect(reducer({}, addRequestToCache(config))).toEqual({ 28 | [buildCacheKey(config)]: { 29 | createdAt: createdAt, 30 | }, 31 | }) 32 | }) 33 | }) 34 | 35 | describe('Given we have cached request', () => { 36 | it('When we add a new request to the cache Then its added to new state', () => { 37 | const dummy1 = getDummyCacheData(1) 38 | const dummy2 = getDummyCacheData(2) 39 | const expected3 = getDummyCacheData(3) 40 | 41 | Date.now.mockReturnValue(expected3.createdAt) 42 | 43 | expect( 44 | reducer( 45 | { 46 | [buildCacheKey(dummy1)]: { 47 | createdAt: dummy1.createdAt, 48 | ...dummy1.config, 49 | }, 50 | [buildCacheKey(dummy2)]: { 51 | createdAt: dummy2.createdAt, 52 | ...dummy2.config, 53 | }, 54 | }, 55 | addRequestToCache(expected3) 56 | ) 57 | ).toEqual({ 58 | [buildCacheKey(dummy1)]: { 59 | createdAt: dummy1.createdAt, 60 | ...dummy1.config, 61 | }, 62 | [buildCacheKey(dummy2)]: { 63 | createdAt: dummy2.createdAt, 64 | ...dummy2.config, 65 | }, 66 | [buildCacheKey(expected3)]: { 67 | createdAt: expected3.createdAt, 68 | ...expected3.config, 69 | }, 70 | }) 71 | }) 72 | 73 | it('When we call deleteRequestFromCache and pass the url and httpMethod of a cached request Then its removed from new state', () => { 74 | const dummy1 = getDummyCacheData(1) 75 | const dummy2 = getDummyCacheData(2) 76 | const dummy3 = getDummyCacheData(3) 77 | 78 | expect( 79 | reducer( 80 | { 81 | [buildCacheKey(dummy1)]: { 82 | createdAt: dummy1.createdAt, 83 | ...dummy1.config, 84 | }, 85 | [buildCacheKey(dummy2)]: { 86 | createdAt: dummy2.createdAt, 87 | ...dummy2.config, 88 | }, 89 | [buildCacheKey(dummy3)]: { 90 | createdAt: dummy3.createdAt, 91 | ...dummy3.config, 92 | }, 93 | }, 94 | deleteRequestFromCache(dummy2) 95 | ) 96 | ).toEqual({ 97 | [buildCacheKey(dummy1)]: { 98 | createdAt: dummy1.createdAt, 99 | ...dummy1.config, 100 | }, 101 | [buildCacheKey(dummy3)]: { 102 | createdAt: dummy3.createdAt, 103 | ...dummy3.config, 104 | }, 105 | }) 106 | }) 107 | 108 | it('When we call deleteRequestFromCache and pass patterns with regexs to delete of multiple cached request Then all related request are removed from new state', () => { 109 | const dummy1 = getDummyCacheData(1) 110 | const dummy2 = getDummyCacheData(2) 111 | const dummy3 = getDummyCacheData(3) 112 | 113 | expect( 114 | reducer( 115 | { 116 | [buildCacheKey(dummy1)]: { 117 | createdAt: dummy1.createdAt, 118 | ...dummy1.config, 119 | }, 120 | [buildCacheKey(dummy2)]: { 121 | createdAt: dummy2.createdAt, 122 | ...dummy2.config, 123 | }, 124 | [buildCacheKey(dummy3)]: { 125 | createdAt: dummy3.createdAt, 126 | ...dummy3.config, 127 | }, 128 | }, 129 | deleteRequestFromCache({ patterns: [/1/, /2/] }) 130 | ) 131 | ).toEqual({ 132 | [buildCacheKey(dummy3)]: { 133 | createdAt: dummy3.createdAt, 134 | ...dummy3.config, 135 | }, 136 | }) 137 | }) 138 | }) 139 | 140 | describe('Given we have cached requested ', () => { 141 | it('Then calling tryToFindRequestInCache with a url and httpMethod that is in cache returns the correct request', () => { 142 | const dummy1 = getDummyCacheData(1) 143 | const dummy2 = getDummyCacheData(2) 144 | const dummy3 = getDummyCacheData(3) 145 | 146 | const cachedRequests = { 147 | [buildCacheKey(dummy1)]: { 148 | createdAt: dummy1.createdAt, 149 | ...dummy1.config, 150 | }, 151 | [buildCacheKey(dummy2)]: { 152 | createdAt: dummy2.createdAt, 153 | ...dummy2.config, 154 | }, 155 | [buildCacheKey(dummy3)]: { 156 | createdAt: dummy3.createdAt, 157 | ...dummy3.config, 158 | }, 159 | } 160 | 161 | expect( 162 | tryToFindRequestInCache( 163 | { [name]: cachedRequests }, 164 | dummy1.url, 165 | dummy1.httpMethod 166 | ) 167 | ).toEqual({ 168 | createdAt: dummy1.createdAt, 169 | ...dummy1.config, 170 | }) 171 | }) 172 | 173 | it('Then calling tryToFindRequestInCache with a url and httpMethod that is in cache whose TIME IS EXPIRED returns null', () => { 174 | const dummy1 = getDummyCacheData(1) 175 | const dummy2 = getDummyCacheData(2) 176 | 177 | let cachedRequests = { 178 | [buildCacheKey(dummy1)]: { 179 | createdAt: dummy1.createdAt, 180 | ...dummy1.config, 181 | }, 182 | [buildCacheKey(dummy2)]: { 183 | createdAt: dummy2.createdAt, 184 | ...dummy2.config, 185 | }, 186 | } 187 | 188 | const createdAt = 1532009640017 189 | 190 | const dummy3 = getDummyCacheData(3, createdAt) 191 | 192 | Date.now.mockReturnValueOnce(createdAt + CACHE_TIMEOUT + 1000) 193 | 194 | cachedRequests = reducer(cachedRequests, addRequestToCache(dummy3)) 195 | 196 | expect( 197 | tryToFindRequestInCache( 198 | { [name]: cachedRequests }, 199 | dummy3.url, 200 | dummy3.httpMethod 201 | ) 202 | ).toBe(false) 203 | }) 204 | 205 | describe('Then calling tryToFindRequestInCache with a url', () => { 206 | const tryAndGetPostOrPutFromCache = (dummy, body) => { 207 | const dummy1 = getDummyCacheData(1) 208 | const dummy2 = getDummyCacheData(2) 209 | 210 | let cachedRequests = { 211 | [buildCacheKey(dummy1)]: { 212 | createdAt: dummy1.createdAt, 213 | ...dummy1.config, 214 | }, 215 | [buildCacheKey(dummy2)]: { 216 | createdAt: dummy2.createdAt, 217 | ...dummy2.config, 218 | }, 219 | } 220 | 221 | Date.now.mockReturnValue(dummy.createdAt) 222 | 223 | cachedRequests = reducer(cachedRequests, addRequestToCache(dummy)) 224 | 225 | return tryToFindRequestInCache( 226 | { [name]: cachedRequests }, 227 | dummy.url, 228 | dummy.httpMethod, 229 | body || dummy.config.body 230 | ) 231 | } 232 | 233 | it(' and httpMethod put and body that is in cache returns the correct request', () => { 234 | const dummy = getDummyCacheData(3) 235 | dummy.httpMethod = 'put' 236 | dummy.config.body = { 237 | foo: 'bar', 238 | } 239 | 240 | expect(tryAndGetPostOrPutFromCache(dummy)).toEqual({ 241 | createdAt: dummy.createdAt, 242 | ...dummy.config, 243 | }) 244 | }) 245 | 246 | it(' and httpMethod post and body that is in cache returns the correct request', () => { 247 | const dummy = getDummyCacheData(3) 248 | dummy.httpMethod = 'post' 249 | dummy.config.body = { 250 | foo: 'bar', 251 | } 252 | 253 | expect(tryAndGetPostOrPutFromCache(dummy)).toEqual({ 254 | createdAt: dummy.createdAt, 255 | ...dummy.config, 256 | }) 257 | }) 258 | 259 | it(' and httpMethod put and body that is NOT in cache returns undefined', () => { 260 | const dummy = getDummyCacheData(3) 261 | dummy.httpMethod = 'put' 262 | dummy.config.body = { 263 | foo: 'bar', 264 | } 265 | 266 | expect( 267 | tryAndGetPostOrPutFromCache(dummy, { 268 | foo: 'baz', 269 | }) 270 | ).toBe(false) 271 | }) 272 | 273 | it(' and httpMethod post and body that is NOT in cache returns undefined', () => { 274 | const dummy = getDummyCacheData(3) 275 | dummy.httpMethod = 'post' 276 | dummy.config.body = { 277 | foo: 'bar', 278 | } 279 | 280 | expect( 281 | tryAndGetPostOrPutFromCache(dummy, { 282 | foo: 'baz', 283 | }) 284 | ).toBe(false) 285 | }) 286 | }) 287 | 288 | it('Then calling getRequest with an actionType that is not pending returns undefined', () => { 289 | const dummy1 = getDummyCacheData(1) 290 | const dummy2 = getDummyCacheData(2) 291 | const dummy3 = getDummyCacheData(3) 292 | 293 | const cachedRequests = { 294 | [buildCacheKey(dummy1)]: { 295 | createdAt: dummy1.createdAt, 296 | ...dummy1.config, 297 | }, 298 | [buildCacheKey(dummy2)]: { 299 | createdAt: dummy2.createdAt, 300 | ...dummy2.config, 301 | }, 302 | [buildCacheKey(dummy3)]: { 303 | createdAt: dummy3.createdAt, 304 | ...dummy3.config, 305 | }, 306 | } 307 | 308 | expect( 309 | tryToFindRequestInCache( 310 | { [name]: cachedRequests }, 311 | 'notInCacheUrl', 312 | 'notInCacheMethod' 313 | ) 314 | ).toBe(undefined) 315 | }) 316 | }) 317 | 318 | function getDummyCacheData(index, createdAt) { 319 | const expectedUrl = 'expectedUrl' + index 320 | const expectedMethod = 'expectedMethod' + index 321 | createdAt = createdAt || 'expectedExpirtationTime' + index 322 | const config = { 323 | foo: 'foo' + index, 324 | } 325 | 326 | return { 327 | url: expectedUrl, 328 | httpMethod: expectedMethod, 329 | createdAt, 330 | config, 331 | } 332 | } 333 | -------------------------------------------------------------------------------- /webapp/src/infrastructure/notificationPopup/NotificationPopup.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { useDispatch, useSelector } from 'react-redux' 3 | import Toast from 'react-bootstrap/Toast' 4 | import { actions } from './notificationPopup.slice' 5 | import { selectNotification } from './notificationPopup.selectors' 6 | 7 | const { closePopup } = actions 8 | 9 | export default function NotificationPopupContainer() { 10 | const { errorMessage, successMessage, title } = useSelector( 11 | selectNotification 12 | ) 13 | const dispatch = useDispatch() 14 | const message = errorMessage || successMessage 15 | 16 | return ( 17 | <> 18 | {message && ( 19 | dispatch(closePopup())} 23 | > 24 | 25 | 26 | {title || (errorMessage ? 'Error' : 'Status')} 27 | 28 | 29 | {message} 30 | 31 | )} 32 | 33 | ) 34 | } 35 | -------------------------------------------------------------------------------- /webapp/src/infrastructure/notificationPopup/__mocks__/notificationPopup.actions.js: -------------------------------------------------------------------------------- 1 | import { failTest } from '../../../test/common.utils' 2 | import { NOTIFY_SUCCESS, RESET } from '../notificationPopup.actionTypes' 3 | 4 | export const notifyError = ( 5 | type, 6 | { errorMessage, message, stack, componentStack } 7 | ) => { 8 | failTest( 9 | `notifyError was called! 10 | type: ${type} 11 | errorMessage: ${errorMessage} 12 | message: ${message} 13 | stack: ${stack} 14 | componentStack: ${componentStack}` 15 | ) 16 | } 17 | 18 | export const notifySuccess = (successMessage, { title, config } = {}) => { 19 | return { 20 | type: NOTIFY_SUCCESS, 21 | payload: { 22 | successMessage, 23 | config: { 24 | ...config, 25 | title, 26 | }, 27 | }, 28 | } 29 | } 30 | 31 | export const resetError = () => ({ 32 | type: RESET, 33 | }) 34 | -------------------------------------------------------------------------------- /webapp/src/infrastructure/notificationPopup/index.js: -------------------------------------------------------------------------------- 1 | import NotificationPopup from './NotificationPopup' 2 | import * as notificationPopupSelectors from './notificationPopup.selectors' 3 | import slice from './notificationPopup.slice' 4 | 5 | export const { 6 | name, 7 | actions: { notifyError, notifySuccess, resetError, closePopup }, 8 | reducer, 9 | } = slice 10 | 11 | export const { 12 | selectNotification: getNotification, 13 | } = notificationPopupSelectors 14 | 15 | export default NotificationPopup 16 | -------------------------------------------------------------------------------- /webapp/src/infrastructure/notificationPopup/notificationPopup.selectors.js: -------------------------------------------------------------------------------- 1 | import slice from './notificationPopup.slice' 2 | 3 | export const selectSlice = (state) => state[slice.name] 4 | 5 | export const selectNotification = (state) => selectSlice(state) 6 | -------------------------------------------------------------------------------- /webapp/src/infrastructure/notificationPopup/notificationPopup.slice.js: -------------------------------------------------------------------------------- 1 | import { createSlice } from '@reduxjs/toolkit' 2 | 3 | const initialState = {} 4 | 5 | const slice = createSlice({ 6 | name: 'notificationPopup', 7 | initialState, 8 | reducers: { 9 | notifyError(state, action) { 10 | state.errorMessage = action.payload 11 | }, 12 | notifySuccess(state, action) { 13 | state.successMessage = action.payload 14 | }, 15 | resetError(state) { 16 | state.errorMessage = undefined 17 | state.successMessage = undefined 18 | }, 19 | closePopup(state) { 20 | state.errorMessage = undefined 21 | state.successMessage = undefined 22 | }, 23 | }, 24 | }) 25 | 26 | export default slice 27 | 28 | export const { name, actions, reducer } = slice 29 | -------------------------------------------------------------------------------- /webapp/src/infrastructure/pendingRequest/index.js: -------------------------------------------------------------------------------- 1 | import * as pendingRequestSelectors from './pendingRequest.selectors' 2 | import slice from './pendingRequest.slice' 3 | 4 | export const { 5 | name, 6 | actions: { addPendingRequest, setBusySpinner, deletePendingRequest }, 7 | reducer, 8 | } = slice 9 | 10 | export const { selectPendingRequest } = pendingRequestSelectors 11 | -------------------------------------------------------------------------------- /webapp/src/infrastructure/pendingRequest/pendingRequest.reducer.js: -------------------------------------------------------------------------------- 1 | import * as types from './pendingRequest.actionTypes' 2 | import buildCacheKey from '../buildCacheKey' 3 | 4 | const intialState = {} 5 | 6 | export default function reducer(state = intialState, action) { 7 | switch (action.type) { 8 | case types.ADD: { 9 | const newState = { 10 | ...state, 11 | } 12 | 13 | newState[buildCacheKey(action.payload)] = { 14 | turnSpinnerOff: false, 15 | } 16 | 17 | return newState 18 | } 19 | 20 | case types.DELETE: { 21 | const newState = { 22 | ...state, 23 | } 24 | 25 | delete newState[buildCacheKey(action.payload)] 26 | 27 | return newState 28 | } 29 | 30 | case types.SET_BUSY_SPINNER: { 31 | const { turnSpinnerOff } = action.payload 32 | const newState = { 33 | ...state, 34 | } 35 | 36 | newState[buildCacheKey(action.payload)] = { 37 | ...newState[buildCacheKey(action.payload)], 38 | turnSpinnerOff, 39 | } 40 | 41 | return newState 42 | } 43 | 44 | default: { 45 | return state 46 | } 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /webapp/src/infrastructure/pendingRequest/pendingRequest.selectors.js: -------------------------------------------------------------------------------- 1 | import slice from './pendingRequest.slice' 2 | import buildCacheKey from '../buildCacheKey' 3 | 4 | export const selectSlice = (state) => state[slice.name] 5 | 6 | export const selectPendingRequest = (state, { url, httpMethod }) => 7 | selectSlice(state)[buildCacheKey({ url, httpMethod })] 8 | -------------------------------------------------------------------------------- /webapp/src/infrastructure/pendingRequest/pendingRequest.slice.js: -------------------------------------------------------------------------------- 1 | import { createSlice } from '@reduxjs/toolkit' 2 | import buildCacheKey from '../buildCacheKey' 3 | 4 | const initialState = {} 5 | 6 | const slice = createSlice({ 7 | name: 'pendingReqeust', 8 | initialState, 9 | reducers: { 10 | addPendingRequest(state, action) { 11 | state[buildCacheKey(action.payload)] = { 12 | turnSpinnerOff: false, 13 | } 14 | }, 15 | deletePendingRequest(state, action) { 16 | delete state[buildCacheKey(action.payload)] 17 | }, 18 | setBusySpinner(state, action) { 19 | const { turnSpinnerOff } = action.payload 20 | 21 | state[buildCacheKey(action.payload)].turnSpinnerOff = turnSpinnerOff 22 | }, 23 | }, 24 | }) 25 | 26 | export default slice 27 | 28 | export const { name, actions, reducer } = slice 29 | -------------------------------------------------------------------------------- /webapp/src/infrastructure/pendingRequest/tests/pendingReqeust.test.js: -------------------------------------------------------------------------------- 1 | import { 2 | reducer, 3 | selectPendingRequest, 4 | addPendingRequest, 5 | deletePendingRequest, 6 | name, 7 | } from '../../../infrastructure/pendingRequest' 8 | import buildCacheKey from '../../../infrastructure/buildCacheKey' 9 | 10 | const buildDummnyCacheKey = (name = '') => { 11 | const url = `expectedUrl${name}` 12 | const httpMethod = `expectedMethod${name}` 13 | return { 14 | url, 15 | httpMethod, 16 | key: buildCacheKey({ url, httpMethod }), 17 | } 18 | } 19 | 20 | describe('Given we have no pending request', () => { 21 | it('When we add a new pending request Then its added to new state', () => { 22 | const cacheKey = buildDummnyCacheKey() 23 | expect(reducer({}, addPendingRequest(cacheKey))).toEqual({ 24 | [cacheKey.key]: { 25 | turnSpinnerOff: false, 26 | }, 27 | }) 28 | }) 29 | }) 30 | 31 | describe('Given we have pending request', () => { 32 | it('When we add a new pending request Then its added to new state', () => { 33 | const expected = buildDummnyCacheKey('expected') 34 | const existing = buildDummnyCacheKey('existing') 35 | 36 | expect( 37 | reducer( 38 | { 39 | [existing.key]: { 40 | turnSpinnerOff: false, 41 | }, 42 | }, 43 | addPendingRequest(expected) 44 | ) 45 | ).toEqual({ 46 | [existing.key]: { 47 | turnSpinnerOff: false, 48 | }, 49 | [expected.key]: { 50 | turnSpinnerOff: false, 51 | }, 52 | }) 53 | }) 54 | 55 | it('When we delete an existing pending request Then its removed from new state', () => { 56 | const existing = buildDummnyCacheKey('existing') 57 | 58 | expect( 59 | reducer( 60 | { 61 | [existing.key]: { 62 | turnSpinnerOff: false, 63 | }, 64 | }, 65 | deletePendingRequest(existing) 66 | ) 67 | ).toEqual({}) 68 | }) 69 | }) 70 | 71 | describe('Given we have pending requested ', () => { 72 | it('Then calling getRequest with an actionType that is pending returns the correct request', () => { 73 | const existing1 = buildDummnyCacheKey('1') 74 | const existing2 = buildDummnyCacheKey('2') 75 | 76 | const pendingRequest = { 77 | [existing1.key]: { 78 | turnSpinnerOff: true, 79 | }, 80 | [existing2.key]: { 81 | turnSpinnerOff: false, 82 | }, 83 | } 84 | 85 | expect(selectPendingRequest({ [name]: pendingRequest }, existing1)).toEqual( 86 | { 87 | turnSpinnerOff: true, 88 | } 89 | ) 90 | }) 91 | it('Then calling getRequest with an actionType that is not pending returns undefined', () => { 92 | const existing1 = buildDummnyCacheKey('1') 93 | const existing2 = buildDummnyCacheKey('2') 94 | 95 | const pendingRequest = { 96 | [existing1.key]: { 97 | turnSpinnerOff: true, 98 | }, 99 | [existing2.key]: { 100 | turnSpinnerOff: false, 101 | }, 102 | } 103 | 104 | expect( 105 | selectPendingRequest({ [name]: pendingRequest }, 'notPendingActionType') 106 | ).toBe(undefined) 107 | }) 108 | }) 109 | -------------------------------------------------------------------------------- /webapp/src/infrastructure/reduxHelpers.js: -------------------------------------------------------------------------------- 1 | import { ACTION_TYPE_PREFIX } from '../config' 2 | 3 | export const mergeTypes = { UNION: 'UNION', INTERSECT: 'INTERSECT' } 4 | 5 | export const buildAsyncActionType = (module, actionType) => { 6 | const asyncStates = ['REQUESTED', 'RECEIVED', 'ERROR'] 7 | 8 | return asyncStates.reduce((accumulator, asyncState) => { 9 | accumulator[asyncState] = `${buildActionType( 10 | module, 11 | actionType 12 | )}_${asyncState}_ASYNC` 13 | return accumulator 14 | }, {}) 15 | } 16 | 17 | export const buildActionType = (module, actionType) => 18 | `${ACTION_TYPE_PREFIX}/${module}/${actionType}` 19 | 20 | export const mergeCollections = ( 21 | elements, 22 | newElements, 23 | idProperty = 'id', 24 | mergeType = mergeTypes.INTERSECT 25 | ) => { 26 | newElements = wrapInArrayIfNeeded(newElements) 27 | 28 | return mergeType === mergeTypes.INTERSECT 29 | ? intersectCollections(elements, newElements, idProperty) 30 | : unionCollections(elements, newElements, idProperty) 31 | } 32 | 33 | const unionCollections = (elements, newElements, idProperty) => { 34 | newElements = wrapInArrayIfNeeded(newElements) 35 | 36 | return [...elements, ...newElements].reduce((acc, cur) => { 37 | const foundIndex = acc.findIndex((a) => a[idProperty] === cur[idProperty]) 38 | 39 | const NOT_FOUND = -1 40 | 41 | if (foundIndex === NOT_FOUND) { 42 | acc.push(cur) 43 | return acc 44 | } 45 | 46 | acc[foundIndex] = { ...acc[foundIndex], ...cur } 47 | 48 | return acc 49 | }, []) 50 | } 51 | 52 | const intersectCollections = (elements, newElements, idProperty) => { 53 | newElements = wrapInArrayIfNeeded(newElements) 54 | 55 | return [...elements, ...newElements].reduce((acc, cur) => { 56 | const foundIndex = acc.findIndex((a) => a[idProperty] === cur[idProperty]) 57 | 58 | const NOT_FOUND = -1 59 | 60 | if (foundIndex === NOT_FOUND) { 61 | if ( 62 | newElements.map((newEl) => newEl[idProperty]).includes(cur[idProperty]) 63 | ) { 64 | acc.push(cur) 65 | } 66 | return acc 67 | } 68 | 69 | acc[foundIndex] = { ...acc[foundIndex], ...cur } 70 | 71 | return acc 72 | }, []) 73 | } 74 | 75 | function wrapInArrayIfNeeded(elements) { 76 | if (elements && !elements.length) { 77 | return [elements] 78 | } 79 | 80 | return elements 81 | } 82 | -------------------------------------------------------------------------------- /webapp/src/infrastructure/test/mockFetch.js: -------------------------------------------------------------------------------- 1 | import _ from 'lodash' 2 | 3 | const buildFetchErrorResponse = (data, status) => 4 | Promise.resolve({ 5 | ok: false, 6 | status: status || 400, 7 | headers: { 8 | get: () => null, 9 | map: { 10 | 'content-type': '', 11 | }, 12 | }, 13 | json: () => Promise.resolve(data), 14 | }) 15 | 16 | const buildFetchResponse = (data) => 17 | Promise.resolve({ 18 | ok: {}, 19 | headers: { 20 | get: () => null, 21 | map: { 22 | 'content-type': '', 23 | }, 24 | }, 25 | json: () => Promise.resolve(data), 26 | }) 27 | 28 | const mockFetch = (stubConfig, { calls } = {}) => { 29 | stubConfig.sort((a, b) => { 30 | const aIsArray = !!a.reduce 31 | const bIsArray = !!b.reduce 32 | 33 | if (!aIsArray === bIsArray) { 34 | return 0 35 | } 36 | 37 | if (aIsArray) { 38 | return -1 39 | } 40 | 41 | return 1 42 | }) 43 | 44 | if (!stubConfig) { 45 | throw new Error('stubConfig is required!') 46 | } 47 | 48 | const NOT_FOUND = -1 49 | if ( 50 | !stubConfig.length || 51 | stubConfig.findIndex( 52 | (c) => 53 | !c.url || 54 | (!c.response && typeof c.response === 'function') || 55 | (!c.errorResponse && typeof c.errorResponse === 'function') 56 | ) > NOT_FOUND 57 | ) { 58 | throw new Error( 59 | 'You must specify at least one config as part of the stubConfig argument and each config must have a valid format. Example: [{url:"/foo", response: fooResponseGetter}]' 60 | ) 61 | } 62 | 63 | if (calls) { 64 | calls.get = function (url, method) { 65 | return this.find( 66 | (c) => 67 | ((!c.config.method && (!method || method.toLowerCase() === 'get')) || 68 | (c.config.method && 69 | c.config.method.toLowerCase() === method.toLowerCase())) && 70 | c.url.includes(url) 71 | ) 72 | } 73 | 74 | calls.expect = function (url, method) { 75 | expect(!!this.get(url, method)).toBeTruthy() 76 | } 77 | 78 | calls.getBody = function (url, method) { 79 | const call = this.get(url, method) 80 | 81 | const body = _.get(call, 'config.body') 82 | 83 | return body && JSON.parse(body) 84 | } 85 | } 86 | 87 | return (url, config) => { 88 | const isGetOrDefault = (stubUrlConfig, curConfig) => 89 | (!curConfig.method || curConfig.method.toLocaleString() === 'get') && 90 | (!stubUrlConfig.method || stubUrlConfig.method.toLowerCase() === 'get') 91 | 92 | const stubMethodMatchesConfig = (stubUrlConfig) => 93 | stubUrlConfig.method && 94 | config.method && 95 | config.method.toLowerCase() === stubUrlConfig.method.toLowerCase() 96 | 97 | const methodIsGetOrMethodsMatch = (stubUrlConfig, curConfig) => 98 | isGetOrDefault(stubUrlConfig, curConfig) || 99 | stubMethodMatchesConfig(stubUrlConfig) 100 | 101 | const urlIncludesFragments = (url, fragments) => { 102 | if (fragments.reduce) { 103 | return fragments.reduce((acc, cur) => { 104 | if (!acc) { 105 | return acc 106 | } 107 | return url.includes(cur) 108 | }, true) 109 | } 110 | 111 | return url.includes(fragments) 112 | } 113 | 114 | const foundUrl = stubConfig.find( 115 | (stubUrlConfig) => 116 | methodIsGetOrMethodsMatch(stubUrlConfig, config) && 117 | urlIncludesFragments(url, stubUrlConfig.url) 118 | ) 119 | 120 | if (foundUrl) { 121 | if (calls && calls.length !== undefined) { 122 | calls.push({ 123 | url, 124 | config, 125 | }) 126 | } 127 | 128 | if (foundUrl.errorResponse) { 129 | return buildFetchErrorResponse(foundUrl.response(), foundUrl.statusCode) 130 | } else { 131 | return buildFetchResponse(foundUrl.response()) 132 | } 133 | } 134 | 135 | throw new Error( 136 | `There was an unexpected ajax call. Details follow... 137 | url: ${url} 138 | method: ${config.method || 'GET'} 139 | config: ${JSON.stringify(config)}. 140 | stubConfig: ${JSON.stringify(stubConfig)}` 141 | ) 142 | } 143 | } 144 | 145 | export default mockFetch 146 | -------------------------------------------------------------------------------- /webapp/src/infrastructure/useAsync.js: -------------------------------------------------------------------------------- 1 | import { useDispatch, useStore } from 'react-redux' 2 | import { useEffect } from 'react' 3 | import doAsync from './doAsync' 4 | 5 | const defaultHttpMethod = 'get' 6 | 7 | export default function useAsync({ 8 | actionType, 9 | url, 10 | httpMethod = defaultHttpMethod, 11 | mapResponseToPayload, 12 | errorMessage, 13 | httpConfig, 14 | onError, 15 | successMessage, 16 | noBusySpinner, 17 | useCaching = false, 18 | stubSuccess, 19 | stubError, 20 | dependencies = [], 21 | } = {}) { 22 | if ( 23 | dependencies && 24 | (dependencies.length === undefined || dependencies.length === null) 25 | ) { 26 | throw new Error('dependencies must be an array.') 27 | } 28 | 29 | const dispatch = useDispatch() 30 | const { getState } = useStore() 31 | 32 | useEffect(() => { 33 | doAsync({ 34 | dispatch, 35 | getState, 36 | actionType, 37 | url, 38 | httpMethod, 39 | mapResponseToPayload, 40 | errorMessage, 41 | httpConfig, 42 | onError, 43 | successMessage, 44 | noBusySpinner, 45 | useCaching, 46 | stubSuccess, 47 | stubError, 48 | }) 49 | 50 | // This was added because the of two issues 51 | // 1) including dummyResponse and dummyError below makes 52 | // using those from calling code really awkward as you 53 | // have to make sure that their pointers doing change or 54 | // you get endless loops. I lost have a day chasing that down. 55 | // 2) it complains when you spred out dependencies but there's really 56 | // no other way I can think to pass the dependencies in in a way that 57 | // doesn't cause some kind of warning 58 | // eslint-disable-next-line react-hooks/exhaustive-deps 59 | }, [ 60 | dispatch, 61 | getState, 62 | actionType, 63 | url, 64 | httpMethod, 65 | mapResponseToPayload, 66 | errorMessage, 67 | httpConfig, 68 | onError, 69 | successMessage, 70 | noBusySpinner, 71 | useCaching, 72 | // NOTE: These were left here so that we would know not to add these back. 73 | // If we add these to the depedency list then they have to be the same pointers passed 74 | // in each time which makes the useEffect api awkward for developers to work with. Leaving these 75 | // breaks the idiomatic rules of Redux a bit but in a valuable and safe way Please don't remove 76 | // this comment or change the commented out stubSucces and stubError without discussing with Ryan first 77 | // stubSuccess, 78 | // stubError, 79 | // eslint-disable-next-line react-hooks/exhaustive-deps 80 | ...dependencies, 81 | ]) 82 | } 83 | -------------------------------------------------------------------------------- /webapp/src/rootReducer.js: -------------------------------------------------------------------------------- 1 | import { combineReducers } from 'redux' 2 | import * as busyIndicator from './widgets/busyIndicator' 3 | import * as modal from './widgets/modal' 4 | import * as pendingRequest from './infrastructure/pendingRequest' 5 | import * as notificationPopup from './infrastructure/notificationPopup' 6 | import * as users from './features/users' 7 | import * as httpCache from './infrastructure/httpCache' 8 | import * as settings from './features/settings' 9 | import * as userContext from './features/userContext' 10 | 11 | export default combineReducers({ 12 | [busyIndicator.name]: busyIndicator.reducer, 13 | [modal.name]: modal.reducer, 14 | [pendingRequest.name]: pendingRequest.reducer, 15 | [notificationPopup.name]: notificationPopup.reducer, 16 | [httpCache.name]: httpCache.reducer, 17 | [users.name]: users.reducer, 18 | [settings.name]: settings.reducer, 19 | [userContext.name]: userContext.reducer, 20 | }) 21 | -------------------------------------------------------------------------------- /webapp/src/serviceWorker.js: -------------------------------------------------------------------------------- 1 | // This optional code is used to register a service worker. 2 | // register() is not called by default. 3 | 4 | // This lets the app load faster on subsequent visits in production, and gives 5 | // it offline capabilities. However, it also means that developers (and users) 6 | // will only see deployed updates on subsequent visits to a page, after all the 7 | // existing tabs open on the page have been closed, since previously cached 8 | // resources are updated in the background. 9 | 10 | // To learn more about the benefits of this model and instructions on how to 11 | // opt-in, read https://bit.ly/CRA-PWA 12 | 13 | const isLocalhost = Boolean( 14 | window.location.hostname === 'localhost' || 15 | // [::1] is the IPv6 localhost address. 16 | window.location.hostname === '[::1]' || 17 | // 127.0.0.0/8 are considered localhost for IPv4. 18 | window.location.hostname.match( 19 | /^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/ 20 | ) 21 | ) 22 | 23 | export function register(config) { 24 | if (process.env.NODE_ENV === 'production' && 'serviceWorker' in navigator) { 25 | // The URL constructor is available in all browsers that support SW. 26 | const publicUrl = new URL(process.env.PUBLIC_URL, window.location.href) 27 | if (publicUrl.origin !== window.location.origin) { 28 | // Our service worker won't work if PUBLIC_URL is on a different origin 29 | // from what our page is served on. This might happen if a CDN is used to 30 | // serve assets; see https://github.com/facebook/create-react-app/issues/2374 31 | return 32 | } 33 | 34 | window.addEventListener('load', () => { 35 | const swUrl = `${process.env.PUBLIC_URL}/service-worker.js` 36 | 37 | if (isLocalhost) { 38 | // This is running on localhost. Let's check if a service worker still exists or not. 39 | checkValidServiceWorker(swUrl, config) 40 | 41 | // Add some additional logging to localhost, pointing developers to the 42 | // service worker/PWA documentation. 43 | navigator.serviceWorker.ready.then(() => { 44 | console.log( 45 | 'This web app is being served cache-first by a service ' + 46 | 'worker. To learn more, visit https://bit.ly/CRA-PWA' 47 | ) 48 | }) 49 | } else { 50 | // Is not localhost. Just register service worker 51 | registerValidSW(swUrl, config) 52 | } 53 | }) 54 | } 55 | } 56 | 57 | function registerValidSW(swUrl, config) { 58 | navigator.serviceWorker 59 | .register(swUrl) 60 | .then((registration) => { 61 | registration.onupdatefound = () => { 62 | const installingWorker = registration.installing 63 | if (installingWorker == null) { 64 | return 65 | } 66 | installingWorker.onstatechange = () => { 67 | if (installingWorker.state === 'installed') { 68 | if (navigator.serviceWorker.controller) { 69 | // At this point, the updated precached content has been fetched, 70 | // but the previous service worker will still serve the older 71 | // content until all client tabs are closed. 72 | console.log( 73 | 'New content is available and will be used when all ' + 74 | 'tabs for this page are closed. See https://bit.ly/CRA-PWA.' 75 | ) 76 | 77 | // Execute callback 78 | if (config && config.onUpdate) { 79 | config.onUpdate(registration) 80 | } 81 | } else { 82 | // At this point, everything has been precached. 83 | // It's the perfect time to display a 84 | // "Content is cached for offline use." message. 85 | console.log('Content is cached for offline use.') 86 | 87 | // Execute callback 88 | if (config && config.onSuccess) { 89 | config.onSuccess(registration) 90 | } 91 | } 92 | } 93 | } 94 | } 95 | }) 96 | .catch((error) => { 97 | console.error('Error during service worker registration:', error) 98 | }) 99 | } 100 | 101 | function checkValidServiceWorker(swUrl, config) { 102 | // Check if the service worker can be found. If it can't reload the page. 103 | fetch(swUrl, { 104 | headers: { 'Service-Worker': 'script' }, 105 | }) 106 | .then((response) => { 107 | // Ensure service worker exists, and that we really are getting a JS file. 108 | const contentType = response.headers.get('content-type') 109 | if ( 110 | response.status === 404 || 111 | (contentType != null && contentType.indexOf('javascript') === -1) 112 | ) { 113 | // No service worker found. Probably a different app. Reload the page. 114 | navigator.serviceWorker.ready.then((registration) => { 115 | registration.unregister().then(() => { 116 | window.location.reload() 117 | }) 118 | }) 119 | } else { 120 | // Service worker found. Proceed as normal. 121 | registerValidSW(swUrl, config) 122 | } 123 | }) 124 | .catch(() => { 125 | console.log( 126 | 'No internet connection found. App is running in offline mode.' 127 | ) 128 | }) 129 | } 130 | 131 | export function unregister() { 132 | if ('serviceWorker' in navigator) { 133 | navigator.serviceWorker.ready 134 | .then((registration) => { 135 | registration.unregister() 136 | }) 137 | .catch((error) => { 138 | console.error(error.message) 139 | }) 140 | } 141 | } 142 | -------------------------------------------------------------------------------- /webapp/src/setupTests.js: -------------------------------------------------------------------------------- 1 | // jest-dom adds custom jest matchers for asserting on DOM nodes. 2 | // allows you to do things like: 3 | // expect(element).toHaveTextContent(/react/i) 4 | // learn more: https://github.com/testing-library/jest-dom 5 | import '@testing-library/jest-dom/extend-expect' 6 | -------------------------------------------------------------------------------- /webapp/src/widgets/NavBar/NavBar.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { useSelector, useDispatch } from 'react-redux' 3 | import Navbar from 'react-bootstrap/Navbar' 4 | import Nav from 'react-bootstrap/Nav' 5 | import { LinkContainer } from 'react-router-bootstrap' 6 | import { selectIsAuthenticated, logout } from '../../features/userContext' 7 | 8 | export default function NavBar() { 9 | const isAuthenticated = useSelector(selectIsAuthenticated) 10 | const dispatch = useDispatch() 11 | return ( 12 | 13 | 14 | Vice Software 15 | 16 | 21 | 26 | 31 | 36 | 37 | {isAuthenticated ? ( 38 | 39 | dispatch(logout())}> 40 | Logout 41 | 42 | 43 | ) : ( 44 | 45 | Sign In 46 | 47 | )} 48 | 49 | 50 | ) 51 | } 52 | -------------------------------------------------------------------------------- /webapp/src/widgets/NavBar/index.js: -------------------------------------------------------------------------------- 1 | import NavBar from './NavBar' 2 | 3 | export default NavBar 4 | -------------------------------------------------------------------------------- /webapp/src/widgets/Page/Page.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import Container from 'react-bootstrap/Container' 3 | import './page.css' 4 | 5 | export default function Page({ children }) { 6 | return ( 7 |
8 | {children} 9 |
10 | ) 11 | } 12 | -------------------------------------------------------------------------------- /webapp/src/widgets/Page/page.css: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vicesoftware/react-redux-hooks-boilerplate/7f414cb6172b8d57721e3e2e300504d423455980/webapp/src/widgets/Page/page.css -------------------------------------------------------------------------------- /webapp/src/widgets/busyIndicator/BusyIndicator.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { useSelector } from 'react-redux' 3 | import { getGlobalBusyIndicator } from './busyIndicator.selectors' 4 | import './busyIndicator.css' 5 | 6 | export default function BusyIndicator({ children }) { 7 | const show = useSelector(getGlobalBusyIndicator) 8 | 9 | const hasContentToDisplay = 10 | !show && children && (children.length === undefined || children.length > 0) 11 | 12 | return ( 13 | 14 | {show ? ( 15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 | ) : ( 26 | 27 | {hasContentToDisplay ? children : } 28 | 29 | )} 30 |
31 | ) 32 | } 33 | 34 | function ContentNotFound() { 35 | return

No content found

36 | } 37 | -------------------------------------------------------------------------------- /webapp/src/widgets/busyIndicator/busyIndicator.css: -------------------------------------------------------------------------------- 1 | #floatBarsG { 2 | position: relative; 3 | width: 90px; 4 | height: 11px; 5 | margin-top: 1em; 6 | margin-bottom: 1em; 7 | } 8 | 9 | .floatBarsG { 10 | position: absolute; 11 | top: 0; 12 | background-color: rgba(255, 255, 255, 0.26); 13 | width: 11px; 14 | height: 11px; 15 | animation-name: bounce_floatBarsG; 16 | -o-animation-name: bounce_floatBarsG; 17 | -ms-animation-name: bounce_floatBarsG; 18 | -webkit-animation-name: bounce_floatBarsG; 19 | -moz-animation-name: bounce_floatBarsG; 20 | animation-duration: 0.775s; 21 | -o-animation-duration: 0.775s; 22 | -ms-animation-duration: 0.775s; 23 | -webkit-animation-duration: 0.775s; 24 | -moz-animation-duration: 0.775s; 25 | animation-iteration-count: infinite; 26 | -o-animation-iteration-count: infinite; 27 | -ms-animation-iteration-count: infinite; 28 | -webkit-animation-iteration-count: infinite; 29 | -moz-animation-iteration-count: infinite; 30 | animation-direction: normal; 31 | -o-animation-direction: normal; 32 | -ms-animation-direction: normal; 33 | -webkit-animation-direction: normal; 34 | -moz-animation-direction: normal; 35 | transform: scale(0.3); 36 | -o-transform: scale(0.3); 37 | -ms-transform: scale(0.3); 38 | -webkit-transform: scale(0.3); 39 | -moz-transform: scale(0.3); 40 | } 41 | 42 | #floatBarsG_1 { 43 | left: 0; 44 | animation-delay: 0.316s; 45 | -o-animation-delay: 0.316s; 46 | -ms-animation-delay: 0.316s; 47 | -webkit-animation-delay: 0.316s; 48 | -moz-animation-delay: 0.316s; 49 | } 50 | 51 | #floatBarsG_2 { 52 | left: 11px; 53 | animation-delay: 0.3925s; 54 | -o-animation-delay: 0.3925s; 55 | -ms-animation-delay: 0.3925s; 56 | -webkit-animation-delay: 0.3925s; 57 | -moz-animation-delay: 0.3925s; 58 | } 59 | 60 | #floatBarsG_3 { 61 | left: 22px; 62 | animation-delay: 0.469s; 63 | -o-animation-delay: 0.469s; 64 | -ms-animation-delay: 0.469s; 65 | -webkit-animation-delay: 0.469s; 66 | -moz-animation-delay: 0.469s; 67 | } 68 | 69 | #floatBarsG_4 { 70 | left: 34px; 71 | animation-delay: 0.5455s; 72 | -o-animation-delay: 0.5455s; 73 | -ms-animation-delay: 0.5455s; 74 | -webkit-animation-delay: 0.5455s; 75 | -moz-animation-delay: 0.5455s; 76 | } 77 | 78 | #floatBarsG_5 { 79 | left: 45px; 80 | animation-delay: 0.622s; 81 | -o-animation-delay: 0.622s; 82 | -ms-animation-delay: 0.622s; 83 | -webkit-animation-delay: 0.622s; 84 | -moz-animation-delay: 0.622s; 85 | } 86 | 87 | #floatBarsG_6 { 88 | left: 56px; 89 | animation-delay: 0.6985s; 90 | -o-animation-delay: 0.6985s; 91 | -ms-animation-delay: 0.6985s; 92 | -webkit-animation-delay: 0.6985s; 93 | -moz-animation-delay: 0.6985s; 94 | } 95 | 96 | #floatBarsG_7 { 97 | left: 67px; 98 | animation-delay: 0.775s; 99 | -o-animation-delay: 0.775s; 100 | -ms-animation-delay: 0.775s; 101 | -webkit-animation-delay: 0.775s; 102 | -moz-animation-delay: 0.775s; 103 | } 104 | 105 | #floatBarsG_8 { 106 | left: 79px; 107 | animation-delay: 0.8615s; 108 | -o-animation-delay: 0.8615s; 109 | -ms-animation-delay: 0.8615s; 110 | -webkit-animation-delay: 0.8615s; 111 | -moz-animation-delay: 0.8615s; 112 | } 113 | 114 | @keyframes bounce_floatBarsG { 115 | 0% { 116 | transform: scale(1); 117 | background-color: rgba(255, 255, 255, 0.22); 118 | } 119 | 120 | 100% { 121 | transform: scale(0.3); 122 | background-color: rgba(0, 0, 0, 0.28); 123 | } 124 | } 125 | 126 | @-o-keyframes bounce_floatBarsG { 127 | 0% { 128 | -o-transform: scale(1); 129 | background-color: rgba(255, 255, 255, 0.22); 130 | } 131 | 132 | 100% { 133 | -o-transform: scale(0.3); 134 | background-color: rgba(0, 0, 0, 0.28); 135 | } 136 | } 137 | 138 | @-ms-keyframes bounce_floatBarsG { 139 | 0% { 140 | -ms-transform: scale(1); 141 | background-color: rgba(255, 255, 255, 0.22); 142 | } 143 | 144 | 100% { 145 | -ms-transform: scale(0.3); 146 | background-color: rgba(0, 0, 0, 0.28); 147 | } 148 | } 149 | 150 | @-webkit-keyframes bounce_floatBarsG { 151 | 0% { 152 | -webkit-transform: scale(1); 153 | background-color: rgba(255, 255, 255, 0.22); 154 | } 155 | 156 | 100% { 157 | -webkit-transform: scale(0.3); 158 | background-color: rgba(0, 0, 0, 0.28); 159 | } 160 | } 161 | 162 | @-moz-keyframes bounce_floatBarsG { 163 | 0% { 164 | -moz-transform: scale(1); 165 | background-color: rgba(255, 255, 255, 0.22); 166 | } 167 | 168 | 100% { 169 | -moz-transform: scale(0.3); 170 | background-color: rgba(0, 0, 0, 0.28); 171 | } 172 | } 173 | -------------------------------------------------------------------------------- /webapp/src/widgets/busyIndicator/busyIndicator.selectors.js: -------------------------------------------------------------------------------- 1 | export const getGlobalBusyIndicator = (state) => 2 | getNamedBusyIndicator('global')(state) 3 | export const getNamedBusyIndicator = (name) => (state) => 4 | !!state.busyIndicator[name] 5 | -------------------------------------------------------------------------------- /webapp/src/widgets/busyIndicator/busyIndicator.slice.js: -------------------------------------------------------------------------------- 1 | import { createSlice } from '@reduxjs/toolkit' 2 | 3 | const initialState = { 4 | global: 0, 5 | } 6 | 7 | export default createSlice({ 8 | name: 'busyIndicator', 9 | initialState, 10 | reducers: { 11 | incrementBusyIndicator(state, action) { 12 | if (action.payload) { 13 | if (!action.payload) { 14 | state[action.payload] = 1 15 | } else { 16 | state[action.payload]++ 17 | } 18 | return 19 | } 20 | 21 | state.global++ 22 | }, 23 | decrementBusyIndicator(state, action) { 24 | if (action.payload) { 25 | if (!action.payload) { 26 | throw new Error('Attempted to decrement an empty busy indicator') 27 | } else { 28 | state[action.payload]-- 29 | } 30 | return 31 | } 32 | 33 | if (!state.global) { 34 | throw new Error('Attempted to decrement an empty busy indicator') 35 | } 36 | 37 | state.global-- 38 | }, 39 | }, 40 | }) 41 | -------------------------------------------------------------------------------- /webapp/src/widgets/busyIndicator/index.js: -------------------------------------------------------------------------------- 1 | import * as busyIndicatorSelectors from './busyIndicator.selectors' 2 | import BusyIndicator from './BusyIndicator' 3 | import slice from './busyIndicator.slice' 4 | 5 | export const { 6 | name, 7 | actions: { incrementBusyIndicator, decrementBusyIndicator }, 8 | reducer, 9 | } = slice 10 | 11 | export const { 12 | getGlobalBusyIndicator, 13 | getNamedBusyIndicator, 14 | } = busyIndicatorSelectors 15 | 16 | export default BusyIndicator 17 | -------------------------------------------------------------------------------- /webapp/src/widgets/modal/Modal.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import Modal from 'react-bootstrap/Modal' 3 | import { useSelector } from 'react-redux' 4 | import { selectShowModal } from './modal.selectors' 5 | import { actions } from './modal.slice' 6 | 7 | const { useHideModal } = actions 8 | 9 | export default function ViceModal({ children }) { 10 | const show = useSelector(selectShowModal) 11 | const hideModal = useHideModal() 12 | 13 | return ( 14 | 15 | {children} 16 | 17 | ) 18 | } 19 | -------------------------------------------------------------------------------- /webapp/src/widgets/modal/index.js: -------------------------------------------------------------------------------- 1 | import Modal from './Modal' 2 | import slice from './modal.slice' 3 | import * as selectors from './modal.selectors' 4 | 5 | export const { 6 | name, 7 | actions: { showModal, hideModal }, 8 | reducer, 9 | } = slice 10 | 11 | export const { selectShowModal } = selectors 12 | 13 | export default Modal 14 | -------------------------------------------------------------------------------- /webapp/src/widgets/modal/modal.selectors.js: -------------------------------------------------------------------------------- 1 | export const selectShowModal = (state) => state.modal.show 2 | -------------------------------------------------------------------------------- /webapp/src/widgets/modal/modal.slice.js: -------------------------------------------------------------------------------- 1 | import { createSlice } from '@reduxjs/toolkit' 2 | 3 | const initialState = { show: false } 4 | 5 | const slice = createSlice({ 6 | name: 'modal', 7 | initialState, 8 | reducers: { 9 | showModal(state, action) { 10 | state.show = true 11 | }, 12 | hideModal(state, action) { 13 | state.show = false 14 | }, 15 | }, 16 | }) 17 | 18 | export default slice 19 | 20 | export const { name, actions, reducer } = slice 21 | --------------------------------------------------------------------------------