├── .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 | [](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 | [](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 | 
181 |
182 | 1. Select the `Feature Module` template
183 |
184 | 
185 |
186 | 1. Enter the name of your new feature
187 |
188 | 
189 |
190 | Now you will have a new feature module created like the one showed below.
191 |
192 | 
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 | 
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 | 
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 |
491 | {demo &&
492 | demo
493 | .filter((item) => (filter ? item.includes(filter) : true))
494 | .map((item) => {item} )}
495 |
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 | You need to enable JavaScript to run this app.
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 |
48 | Email address
49 |
57 | {/*
58 | We'll never share your email with anyone else.
59 | */}
60 |
61 |
62 |
63 | Password
64 |
72 |
73 |
74 | Submit
75 |
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 | dispatch(setNoBusySpinner(false))}
54 | variant='info'
55 | >
56 | Reload with Busy Spinnner
57 |
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 | go to settings page and turn on useCaching
96 |
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 |
101 |
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 |
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 | navigate to settings page
116 | refresh the browser on the settings page
117 |
118 | after the settings page reloads turn check the noBusySpinner
119 | option
120 |
121 |
122 | {' '}
123 | navigate back to users page and you won't see a busy
124 | indicator while the data is loading
125 |
126 |
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 |
132 | determine there is already a request in progress so it
133 | won't call the API again
134 |
135 |
136 | determine that the busyIndicator isn't being showed but
137 | should be so will turn the busy indicator on
138 |
139 |
140 |
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 |
17 |
18 | Users
19 |
20 |
21 |
22 |
23 | Authenticated
24 |
25 |
26 |
27 |
28 | Authorized
29 |
30 |
31 |
32 |
33 | Settings
34 |
35 |
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 |
--------------------------------------------------------------------------------